Skip to content

Commit fb8abd6

Browse files
committed
Migrate workspace scripts from .cmux to .mux with backward compatibility
- Export MUX_DIR_NAME and LEGACY_MUX_DIR_NAME constants - Update script discovery to scan both .mux/scripts and .cmux/scripts - Update script execution to try canonical path first, then legacy fallback - Update all UI text and documentation to reference .mux/scripts - Add tests for backward compatibility with legacy .cmux paths Change-Id: I1904a0f559b07b1478ff6019bb2b4394a60e057a Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent b307ac3 commit fb8abd6

File tree

8 files changed

+211
-75
lines changed

8 files changed

+211
-75
lines changed

docs/scripts.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,21 @@ Execute custom scripts from your workspace using slash commands or let the AI Ag
44

55
## Overview
66

7-
Scripts are stored in `.cmux/scripts/` within each workspace. They serve two purposes:
7+
Scripts are stored in `.mux/scripts/` within each workspace. They serve two purposes:
88

99
1. **Human Use**: Executable via `/script <name>` or `/s <name>` in chat.
1010
2. **Agent Use**: Automatically exposed to the AI as tools (`script_<name>`), allowing the agent to run complex workflows you define.
1111

1212
Scripts run in the workspace directory with full access to project secrets and environment variables.
1313

14-
**Key Point**: Scripts are workspace-specific. Each workspace has its own custom toolkit defined in `.cmux/scripts/`.
14+
**Key Point**: Scripts are workspace-specific. Each workspace has its own custom toolkit defined in `.mux/scripts/`.
1515

1616
## Creating Scripts
1717

1818
1. **Create the scripts directory**:
1919

2020
```bash
21-
mkdir -p .cmux/scripts
21+
mkdir -p .mux/scripts
2222
```
2323

2424
2. **Add an executable script**:
@@ -39,12 +39,12 @@ Scripts run in the workspace directory with full access to project secrets and e
3939
3. **Make it executable**:
4040

4141
```bash
42-
chmod +x .cmux/scripts/deploy
42+
chmod +x .mux/scripts/deploy
4343
```
4444

4545
## Agent Integration (AI Tools)
4646

47-
Every executable script in `.cmux/scripts/` is automatically registered as a tool for the AI Agent.
47+
Every executable script in `.mux/scripts/` is automatically registered as a tool for the AI Agent.
4848

4949
- **Tool Name**: `script_<name>` (e.g., `deploy` -> `script_deploy`, `run-tests` -> `script_run_tests`)
5050
- **Tool Description**: Taken from the script's header comment (`# Description: ...`).
@@ -185,16 +185,16 @@ curl -sL "$1"
185185

186186
## Script Discovery
187187

188-
- Scripts are discovered automatically from `.cmux/scripts/` in the current workspace.
188+
- Scripts are discovered automatically from `.mux/scripts/` in the current workspace.
189189
- Discovery is cached for performance but refreshes intelligently.
190190
- **Sanitization**: Script names are sanitized for tool use (e.g., `my-script.sh` -> `script_my_script_sh`).
191191

192192
## Troubleshooting
193193

194194
**Script not appearing in suggestions or tools?**
195195

196-
- Ensure file is executable: `chmod +x .cmux/scripts/scriptname`
197-
- Verify file is in `.cmux/scripts/` directory.
196+
- Ensure file is executable: `chmod +x .mux/scripts/scriptname`
197+
- Verify file is in `.mux/scripts/` directory.
198198
- Check for valid description header.
199199

200200
**Agent using script incorrectly?**

src/browser/components/ChatInputToasts.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ export const createCommandToast = (parsed: ParsedCommand): Toast | null => {
133133
id: Date.now().toString(),
134134
type: "error",
135135
title: "Script Command",
136-
message: "Execute a script from .cmux/scripts/",
136+
message: "Execute a script from .mux/scripts/",
137137
solution: (
138138
<>
139139
<SolutionLabel>Usage:</SolutionLabel>
@@ -149,7 +149,7 @@ export const createCommandToast = (parsed: ParsedCommand): Toast | null => {
149149
<br />
150150
<br />
151151
<SolutionLabel>Note:</SolutionLabel>
152-
Scripts must be executable (chmod +x) and located in .cmux/scripts/
152+
Scripts must be executable (chmod +x) and located in .mux/scripts/
153153
</>
154154
),
155155
};

src/browser/utils/slashCommands/registry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,7 @@ const newCommandDefinition: SlashCommandDefinition = {
598598

599599
const scriptCommandDefinition: SlashCommandDefinition = {
600600
key: "script",
601-
description: "Execute a script from .cmux/scripts/",
601+
description: "Execute a script from .mux/scripts/",
602602
handler: ({ cleanRemainingTokens }): ParsedCommand => {
603603
if (cleanRemainingTokens.length === 0) {
604604
return { type: "script-help" };
@@ -618,7 +618,7 @@ const scriptCommandDefinition: SlashCommandDefinition = {
618618
if (stage === 1 && context.availableScripts) {
619619
const scripts = context.availableScripts.map((script) => ({
620620
key: script.name,
621-
description: script.description ?? `Run .cmux/scripts/${script.name}`,
621+
description: script.description ?? `Run .mux/scripts/${script.name}`,
622622
}));
623623

624624
return filterAndMapSuggestions(scripts, partialToken, (definition) => ({

src/common/constants/paths.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { existsSync, renameSync, symlinkSync } from "fs";
22
import { homedir } from "os";
33
import { join } from "path";
44

5-
const LEGACY_MUX_DIR_NAME = ".cmux";
6-
const MUX_DIR_NAME = ".mux";
5+
export const LEGACY_MUX_DIR_NAME = ".cmux";
6+
export const MUX_DIR_NAME = ".mux";
77

88
/**
99
* Migrate from the legacy ~/.cmux directory into ~/.mux for rebranded installs.

src/node/services/scriptRunner.ts

Lines changed: 30 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import * as path from "path";
22
import { type Runtime } from "@/node/runtime/Runtime";
3-
import { getScriptPath, getScriptsDir } from "@/utils/scripts/discovery";
3+
import {
4+
getScriptPath,
5+
getScriptsDir,
6+
getLegacyScriptPath,
7+
getLegacyScriptsDir,
8+
} from "@/utils/scripts/discovery";
49
import { createBashTool } from "@/node/services/tools/bash";
510
import { writeFileString, readFileString, execBuffered } from "@/node/utils/runtime/helpers";
611
import { Ok, Err, type Result } from "@/common/types/result";
@@ -60,43 +65,34 @@ export async function runWorkspaceScript(
6065
}
6166

6267
// Resolve real paths to handle symlinks and prevent escape
63-
const scriptPath = getScriptPath(workspacePath, scriptName);
64-
const scriptsDir = getScriptsDir(workspacePath);
68+
const canonicalScriptPath = getScriptPath(workspacePath, scriptName);
69+
const canonicalScriptsDir = getScriptsDir(workspacePath);
70+
71+
const legacyScriptPath = getLegacyScriptPath(workspacePath, scriptName);
72+
const legacyScriptsDir = getLegacyScriptsDir(workspacePath);
6573

6674
let resolvedScriptPath: string;
6775
let resolvedScriptsDir: string;
6876

6977
try {
70-
// Use runtime.resolvePath (which should behave like realpath) if available,
71-
// otherwise rely on the runtime-specific normalization.
72-
// Ideally, we want `realpath` behavior here.
73-
// Since the Runtime interface doesn't strictly expose `realpath`, we'll rely on
74-
// the filesystem (via runtime.exec or similar) or assume normalizePath+standard checks are mostly sufficient.
75-
// HOWEVER, for local runtime we can use fs.realpath. For SSH, we might need a command.
76-
// To keep it simple and robust within the existing abstractions:
77-
// We will use the runtime to resolve the path if possible, but `runtime.resolvePath`
78-
// is documented to expand tildes, not necessarily resolve symlinks (though it often does).
79-
80-
// BUT, to address the specific review concern about symlinks:
81-
// We should try to get the canonical path.
82-
// Note: checking containment purely by string path on un-resolved paths is weak against symlinks.
83-
84-
// Strategy:
85-
// 1. Get the script path (constructed from workspace + script name).
86-
// 2. Get the scripts dir.
87-
// 3. Ask runtime to resolve them to absolute, canonical paths (resolving symlinks).
88-
// (If runtime doesn't support explicit symlink resolution in its API, we might be limited).
89-
// The review implies we *should* do this.
90-
// Let's add a helper or use `runtime.resolvePath` which claims to resolve to "absolute, canonical form".
91-
92-
resolvedScriptPath = await runtime.resolvePath(scriptPath);
93-
resolvedScriptsDir = await runtime.resolvePath(scriptsDir);
78+
// Try canonical path first
79+
const candidatePath = await runtime.resolvePath(canonicalScriptPath);
80+
await runtime.stat(candidatePath); // Throws if not exists
81+
resolvedScriptPath = candidatePath;
82+
resolvedScriptsDir = await runtime.resolvePath(canonicalScriptsDir);
9483
} catch {
95-
// If we can't resolve paths (e.g. file doesn't exist), we can't verify containment securely.
96-
// But we already established the script *must* exist in step 2 (which we moved up or will do).
97-
// Actually step 2 is below. Let's do existence check + resolution together or accept that
98-
// resolution failure implies non-existence.
99-
return Err(`Script not found or inaccessible: ${scriptName}`);
84+
try {
85+
// Try legacy path fallback
86+
const candidateLegacyPath = await runtime.resolvePath(legacyScriptPath);
87+
await runtime.stat(candidateLegacyPath); // Throws if not exists
88+
resolvedScriptPath = candidateLegacyPath;
89+
resolvedScriptsDir = await runtime.resolvePath(legacyScriptsDir);
90+
} catch {
91+
// Both missing. Default to canonical so the error message later (in step 2)
92+
// correctly reports the canonical path as missing.
93+
resolvedScriptPath = await runtime.resolvePath(canonicalScriptPath);
94+
resolvedScriptsDir = await runtime.resolvePath(canonicalScriptsDir);
95+
}
10096
}
10197

10298
// Use runtime-aware normalization on the RESOLVED paths
@@ -115,11 +111,11 @@ export async function runWorkspaceScript(
115111
try {
116112
const stat = await runtime.stat(resolvedScriptPath);
117113
if (stat.isDirectory) {
118-
return Err(`Script not found: .cmux/scripts/${scriptName}`);
114+
return Err(`Script is a directory: ${scriptName}`);
119115
}
120116
} catch {
121117
return Err(
122-
`Script not found: .cmux/scripts/${scriptName}. Create the script in your workspace and make it executable (chmod +x).`
118+
`Script not found: .mux/scripts/${scriptName}. Create the script in your workspace and make it executable (chmod +x).`
123119
);
124120
}
125121

src/utils/scripts/discovery.test.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, test, expect } from "bun:test";
22
import type { Runtime } from "@/node/runtime/Runtime";
3-
import { listScripts, getScriptPath } from "./discovery";
3+
import { listScripts, getScriptPath, getLegacyScriptPath } from "./discovery";
44
import * as path from "path";
55

66
// Mock runtime for testing
@@ -215,22 +215,60 @@ describe("listScripts", () => {
215215
},
216216
]);
217217
});
218+
219+
test("deduplicates scripts found in both locations (prefers canonical)", async () => {
220+
// Construct output where the same script appears twice
221+
// Since our implementation scans canonical first, the first occurrence is canonical
222+
const output = [
223+
`${separator}dup-script`,
224+
"IS_EXECUTABLE:1",
225+
"#!/bin/bash",
226+
"# Description: Canonical version",
227+
"echo canonical",
228+
"",
229+
`${separator}dup-script`,
230+
"IS_EXECUTABLE:1",
231+
"#!/bin/bash",
232+
"# Description: Legacy version",
233+
"echo legacy",
234+
].join("\n");
235+
236+
const runtime = createMockRuntime(new Map([[separator, { stdout: output, exitCode: 0 }]]));
237+
238+
const scripts = await listScripts(runtime, "/test/workspace/dup");
239+
expect(scripts).toEqual([
240+
{
241+
name: "dup-script",
242+
description: "Canonical version",
243+
isExecutable: true,
244+
},
245+
]);
246+
});
218247
});
219248

220249
describe("getScriptPath", () => {
221250
test("uses POSIX separators for POSIX workspace paths", () => {
222251
const workspacePath = "/home/user/workspace";
223252
const scriptName = "test.sh";
224253
// Explicitly check for forward slashes regardless of host OS
225-
const expected = "/home/user/workspace/.cmux/scripts/test.sh";
254+
const expected = "/home/user/workspace/.mux/scripts/test.sh";
226255
expect(getScriptPath(workspacePath, scriptName)).toBe(expected);
227256
});
228257

229258
test("uses host separators (default) for Windows workspace paths", () => {
230259
const workspacePath = "C:\\Users\\user\\workspace";
231260
const scriptName = "test.bat";
232261
// Should use path.join, which depends on the host OS running the test
233-
const expected = path.join(workspacePath, ".cmux", "scripts", scriptName);
262+
const expected = path.join(workspacePath, ".mux", "scripts", scriptName);
234263
expect(getScriptPath(workspacePath, scriptName)).toBe(expected);
235264
});
236265
});
266+
267+
describe("getLegacyScriptPath", () => {
268+
test("returns path in .cmux", () => {
269+
const workspacePath = "/home/user/workspace";
270+
const scriptName = "test.sh";
271+
const expected = "/home/user/workspace/.cmux/scripts/test.sh";
272+
expect(getLegacyScriptPath(workspacePath, scriptName)).toBe(expected);
273+
});
274+
});

0 commit comments

Comments
 (0)