chamba 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +53 -14
- package/README.md +28 -8
- package/dist/commands/advanced.js +7 -278
- package/dist/commands/dev.js +11 -612
- package/dist/commands/doctor.js +1 -29
- package/dist/commands/menu.js +1 -80
- package/dist/commands/onboard.js +6 -230
- package/dist/commands/settings.js +15 -349
- package/dist/lib/agent-commands.js +2 -0
- package/dist/lib/agent-context.js +6 -210
- package/dist/lib/browser.js +1 -40
- package/dist/lib/chamba-yaml.js +10 -191
- package/dist/lib/constants.js +1 -125
- package/dist/lib/dockerfile-builder.js +41 -261
- package/dist/lib/env.js +2 -78
- package/dist/lib/git-env.js +1 -21
- package/dist/lib/global-config.js +4 -66
- package/dist/lib/pnpm-store.js +1 -19
- package/dist/lib/ports.js +1 -210
- package/dist/lib/safe-rm.js +1 -36
- package/dist/lib/sessions.js +2 -34
- package/dist/lib/shadows.js +1 -176
- package/dist/lib/skills.js +2 -0
- package/dist/lib/templates.js +1 -0
- package/dist/lib/webterm.js +1 -305
- package/dist/lib/workspace-identity.js +4 -260
- package/package.json +4 -4
- package/templates/Dockerfile +12 -19
- package/templates/claude-statusline.sh +5 -7
- package/templates/context/baseline.md +2 -0
- package/templates/pane-apps/client/assets/c-BNa-DeUl.json +3552 -0
- package/templates/pane-apps/client/assets/cpp-B-T4NRoH.json +20239 -0
- package/templates/pane-apps/client/assets/csharp-BbxHChhA.json +6010 -0
- package/templates/pane-apps/client/assets/css-CWCxuwmz.json +1860 -0
- package/templates/pane-apps/client/assets/diff-B-2N89g8.json +154 -0
- package/templates/pane-apps/client/assets/docker-DWTvYW26.json +103 -0
- package/templates/pane-apps/client/assets/files-D0nJgFAA.js +12 -0
- package/templates/pane-apps/client/assets/files-h0K1vZyQ.css +1 -0
- package/templates/pane-apps/client/assets/go-B2hyYeNg.json +3156 -0
- package/templates/pane-apps/client/assets/graphql-Z6Id2aNO.json +1292 -0
- package/templates/pane-apps/client/assets/html-BtzvlIfu.json +2627 -0
- package/templates/pane-apps/client/assets/ini-Bn3Twap1.json +108 -0
- package/templates/pane-apps/client/assets/java-C7t2dwLO.json +1885 -0
- package/templates/pane-apps/client/assets/javascript-DuC2v0C0.json +5996 -0
- package/templates/pane-apps/client/assets/json-CwdkWeV5.json +206 -0
- package/templates/pane-apps/client/assets/jsonc-MYWDbTU3.json +206 -0
- package/templates/pane-apps/client/assets/jsx-Cn8EZuIC.json +5996 -0
- package/templates/pane-apps/client/assets/kotlin-CFNOz1gw.json +580 -0
- package/templates/pane-apps/client/assets/make-BVnlFCcl.json +629 -0
- package/templates/pane-apps/client/assets/markdown-B4Xk2WM_.json +3263 -0
- package/templates/pane-apps/client/assets/php-CAQzQmmI.json +4188 -0
- package/templates/pane-apps/client/assets/python-CYQee8m4.json +4174 -0
- package/templates/pane-apps/client/assets/reviews-BsoGZ35r.css +1 -0
- package/templates/pane-apps/client/assets/reviews-iW0Mbn4e.js +153 -0
- package/templates/pane-apps/client/assets/ruby-D7R58tus.json +3114 -0
- package/templates/pane-apps/client/assets/rust-BFBbFiei.json +1103 -0
- package/templates/pane-apps/client/assets/scss-TQRhBkPG.json +1874 -0
- package/templates/pane-apps/client/assets/shellscript-DieAkC4W.json +2375 -0
- package/templates/pane-apps/client/assets/specs-D9z3neR-.js +13 -0
- package/templates/pane-apps/client/assets/sql-Bxd030LV.json +634 -0
- package/templates/pane-apps/client/assets/toml-ChrKGzsV.json +430 -0
- package/templates/pane-apps/client/assets/tsx-D2e90MFw.json +5996 -0
- package/templates/pane-apps/client/assets/typescript-DonXQ_n1.json +5747 -0
- package/templates/pane-apps/client/assets/xml-B17r2RXF.json +382 -0
- package/templates/pane-apps/client/assets/yaml-CKn4h1zy.json +627 -0
- package/templates/pane-apps/client/reviews/index.html +15 -0
- package/templates/pane-apps/client/specs/index.html +3 -2
- package/templates/pane-apps/server/reviews.mjs +20 -0
- package/templates/pane-apps/server/specs.mjs +5 -1588
- package/templates/skills/chamba-statusline/SKILL.md +7 -19
- package/templates/skills/dx-review/SKILL.md +123 -0
- package/templates/skills/dx-review/references/acts.md +162 -0
- package/templates/skills/dx-spec/SKILL.md +223 -27
- package/templates/skills/dx-spec/references/principles-template.md +2 -0
- package/templates/skills/dx-spec/references/review-guide.md +4 -50
- package/templates/skills/dx-spec/references/spec-guide.md +24 -4
- package/templates/skills/dx-spec/references/stages.md +143 -58
- package/templates/skills/dx-spec-execute/SKILL.md +148 -35
- package/templates/startup.mjs +10 -4
- package/templates/tool-helper.sh +166 -0
- package/templates/webterm/README.md +60 -26
- package/templates/webterm/color.js +61 -0
- package/templates/webterm/config.js +35 -4
- package/templates/webterm/context/claude.md +0 -1
- package/templates/webterm/public/app/alerts.js +4 -4
- package/templates/webterm/public/app/anchored.js +81 -0
- package/templates/webterm/public/app/connection.js +9 -0
- package/templates/webterm/public/app/dom.js +2 -0
- package/templates/webterm/public/app/frames.js +51 -17
- package/templates/webterm/public/app/main.js +22 -8
- package/templates/webterm/public/app/new-session.js +13 -41
- package/templates/webterm/public/app/palette.js +58 -0
- package/templates/webterm/public/app/pane-shape.js +167 -0
- package/templates/webterm/public/app/pane-shell.js +199 -134
- package/templates/webterm/public/app/pane.js +33 -24
- package/templates/webterm/public/app/reviews-host.js +15 -0
- package/templates/webterm/public/app/specs-host.js +12 -218
- package/templates/webterm/public/app/state.js +5 -0
- package/templates/webterm/public/app/tabs.js +6 -2
- package/templates/webterm/public/app/theme.js +75 -27
- package/templates/webterm/public/app/tool-host.js +302 -0
- package/templates/webterm/public/app/workspace-color.js +192 -0
- package/templates/webterm/public/index.html +17 -3
- package/templates/webterm/public/styles.css +143 -21
- package/templates/webterm/server.js +198 -150
- package/templates/webterm/tools/commands.js +61 -0
- package/templates/webterm/tools/index.js +25 -0
- package/templates/webterm/tools/mount.js +337 -0
- package/templates/webterm/tools/paths.js +34 -0
- package/templates/webterm/tools/reviews.js +133 -0
- package/templates/webterm/tools/specs.js +154 -0
- package/templates/webterm/typed-line.js +70 -7
- package/templates/context/context-usage.md +0 -1
- package/templates/context-usage.sh +0 -266
- package/templates/pane-apps/client/assets/specs-B1970L17.css +0 -1
- package/templates/pane-apps/client/assets/specs-cEee_SPn.js +0 -23
- package/templates/skills/dx-spec-config/SKILL.md +0 -313
- package/templates/skills/dx-spec-config/references/principles-template.md +0 -12
- package/templates/specs.sh +0 -106
- package/templates/webterm/specs.js +0 -358
|
@@ -1,349 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const yaml = readChambaYaml(ctx.workspaceRoot);
|
|
17
|
-
if (!yaml) {
|
|
18
|
-
log.error("chamba.yaml not found or invalid.");
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
const patterns = yaml.shadow_paths ?? [];
|
|
22
|
-
if (patterns.length > 0) {
|
|
23
|
-
const lines = patterns.map((p) => {
|
|
24
|
-
const hits = countPatternHits(p, ctx.workspaceRoot);
|
|
25
|
-
return ` ${p} (${hits} ${hits === 1 ? "match" : "matches"})`;
|
|
26
|
-
});
|
|
27
|
-
note(lines.join("\n"), "Shadow patterns");
|
|
28
|
-
}
|
|
29
|
-
else {
|
|
30
|
-
log.info("No shadow patterns configured.");
|
|
31
|
-
}
|
|
32
|
-
log.message("Shadow patterns block the agent from seeing matching host paths -\n" +
|
|
33
|
-
"the container gets an empty, isolated copy instead.\n" +
|
|
34
|
-
"Supports gitignore-style patterns (e.g. node_modules, .env*).\n" +
|
|
35
|
-
"Git-tracked paths are skipped to avoid worktree diversions.");
|
|
36
|
-
const options = [{ value: "add", label: "Add pattern or path" }];
|
|
37
|
-
if (patterns.length > 0) {
|
|
38
|
-
options.push({ value: "remove", label: "Remove patterns" });
|
|
39
|
-
}
|
|
40
|
-
options.push({ value: "back", label: "← Back" });
|
|
41
|
-
const action = await select({ message: "Shadow paths:", options });
|
|
42
|
-
if (isCancel(action) || action === "back")
|
|
43
|
-
return;
|
|
44
|
-
if (action === "add") {
|
|
45
|
-
await addShadowPattern(ctx);
|
|
46
|
-
}
|
|
47
|
-
else if (action === "remove") {
|
|
48
|
-
await removeShadowPatterns(ctx);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
async function addShadowPattern(ctx) {
|
|
52
|
-
const yaml = readChambaYaml(ctx.workspaceRoot);
|
|
53
|
-
if (!yaml)
|
|
54
|
-
return;
|
|
55
|
-
const patterns = [...(yaml.shadow_paths ?? [])];
|
|
56
|
-
const typeChoice = await select({
|
|
57
|
-
message: "Add:",
|
|
58
|
-
options: [
|
|
59
|
-
{ value: "pattern", label: "Pattern", hint: "gitignore-style (e.g. .env*, *.log)" },
|
|
60
|
-
{ value: "path", label: "Specific path", hint: "pick a file or directory" },
|
|
61
|
-
],
|
|
62
|
-
});
|
|
63
|
-
if (isCancel(typeChoice))
|
|
64
|
-
return;
|
|
65
|
-
if (typeChoice === "pattern") {
|
|
66
|
-
const input = await text({
|
|
67
|
-
message: "Pattern:",
|
|
68
|
-
placeholder: "e.g. .env* or node_modules",
|
|
69
|
-
validate: (v) => {
|
|
70
|
-
const p = (v ?? "").trim();
|
|
71
|
-
if (p.length === 0)
|
|
72
|
-
return "Pattern cannot be empty";
|
|
73
|
-
if (patterns.includes(p))
|
|
74
|
-
return "Pattern already exists";
|
|
75
|
-
return undefined;
|
|
76
|
-
},
|
|
77
|
-
});
|
|
78
|
-
if (isCancel(input))
|
|
79
|
-
return;
|
|
80
|
-
const pattern = input.trim();
|
|
81
|
-
const hits = countPatternHits(pattern, ctx.workspaceRoot);
|
|
82
|
-
patterns.push(pattern);
|
|
83
|
-
log.info(`Added: ${pattern} (${hits} ${hits === 1 ? "match" : "matches"})`);
|
|
84
|
-
}
|
|
85
|
-
else {
|
|
86
|
-
const selected = await path({
|
|
87
|
-
message: "Path to shadow:",
|
|
88
|
-
root: ctx.workspaceRoot,
|
|
89
|
-
directory: true,
|
|
90
|
-
});
|
|
91
|
-
if (isCancel(selected))
|
|
92
|
-
return;
|
|
93
|
-
const absPath = selected.trim();
|
|
94
|
-
const rel = relative(ctx.workspaceRoot, absPath);
|
|
95
|
-
if (!rel || rel.startsWith("..")) {
|
|
96
|
-
log.warn("Path must be inside the workspace directory. Skipped.");
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
if (patterns.includes(rel)) {
|
|
100
|
-
log.warn(`"${rel}" is already in shadow patterns. Skipped.`);
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
patterns.push(rel);
|
|
104
|
-
log.info(`Added: ${rel}`);
|
|
105
|
-
}
|
|
106
|
-
yaml.shadow_paths = patterns;
|
|
107
|
-
writeChambaYaml(ctx.workspaceRoot, yaml);
|
|
108
|
-
await promptStopContainer(ctx);
|
|
109
|
-
}
|
|
110
|
-
async function removeShadowPatterns(ctx) {
|
|
111
|
-
const yaml = readChambaYaml(ctx.workspaceRoot);
|
|
112
|
-
if (!yaml?.shadow_paths?.length)
|
|
113
|
-
return;
|
|
114
|
-
const toRemove = await multiselect({
|
|
115
|
-
message: "Select patterns to remove: (space to toggle, enter to confirm)",
|
|
116
|
-
options: yaml.shadow_paths.map((p) => ({ value: p, label: p })),
|
|
117
|
-
});
|
|
118
|
-
if (isCancel(toRemove))
|
|
119
|
-
return;
|
|
120
|
-
const removeSet = new Set(toRemove);
|
|
121
|
-
yaml.shadow_paths = yaml.shadow_paths.filter((p) => !removeSet.has(p));
|
|
122
|
-
writeChambaYaml(ctx.workspaceRoot, yaml);
|
|
123
|
-
log.success(`Removed ${removeSet.size} pattern(s).`);
|
|
124
|
-
await promptStopContainer(ctx);
|
|
125
|
-
}
|
|
126
|
-
// --- Git mode menu -----------------------------------------------------------------------------------------------------------------------
|
|
127
|
-
async function gitModeMenu(ctx) {
|
|
128
|
-
const current = readGitMode(ctx.workspaceId) ?? GIT_MODE.local;
|
|
129
|
-
note("Local - local mutations allowed; remote blocked\n" +
|
|
130
|
-
"Strict - read-only; mutations and remote blocked\n" +
|
|
131
|
-
"Unrestricted - no chamba-enforced restrictions", "Git mode");
|
|
132
|
-
const choice = await select({
|
|
133
|
-
message: "Git mode:",
|
|
134
|
-
options: [
|
|
135
|
-
{
|
|
136
|
-
value: GIT_MODE.local,
|
|
137
|
-
label: "Local",
|
|
138
|
-
hint: current === GIT_MODE.local ? "current · default" : "default",
|
|
139
|
-
},
|
|
140
|
-
{ value: GIT_MODE.strict, label: "Strict", ...(current === GIT_MODE.strict ? { hint: "current" } : {}) },
|
|
141
|
-
{
|
|
142
|
-
value: GIT_MODE.unrestricted,
|
|
143
|
-
label: "Unrestricted",
|
|
144
|
-
...(current === GIT_MODE.unrestricted ? { hint: "current" } : {}),
|
|
145
|
-
},
|
|
146
|
-
],
|
|
147
|
-
initialValue: current,
|
|
148
|
-
});
|
|
149
|
-
if (isCancel(choice))
|
|
150
|
-
return;
|
|
151
|
-
if (choice === current)
|
|
152
|
-
return;
|
|
153
|
-
if (choice === GIT_MODE.unrestricted) {
|
|
154
|
-
const confirmed = await confirm({
|
|
155
|
-
message: "Unrestricted mode disables chamba's built-in git restrictions (allows remote push/pull/fetch). Continue?",
|
|
156
|
-
initialValue: false,
|
|
157
|
-
});
|
|
158
|
-
if (isCancel(confirmed) || !confirmed) {
|
|
159
|
-
log.info("Git mode unchanged.");
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
writeGitMode(ctx.workspaceId, choice);
|
|
164
|
-
log.success(`Git mode set to ${choice}.`);
|
|
165
|
-
await promptStopContainer(ctx);
|
|
166
|
-
}
|
|
167
|
-
// --- Ports menu ---------------------------------------------------------------------------------------------------------------------------
|
|
168
|
-
// The web interface itself has no setting - it comes up with the container and the browser opens on it. What
|
|
169
|
-
// is left to choose is the host port range every workspace draws its sticky port from, so this screen is that
|
|
170
|
-
// one setting plus where this workspace currently lives.
|
|
171
|
-
async function portsMenu(ctx) {
|
|
172
|
-
const range = readWebRange();
|
|
173
|
-
const webPort = readWebPort(ctx.workspaceId);
|
|
174
|
-
// The URL only opens the interface with the key the running server created, so the live one is shown
|
|
175
|
-
// whenever it can be read. With nothing running there is no key to show, and saying where it comes
|
|
176
|
-
// from beats printing half a URL.
|
|
177
|
-
const liveKey = webPort === null ? null : readWebKey(ctx.containerName);
|
|
178
|
-
const url = webPort === null
|
|
179
|
-
? "no port assigned yet"
|
|
180
|
-
: liveKey !== null
|
|
181
|
-
? webInterfaceUrl(webPort, liveKey)
|
|
182
|
-
: `${webInterfaceUrl(webPort, null)} (the key comes with the interface when it starts)`;
|
|
183
|
-
note(`web interface range: ${formatWebRange(range)} (all workspaces)\nthis workspace: ${url}`, "Ports");
|
|
184
|
-
log.message("The host port range the web interface draws from.\n" +
|
|
185
|
-
"Each workspace keeps one sticky port from it, so its URL never changes.\n" +
|
|
186
|
-
"Loopback-only, never reachable off this machine, and the URL carries a key\n" +
|
|
187
|
-
"that is minted fresh every time the interface starts.\n" +
|
|
188
|
-
"Ports your own project publishes are set in chamba.yaml, not here.");
|
|
189
|
-
const input = await text({
|
|
190
|
-
message: "Port range (START-END):",
|
|
191
|
-
placeholder: formatWebRange(range),
|
|
192
|
-
initialValue: formatWebRange(range),
|
|
193
|
-
validate: (v) => {
|
|
194
|
-
const raw = (v ?? "").trim();
|
|
195
|
-
if (!/^\d+-\d+$/.test(raw))
|
|
196
|
-
return "Use START-END, e.g. 3900-3999";
|
|
197
|
-
const parsed = parseWebRange(raw);
|
|
198
|
-
if (!parsed) {
|
|
199
|
-
const [start = 0, end = 0] = raw.split("-").map(Number);
|
|
200
|
-
if (start >= end)
|
|
201
|
-
return "Start must be below end";
|
|
202
|
-
return `Ports must be between ${PORT_MIN} and ${PORT_MAX}`;
|
|
203
|
-
}
|
|
204
|
-
return undefined;
|
|
205
|
-
},
|
|
206
|
-
});
|
|
207
|
-
if (isCancel(input))
|
|
208
|
-
return;
|
|
209
|
-
const newRange = parseWebRange(input.trim());
|
|
210
|
-
if (!newRange)
|
|
211
|
-
return; // Validate above guarantees this never fires; keeps the type narrow.
|
|
212
|
-
if (formatWebRange(newRange) === formatWebRange(range))
|
|
213
|
-
return;
|
|
214
|
-
writeWebRange(newRange);
|
|
215
|
-
// Only assignments now outside the range move; in-range ports stay sticky.
|
|
216
|
-
const moves = reassignOutOfRangeWebPorts(newRange);
|
|
217
|
-
for (const m of moves) {
|
|
218
|
-
log.info(`${m.workspaceId}: port ${m.from} -> ${m.to}`);
|
|
219
|
-
}
|
|
220
|
-
log.success(`Port range set to ${formatWebRange(newRange)}.`);
|
|
221
|
-
await promptStopContainer(ctx);
|
|
222
|
-
}
|
|
223
|
-
// --- Prompt to stop container ------------------------------------------------------------------------------------------------------------
|
|
224
|
-
async function promptStopContainer(ctx) {
|
|
225
|
-
const containerName = ctx.containerName;
|
|
226
|
-
const inspect = spawnSync("docker", ["inspect", "--format", "{{.State.Status}}", containerName], {
|
|
227
|
-
encoding: "utf8",
|
|
228
|
-
stdio: "pipe",
|
|
229
|
-
});
|
|
230
|
-
const containerStatus = inspect.status === 0 ? inspect.stdout.trim() : null;
|
|
231
|
-
if (containerStatus !== "running") {
|
|
232
|
-
log.info("Changes will apply on next session.");
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
const shouldStop = await confirm({ message: "Stop the running container so changes apply on next session?" });
|
|
236
|
-
if (isCancel(shouldStop) || !shouldStop) {
|
|
237
|
-
log.warn("Container still running with old config - it will be recreated on next session.");
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
log.info("Stopping container...");
|
|
241
|
-
spawnSync("docker", ["stop", containerName], { stdio: "pipe" });
|
|
242
|
-
spawnSync("docker", ["rm", containerName], { stdio: "pipe" });
|
|
243
|
-
log.info("Container removed. Open a new session to apply changes.");
|
|
244
|
-
}
|
|
245
|
-
// --- Reset chamba.yaml -------------------------------------------------------------------------------------------------------------------
|
|
246
|
-
async function resetChambaYaml(ctx) {
|
|
247
|
-
const yaml = readChambaYaml(ctx.workspaceRoot);
|
|
248
|
-
if (!yaml) {
|
|
249
|
-
log.error("chamba.yaml not found or invalid.");
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
note("This will reset chamba.yaml to the minimal default (workspace_id + shadow_paths).\n" +
|
|
253
|
-
"Your workspace_id is preserved; shadow_paths reset to the defaults (node_modules, .env*).\n" +
|
|
254
|
-
"Any env, profiles, or ports settings will be removed - re-add them with your editor, which reads the schema the file points at.", "Reset chamba.yaml");
|
|
255
|
-
const confirmed = await confirm({ message: "Reset chamba.yaml to defaults?" });
|
|
256
|
-
if (isCancel(confirmed) || !confirmed)
|
|
257
|
-
return;
|
|
258
|
-
const freshYaml = buildDefaultChambaYaml(yaml.workspace_id);
|
|
259
|
-
writeChambaYaml(ctx.workspaceRoot, freshYaml);
|
|
260
|
-
log.success("chamba.yaml reset to defaults.");
|
|
261
|
-
await promptStopContainer(ctx);
|
|
262
|
-
}
|
|
263
|
-
// --- Settings submenu --------------------------------------------------------------------------------------------------------------------
|
|
264
|
-
export async function run(ctx) {
|
|
265
|
-
while (true) {
|
|
266
|
-
const currentGitMode = readGitMode(ctx.workspaceId) ?? GIT_MODE.local;
|
|
267
|
-
const options = [
|
|
268
|
-
{ value: "git-mode", label: "Git mode", hint: `current: ${currentGitMode}` },
|
|
269
|
-
{ value: "shadow-paths", label: "Shadow paths", hint: "manage shadow patterns" },
|
|
270
|
-
{ value: "ports", label: "Ports", hint: `web interface range: ${formatWebRange(readWebRange())}` },
|
|
271
|
-
{ value: "rebuild", label: "Rebuild container", hint: "force a fresh image build" },
|
|
272
|
-
{ value: "clean-rebuild", label: "Clean rebuild", hint: "fresh build, no cache" },
|
|
273
|
-
{ value: "reset", label: "Reset config", hint: "restore chamba.yaml to defaults" },
|
|
274
|
-
{ value: "back", label: "← Back" },
|
|
275
|
-
];
|
|
276
|
-
const action = await select({ message: "Settings:", options });
|
|
277
|
-
if (isCancel(action) || action === "back") {
|
|
278
|
-
return "back";
|
|
279
|
-
}
|
|
280
|
-
switch (action) {
|
|
281
|
-
case "git-mode":
|
|
282
|
-
await gitModeMenu(ctx);
|
|
283
|
-
break;
|
|
284
|
-
case "shadow-paths":
|
|
285
|
-
await shadowPathsMenu(ctx);
|
|
286
|
-
break;
|
|
287
|
-
case "ports":
|
|
288
|
-
await portsMenu(ctx);
|
|
289
|
-
break;
|
|
290
|
-
case "rebuild":
|
|
291
|
-
return "rebuild";
|
|
292
|
-
case "clean-rebuild":
|
|
293
|
-
return "clean-rebuild";
|
|
294
|
-
case "reset":
|
|
295
|
-
await resetChambaYaml(ctx);
|
|
296
|
-
break;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
// --- Stop workspace container ------------------------------------------------------------------------------------------------------------
|
|
301
|
-
export async function stop(containerName) {
|
|
302
|
-
const inspectResult = spawnSync("docker", ["inspect", "--type", "container", containerName], { encoding: "utf8" });
|
|
303
|
-
if (inspectResult.status !== 0) {
|
|
304
|
-
log.info(`Container ${containerName} is not running.`);
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
const confirmed = await confirm({ message: `Stop ${containerName}?` });
|
|
308
|
-
if (isCancel(confirmed) || !confirmed) {
|
|
309
|
-
cancel();
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
log.info(`Stopping ${containerName}...`);
|
|
313
|
-
// Stop-only (no rm) so the next session resumes fast via the "exited" -> docker start path.
|
|
314
|
-
spawnSync("docker", ["stop", containerName], { stdio: "pipe" });
|
|
315
|
-
outro(`${containerName} stopped.`);
|
|
316
|
-
}
|
|
317
|
-
// --- Reset workspace image (stop container + remove image for fresh rebuild) -------------------------------------------------------------
|
|
318
|
-
// Returns false when the user declines a running-container rebuild, so the caller can skip the fresh build
|
|
319
|
-
// and leave the live session untouched.
|
|
320
|
-
export async function resetImage(containerName) {
|
|
321
|
-
const statusResult = spawnSync("docker", ["inspect", "--type", "container", "--format", "{{.State.Status}}", containerName], {
|
|
322
|
-
encoding: "utf8",
|
|
323
|
-
stdio: "pipe",
|
|
324
|
-
});
|
|
325
|
-
const containerExists = statusResult.status === 0;
|
|
326
|
-
const running = containerExists && statusResult.stdout.trim() === "running";
|
|
327
|
-
// A running container is a live session. A rebuild stops and removes it first, so confirm before
|
|
328
|
-
// killing it out from under the user rather than surprising them mid-session.
|
|
329
|
-
if (running) {
|
|
330
|
-
log.warn("Rebuilding stops and removes the running container.\nAgent memory, settings, and workspace data are preserved.");
|
|
331
|
-
const proceed = await confirm({ message: "Rebuild now?", initialValue: true });
|
|
332
|
-
if (isCancel(proceed) || !proceed) {
|
|
333
|
-
cancel("Rebuild cancelled - container left running.");
|
|
334
|
-
return false;
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
if (containerExists) {
|
|
338
|
-
log.info(`Stopping container ${containerName}...`);
|
|
339
|
-
spawnSync("docker", ["stop", containerName], { stdio: "pipe" });
|
|
340
|
-
spawnSync("docker", ["rm", containerName], { stdio: "pipe" });
|
|
341
|
-
}
|
|
342
|
-
const imageResult = spawnSync("docker", ["images", "-q", containerName], { encoding: "utf8", stdio: "pipe" });
|
|
343
|
-
if ((imageResult.stdout ?? "").trim().length > 0) {
|
|
344
|
-
log.info(`Removing image ${containerName}...`);
|
|
345
|
-
spawnSync("docker", ["rmi", containerName], { stdio: "pipe" });
|
|
346
|
-
}
|
|
347
|
-
log.info("Image removed - starting fresh build…");
|
|
348
|
-
return true;
|
|
349
|
-
}
|
|
1
|
+
import{spawnSync as l}from"node:child_process";import{relative as I}from"node:path";import{cancel as v,confirm as h,isCancel as c,log as n,multiselect as A,note as w,outro as T,path as W,select as g,text as R}from"@clack/prompts";import{buildDefaultChambaYaml as U,readChambaYaml as b,writeChambaYaml as y}from"../lib/chamba-yaml.js";import{GIT_MODE as d}from"../lib/constants.js";import{readWebRange as S,writeWebRange as O}from"../lib/global-config.js";import{formatWebRange as p,PORT_MAX as Y,PORT_MIN as E,parseWebRange as $}from"../lib/ports.js";import{countPatternHits as P}from"../lib/shadows.js";import{readWebKey as L,reassignOutOfRangeWebPorts as D,webInterfaceUrl as _}from"../lib/webterm.js";import{readGitMode as C,readWebPort as V,writeGitMode as j}from"../lib/workspace-identity.js";async function B(e){const a=b(e.workspaceRoot);if(!a){n.error("chamba.yaml not found or invalid.");return}const t=a.shadow_paths??[];if(t.length>0){const i=t.map(r=>{const m=P(r,e.workspaceRoot);return` ${r} (${m} ${m===1?"match":"matches"})`});w(i.join(`
|
|
2
|
+
`),"Shadow patterns")}else n.info("No shadow patterns configured.");n.message(`Shadow patterns block the agent from seeing matching host paths -
|
|
3
|
+
the container gets an empty, isolated copy instead.
|
|
4
|
+
Supports gitignore-style patterns (e.g. node_modules, .env*).
|
|
5
|
+
Git-tracked paths are skipped to avoid worktree diversions.`);const o=[{value:"add",label:"Add pattern or path"}];t.length>0&&o.push({value:"remove",label:"Remove patterns"}),o.push({value:"back",label:"\u2190 Back"});const s=await g({message:"Shadow paths:",options:o});c(s)||s==="back"||(s==="add"?await K(e):s==="remove"&&await q(e))}async function K(e){const a=b(e.workspaceRoot);if(!a)return;const t=[...a.shadow_paths??[]],o=await g({message:"Add:",options:[{value:"pattern",label:"Pattern",hint:"gitignore-style (e.g. .env*, *.log)"},{value:"path",label:"Specific path",hint:"pick a file or directory"}]});if(!c(o)){if(o==="pattern"){const s=await R({message:"Pattern:",placeholder:"e.g. .env* or node_modules",validate:m=>{const u=(m??"").trim();if(u.length===0)return"Pattern cannot be empty";if(t.includes(u))return"Pattern already exists"}});if(c(s))return;const i=s.trim(),r=P(i,e.workspaceRoot);t.push(i),n.info(`Added: ${i} (${r} ${r===1?"match":"matches"})`)}else{const s=await W({message:"Path to shadow:",root:e.workspaceRoot,directory:!0});if(c(s))return;const i=s.trim(),r=I(e.workspaceRoot,i);if(!r||r.startsWith("..")){n.warn("Path must be inside the workspace directory. Skipped.");return}if(t.includes(r)){n.warn(`"${r}" is already in shadow patterns. Skipped.`);return}t.push(r),n.info(`Added: ${r}`)}a.shadow_paths=t,y(e.workspaceRoot,a),await f(e)}}async function q(e){const a=b(e.workspaceRoot);if(!a?.shadow_paths?.length)return;const t=await A({message:"Select patterns to remove: (space to toggle, enter to confirm)",options:a.shadow_paths.map(s=>({value:s,label:s}))});if(c(t))return;const o=new Set(t);a.shadow_paths=a.shadow_paths.filter(s=>!o.has(s)),y(e.workspaceRoot,a),n.success(`Removed ${o.size} pattern(s).`),await f(e)}async function z(e){const a=C(e.workspaceId)??d.local;w(`Local - local mutations allowed; remote blocked
|
|
6
|
+
Strict - read-only; mutations and remote blocked
|
|
7
|
+
Unrestricted - no chamba-enforced restrictions`,"Git mode");const t=await g({message:"Git mode:",options:[{value:d.local,label:"Local",hint:a===d.local?"current \xB7 default":"default"},{value:d.strict,label:"Strict",...a===d.strict?{hint:"current"}:{}},{value:d.unrestricted,label:"Unrestricted",...a===d.unrestricted?{hint:"current"}:{}}],initialValue:a});if(!c(t)&&t!==a){if(t===d.unrestricted){const o=await h({message:"Unrestricted mode disables chamba's built-in git restrictions (allows remote push/pull/fetch). Continue?",initialValue:!1});if(c(o)||!o){n.info("Git mode unchanged.");return}}j(e.workspaceId,t),n.success(`Git mode set to ${t}.`),await f(e)}}async function H(e){const a=S(),t=V(e.workspaceId),o=t===null?null:L(e.containerName),s=t===null?"no port assigned yet":o!==null?_(t,o):`${_(t,null)} (the key comes with the interface when it starts)`;w(`web interface range: ${p(a)} (all workspaces)
|
|
8
|
+
this workspace: ${s}`,"Ports"),n.message(`The host port range the web interface draws from.
|
|
9
|
+
Each workspace keeps one sticky port from it, so its URL never changes.
|
|
10
|
+
Loopback-only, never reachable off this machine, and the URL carries a key
|
|
11
|
+
that is minted fresh every time the interface starts.
|
|
12
|
+
Ports your own project publishes are set in chamba.yaml, not here.`);const i=await R({message:"Port range (START-END):",placeholder:p(a),initialValue:p(a),validate:u=>{const k=(u??"").trim();if(!/^\d+-\d+$/.test(k))return"Use START-END, e.g. 3900-3999";if(!$(k)){const[G=0,M=0]=k.split("-").map(Number);return G>=M?"Start must be below end":`Ports must be between ${E} and ${Y}`}}});if(c(i))return;const r=$(i.trim());if(!r||p(r)===p(a))return;O(r);const m=D(r);for(const u of m)n.info(`${u.workspaceId}: port ${u.from} -> ${u.to}`);n.success(`Port range set to ${p(r)}.`),await f(e)}async function f(e){const a=e.containerName,t=l("docker",["inspect","--format","{{.State.Status}}",a],{encoding:"utf8",stdio:"pipe"});if((t.status===0?t.stdout.trim():null)!=="running"){n.info("Changes will apply on next session.");return}const s=await h({message:"Stop the running container so changes apply on next session?"});if(c(s)||!s){n.warn("Container still running with old config - it will be recreated on next session.");return}n.info("Stopping container..."),l("docker",["stop",a],{stdio:"pipe"}),l("docker",["rm",a],{stdio:"pipe"}),n.info("Container removed. Open a new session to apply changes.")}async function X(e){const a=b(e.workspaceRoot);if(!a){n.error("chamba.yaml not found or invalid.");return}w(`This will reset chamba.yaml to the minimal default (workspace_id + shadow_paths).
|
|
13
|
+
Your workspace_id is preserved; shadow_paths reset to the defaults (node_modules, .env*).
|
|
14
|
+
Any env, profiles, or ports settings will be removed - re-add them with your editor, which reads the schema the file points at.`,"Reset chamba.yaml");const t=await h({message:"Reset chamba.yaml to defaults?"});if(c(t)||!t)return;const o=U(a.workspace_id);y(e.workspaceRoot,o),n.success("chamba.yaml reset to defaults."),await f(e)}async function se(e){for(;;){const t=[{value:"git-mode",label:"Git mode",hint:`current: ${C(e.workspaceId)??d.local}`},{value:"shadow-paths",label:"Shadow paths",hint:"manage shadow patterns"},{value:"ports",label:"Ports",hint:`web interface range: ${p(S())}`},{value:"rebuild",label:"Rebuild container",hint:"force a fresh image build"},{value:"clean-rebuild",label:"Clean rebuild",hint:"fresh build, no cache"},{value:"reset",label:"Reset config",hint:"restore chamba.yaml to defaults"},{value:"back",label:"\u2190 Back"}],o=await g({message:"Settings:",options:t});if(c(o)||o==="back")return"back";switch(o){case"git-mode":await z(e);break;case"shadow-paths":await B(e);break;case"ports":await H(e);break;case"rebuild":return"rebuild";case"clean-rebuild":return"clean-rebuild";case"reset":await X(e);break}}}async function re(e){if(l("docker",["inspect","--type","container",e],{encoding:"utf8"}).status!==0){n.info(`Container ${e} is not running.`);return}const t=await h({message:`Stop ${e}?`});if(c(t)||!t){v();return}n.info(`Stopping ${e}...`),l("docker",["stop",e],{stdio:"pipe"}),T(`${e} stopped.`)}async function ie(e){const a=l("docker",["inspect","--type","container","--format","{{.State.Status}}",e],{encoding:"utf8",stdio:"pipe"}),t=a.status===0;if(t&&a.stdout.trim()==="running"){n.warn(`Rebuilding stops and removes the running container.
|
|
15
|
+
Agent memory, settings, and workspace data are preserved.`);const i=await h({message:"Rebuild now?",initialValue:!0});if(c(i)||!i)return v("Rebuild cancelled - container left running."),!1}return t&&(n.info(`Stopping container ${e}...`),l("docker",["stop",e],{stdio:"pipe"}),l("docker",["rm",e],{stdio:"pipe"})),(l("docker",["images","-q",e],{encoding:"utf8",stdio:"pipe"}).stdout??"").trim().length>0&&(n.info(`Removing image ${e}...`),l("docker",["rmi",e],{stdio:"pipe"})),n.info("Image removed - starting fresh build\u2026"),!0}export{ie as resetImage,se as run,re as stop};
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{existsSync as a,readdirSync as p}from"node:fs";import{join as r}from"node:path";import{TOOL_COMMAND_SKILLS as l,TOOL_COMMANDS as h}from"../../templates/webterm/tools/commands.js";import{AGENTS_DIR as u}from"./constants.js";import{safeRmSync as S}from"./safe-rm.js";import{writeFileEnsuringDir as O}from"./templates.js";function w(t){return r(t,u,"opencode","config","commands")}function g(t){const o=w(t),i=new Set;for(const[e,m]of Object.entries(h))for(const[f,d]of Object.entries(m)){const s=d.opencode,n=l[e]?.[f];if(!s?.startsWith("/")||!n)continue;const c=`${s.slice(1)}.md`;i.add(c),O(r(o,c),["---",`description: Start the ${n} skill, which drives a tab of the web pane.`,"---","",`Load the \`${n}\` skill from your skills directory, and follow it.`,"","What the user typed after the command, which may be nothing:","","$ARGUMENTS",""].join(`
|
|
2
|
+
`))}if(a(o))for(const e of p(o))i.has(e)||S(r(o,e),{recursive:!0,force:!0})}export{g as injectOpencodeCommands,w as opencodeCommandsDir};
|
|
@@ -1,210 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
// Verify against official docs periodically:
|
|
8
|
-
// Claude Code: https://docs.anthropic.com/en/docs/claude-code
|
|
9
|
-
// OpenCode: https://github.com/opencode-ai/opencode
|
|
10
|
-
// Codex: https://github.com/openai/codex
|
|
11
|
-
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
12
|
-
import { dirname, join } from "node:path";
|
|
13
|
-
import { AGENTS_DIR, CLAUDE_STATUSLINE_PATH, CONTAINER_HOME, GIT_MODE, PACKAGE_ROOT } from "./constants.js";
|
|
14
|
-
export const AGENT_MOUNTS = [
|
|
15
|
-
{
|
|
16
|
-
agent: "claude",
|
|
17
|
-
hostSubpath: "claude",
|
|
18
|
-
container: `${CONTAINER_HOME}/.claude`,
|
|
19
|
-
description: "Claude Code user-level config and session data",
|
|
20
|
-
},
|
|
21
|
-
{
|
|
22
|
-
agent: "opencode",
|
|
23
|
-
hostSubpath: "opencode/config",
|
|
24
|
-
container: `${CONTAINER_HOME}/.config/opencode`,
|
|
25
|
-
description: "OpenCode user-level config",
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
agent: "opencode",
|
|
29
|
-
hostSubpath: "opencode/data",
|
|
30
|
-
container: `${CONTAINER_HOME}/.local/share/opencode`,
|
|
31
|
-
description: "OpenCode user-level data and session history",
|
|
32
|
-
},
|
|
33
|
-
{
|
|
34
|
-
agent: "codex",
|
|
35
|
-
hostSubpath: "codex",
|
|
36
|
-
container: `${CONTAINER_HOME}/.codex`,
|
|
37
|
-
description: "Codex user-level config and session data",
|
|
38
|
-
},
|
|
39
|
-
];
|
|
40
|
-
export const AGENT_FILE_MOUNTS = [
|
|
41
|
-
{
|
|
42
|
-
// .claude.json lives outside ~/.claude/ so it is not covered by the directory mount.
|
|
43
|
-
// Persisting it as a file mount avoids losing Claude Code settings on container rebuild.
|
|
44
|
-
hostSubpath: "claude/.claude.json",
|
|
45
|
-
container: `${CONTAINER_HOME}/.claude.json`,
|
|
46
|
-
initialContent: "{}\n",
|
|
47
|
-
},
|
|
48
|
-
];
|
|
49
|
-
// --- Build agent mount args --------------------------------------------------------------------------------------------------------------
|
|
50
|
-
/**
|
|
51
|
-
* Creates agents/ subdirectories in the workspace cache dir on the host (lazily) and
|
|
52
|
-
* returns volume mount args for all supported agent tools.
|
|
53
|
-
*/
|
|
54
|
-
export function buildAgentMountArgs(workspaceDir) {
|
|
55
|
-
const agentsDir = join(workspaceDir, AGENTS_DIR);
|
|
56
|
-
const mounts = AGENT_MOUNTS.map((m) => ({
|
|
57
|
-
host: join(agentsDir, m.hostSubpath),
|
|
58
|
-
container: m.container,
|
|
59
|
-
}));
|
|
60
|
-
for (const { host } of mounts)
|
|
61
|
-
mkdirSync(host, { recursive: true });
|
|
62
|
-
const fileMounts = AGENT_FILE_MOUNTS.map((m) => ({
|
|
63
|
-
host: join(agentsDir, m.hostSubpath),
|
|
64
|
-
container: m.container,
|
|
65
|
-
initialContent: m.initialContent,
|
|
66
|
-
}));
|
|
67
|
-
for (const { host, initialContent } of fileMounts) {
|
|
68
|
-
if (!existsSync(host))
|
|
69
|
-
writeFileSync(host, initialContent);
|
|
70
|
-
}
|
|
71
|
-
return [
|
|
72
|
-
...mounts.flatMap(({ host, container }) => ["-v", `${host}:${container}`]),
|
|
73
|
-
...fileMounts.flatMap(({ host, container }) => ["-v", `${host}:${container}`]),
|
|
74
|
-
];
|
|
75
|
-
}
|
|
76
|
-
// --- Template helpers --------------------------------------------------------------------------------------------------------------------
|
|
77
|
-
function loadTemplate(name) {
|
|
78
|
-
return readFileSync(join(PACKAGE_ROOT, "templates", "context", `${name}.md`), "utf8").trimEnd();
|
|
79
|
-
}
|
|
80
|
-
function renderTemplate(template, vars) {
|
|
81
|
-
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`);
|
|
82
|
-
}
|
|
83
|
-
function writeFileEnsuringDir(filePath, content) {
|
|
84
|
-
mkdirSync(dirname(filePath), { recursive: true });
|
|
85
|
-
writeFileSync(filePath, content);
|
|
86
|
-
}
|
|
87
|
-
// --- Build agent context documents -------------------------------------------------------------------------------------------------------
|
|
88
|
-
/**
|
|
89
|
-
* Assembles the agent context markdown injected into each supported agent's config dir at session start.
|
|
90
|
-
*/
|
|
91
|
-
export function buildAgentContextDocs(hasGit, shadowPatterns, gitMode = GIT_MODE.local) {
|
|
92
|
-
const gitSection = loadTemplate(hasGit ? `git-mode-${gitMode}` : "git-unavailable");
|
|
93
|
-
const shadowSection = shadowPatterns && shadowPatterns.length > 0
|
|
94
|
-
? renderTemplate(loadTemplate("shadow-paths"), {
|
|
95
|
-
// Indented two spaces to nest under the section's leading bullet.
|
|
96
|
-
pattern_list: shadowPatterns.map((p) => ` - \`${p}\``).join("\n"),
|
|
97
|
-
})
|
|
98
|
-
: null;
|
|
99
|
-
const baselineTemplate = loadTemplate("baseline");
|
|
100
|
-
// The doc is one bullet list: baseline holds the unconditional bullets, followed by the
|
|
101
|
-
// conditional ones (shadow paths, git mode) and any agent-specific extras.
|
|
102
|
-
function build(toolPath, extraSections = []) {
|
|
103
|
-
const sections = [
|
|
104
|
-
renderTemplate(baselineTemplate, { tool_path: toolPath }),
|
|
105
|
-
...(shadowSection ? [shadowSection] : []),
|
|
106
|
-
gitSection,
|
|
107
|
-
...extraSections,
|
|
108
|
-
];
|
|
109
|
-
return `${sections.join("\n")}\n`;
|
|
110
|
-
}
|
|
111
|
-
// The page-writing craft is claude's web-pane skill body, and this is the same file: a skill is a Claude
|
|
112
|
-
// Code mechanism, so an AGENTS.md section is the only channel codex and opencode have for it. Claude does
|
|
113
|
-
// not get it here - it reads it as the skill, and a second copy in its CLAUDE.md would say it twice.
|
|
114
|
-
// The leading newline: the sections above are one bullet list, and this one opens with a heading.
|
|
115
|
-
const craftSection = `\n${loadTemplate("web-pane-craft")}`;
|
|
116
|
-
// The context-usage section is Claude-only: the snapshot it points at is written by the status line
|
|
117
|
-
// script Claude Code runs, so other agents' sessions never refresh it.
|
|
118
|
-
return {
|
|
119
|
-
claude: build("~/.claude/CLAUDE.md", [loadTemplate("context-usage")]),
|
|
120
|
-
opencode: build("~/.config/opencode/AGENTS.md", [craftSection]),
|
|
121
|
-
codex: build("~/.codex/AGENTS.md", [craftSection]),
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
// --- Claude settings.json bootstrap ------------------------------------------------------------------------------------------------------
|
|
125
|
-
function readSettingsObject(path) {
|
|
126
|
-
try {
|
|
127
|
-
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
128
|
-
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
129
|
-
return parsed;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
// Missing file or malformed JSON: caller proceeds with an empty object.
|
|
134
|
-
}
|
|
135
|
-
return {};
|
|
136
|
-
}
|
|
137
|
-
// Sets ~/.claude/settings.json -> statusLine to the chamba default if no value is set.
|
|
138
|
-
// Non-destructive: preserves other fields and never overwrites a user-set statusLine.
|
|
139
|
-
export function ensureClaudeStatusLine(workspaceDir) {
|
|
140
|
-
const settingsPath = join(workspaceDir, AGENTS_DIR, "claude", "settings.json");
|
|
141
|
-
const settings = readSettingsObject(settingsPath);
|
|
142
|
-
if (settings.statusLine === undefined) {
|
|
143
|
-
settings.statusLine = { type: "command", command: CLAUDE_STATUSLINE_PATH };
|
|
144
|
-
writeFileEnsuringDir(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
// --- Claude skills injection -------------------------------------------------------------------------------------------------------------
|
|
148
|
-
// Copies every templates/skills/<name>/ into agents/claude/skills/<name>/, whole. Drop a new directory
|
|
149
|
-
// under templates/skills/ to ship a new skill - no other code changes needed.
|
|
150
|
-
//
|
|
151
|
-
// The directory rather than the one file, because a skill of any size keeps its craft in references/ and
|
|
152
|
-
// points at those files by name. A SKILL.md that reaches a session without them names files that are not
|
|
153
|
-
// there, which is worse than shipping nothing.
|
|
154
|
-
export function injectClaudeSkills(workspaceDir) {
|
|
155
|
-
const templatesSkillsDir = join(PACKAGE_ROOT, "templates", "skills");
|
|
156
|
-
if (!existsSync(templatesSkillsDir))
|
|
157
|
-
return;
|
|
158
|
-
const targetSkillsDir = join(workspaceDir, AGENTS_DIR, "claude", "skills");
|
|
159
|
-
// web_pane_craft is the page-writing advice codex and opencode also get, as a section of their AGENTS.md.
|
|
160
|
-
// One file, rendered into both channels, so the two cannot come to say different things.
|
|
161
|
-
const vars = { statusline_path: CLAUDE_STATUSLINE_PATH, web_pane_craft: loadTemplate("web-pane-craft") };
|
|
162
|
-
for (const entry of readdirSync(templatesSkillsDir, { withFileTypes: true })) {
|
|
163
|
-
if (!entry.isDirectory())
|
|
164
|
-
continue;
|
|
165
|
-
if (!existsSync(join(templatesSkillsDir, entry.name, "SKILL.md")))
|
|
166
|
-
continue;
|
|
167
|
-
copySkillDir(join(templatesSkillsDir, entry.name), join(targetSkillsDir, entry.name), vars);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
// One skill's files, at whatever depth they sit. Markdown is rendered with the skill variables, since a
|
|
171
|
-
// reference states the same placeholders SKILL.md does; anything else is copied byte for byte.
|
|
172
|
-
function copySkillDir(from, to, vars) {
|
|
173
|
-
for (const entry of readdirSync(from, { withFileTypes: true })) {
|
|
174
|
-
const source = join(from, entry.name);
|
|
175
|
-
const target = join(to, entry.name);
|
|
176
|
-
// A link is copied as what it points at. The container gets a directory rather than a tree of links,
|
|
177
|
-
// and a shared reference brought in as a link still ships - a skill naming a file this walk skipped
|
|
178
|
-
// is exactly the broken skill that copying the whole directory exists to prevent.
|
|
179
|
-
const kind = statSync(source);
|
|
180
|
-
if (kind.isDirectory()) {
|
|
181
|
-
copySkillDir(source, target, vars);
|
|
182
|
-
}
|
|
183
|
-
else if (kind.isFile()) {
|
|
184
|
-
if (entry.name.endsWith(".md")) {
|
|
185
|
-
writeFileEnsuringDir(target, renderTemplate(readFileSync(source, "utf8"), vars));
|
|
186
|
-
}
|
|
187
|
-
else {
|
|
188
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
189
|
-
copyFileSync(source, target);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
// --- Inject agent context ----------------------------------------------------------------------------------------------------------------
|
|
195
|
-
/**
|
|
196
|
-
* Writes agent context markdown files into the workspace's agents/ directory.
|
|
197
|
-
*/
|
|
198
|
-
export function injectAgentContext(workspaceDir, docs) {
|
|
199
|
-
const a = join(workspaceDir, AGENTS_DIR);
|
|
200
|
-
const files = [
|
|
201
|
-
{ path: join(a, "claude", "CLAUDE.md"), content: docs.claude },
|
|
202
|
-
{ path: join(a, "opencode", "config", "AGENTS.md"), content: docs.opencode },
|
|
203
|
-
{ path: join(a, "codex", "AGENTS.md"), content: docs.codex },
|
|
204
|
-
];
|
|
205
|
-
for (const { path: filePath, content } of files) {
|
|
206
|
-
writeFileEnsuringDir(filePath, content);
|
|
207
|
-
}
|
|
208
|
-
ensureClaudeStatusLine(workspaceDir);
|
|
209
|
-
injectClaudeSkills(workspaceDir);
|
|
210
|
-
}
|
|
1
|
+
import{existsSync as h,mkdirSync as g,readFileSync as S,writeFileSync as b}from"node:fs";import{join as i}from"node:path";import{injectOpencodeCommands as A}from"./agent-commands.js";import{AGENTS_DIR as l,CLAUDE_STATUSLINE_PATH as u,CONTAINER_HOME as a,GIT_MODE as C}from"./constants.js";import{injectSkills as T}from"./skills.js";import{loadTemplate as d,renderTemplate as f,writeFileEnsuringDir as m}from"./templates.js";const x=[{agent:"claude",hostSubpath:"claude",container:`${a}/.claude`,description:"Claude Code user-level config and session data"},{agent:"opencode",hostSubpath:"opencode/config",container:`${a}/.config/opencode`,description:"OpenCode user-level config"},{agent:"opencode",hostSubpath:"opencode/config/commands",container:`${a}/.config/opencode/commands`,description:"The pane tools' commands chamba ships to OpenCode",readOnly:!0},{agent:"opencode",hostSubpath:"opencode/config/command",container:`${a}/.config/opencode/command`,description:"Held empty, so no command word is defined behind the ones chamba ships",readOnly:!0},{agent:"opencode",hostSubpath:"opencode/data",container:`${a}/.local/share/opencode`,description:"OpenCode user-level data and session history"},{agent:"codex",hostSubpath:"codex",container:`${a}/.codex`,description:"Codex user-level config and session data"}],E=[{hostSubpath:"claude/.claude.json",container:`${a}/.claude.json`,initialContent:`{}
|
|
2
|
+
`}];function M(t){const e=i(t,l),o=x.map(n=>({host:i(e,n.hostSubpath),container:n.readOnly===!0?`${n.container}:ro`:n.container}));for(const{host:n}of o)g(n,{recursive:!0});const s=E.map(n=>({host:i(e,n.hostSubpath),container:n.container,initialContent:n.initialContent}));for(const{host:n,initialContent:c}of s)h(n)||b(n,c);return[...o.flatMap(({host:n,container:c})=>["-v",`${n}:${c}`]),...s.flatMap(({host:n,container:c})=>["-v",`${n}:${c}`])]}function U(t,e,o=C.local){const s=d(t?`git-mode-${o}`:"git-unavailable"),n=e&&e.length>0?f(d("shadow-paths"),{pattern_list:e.map(p=>` - \`${p}\``).join(`
|
|
3
|
+
`)}):null,c=d("baseline");function r(p){return`${[f(c,{tool_path:p}),...n?[n]:[],s].join(`
|
|
4
|
+
`)}
|
|
5
|
+
`}return{claude:r("~/.claude/CLAUDE.md"),opencode:r("~/.config/opencode/AGENTS.md"),codex:r("~/.codex/AGENTS.md")}}function $(t){try{const e=JSON.parse(S(t,"utf8"));if(e!==null&&typeof e=="object"&&!Array.isArray(e))return e}catch{}return{}}function y(t){const e=i(t,l,"claude","settings.json"),o=$(e);o.statusLine===void 0&&(o.statusLine={type:"command",command:u},m(e,`${JSON.stringify(o,null,2)}
|
|
6
|
+
`))}function D(t,e){const o=i(t,l),s=[{path:i(o,"claude","CLAUDE.md"),content:e.claude},{path:i(o,"opencode","config","AGENTS.md"),content:e.opencode},{path:i(o,"codex","AGENTS.md"),content:e.codex}];for(const{path:n,content:c}of s)m(n,c);y(t),A(t),T(t,{statusline_path:u,web_pane_craft:d("web-pane-craft")})}export{E as AGENT_FILE_MOUNTS,x as AGENT_MOUNTS,U as buildAgentContextDocs,M as buildAgentMountArgs,y as ensureClaudeStatusLine,D as injectAgentContext};
|
package/dist/lib/browser.js
CHANGED
|
@@ -1,40 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// src/lib/browser.ts - Open a URL in the host's default browser
|
|
3
|
-
// One tiny best-effort step of the session start: chamba resolves the web port, brings the interface up,
|
|
4
|
-
// and hands the URL to whatever the platform uses to open links. Never blocks and never fails a session.
|
|
5
|
-
// =========================================================================================================================================
|
|
6
|
-
import { spawnSync } from "node:child_process";
|
|
7
|
-
// A hung opener would sit in front of the user's shell, so give it a short deadline and move on. Every
|
|
8
|
-
// opener below hands the URL to a running desktop and returns at once, so this only bites when something
|
|
9
|
-
// is wrong - and then printing the URL is the better outcome anyway.
|
|
10
|
-
const OPEN_TIMEOUT_MS = 3000;
|
|
11
|
-
/**
|
|
12
|
-
* The argv that opens a URL in the default browser on this platform, or null when we know of no opener for
|
|
13
|
-
* it. Argv, never a shell string: the URL carries a key read back from a file every process in the
|
|
14
|
-
* container can write, so it must reach the opener as one argument and never as something a shell parses.
|
|
15
|
-
* On Windows the opener is `start`, which is a cmd builtin rather than a program, hence the `cmd /c` -
|
|
16
|
-
* its first quoted argument is the window title, which is why an empty one goes before the URL.
|
|
17
|
-
*/
|
|
18
|
-
export function browserOpenArgv(url, platform = process.platform) {
|
|
19
|
-
if (platform === "darwin")
|
|
20
|
-
return ["open", url];
|
|
21
|
-
if (platform === "win32")
|
|
22
|
-
return ["cmd", "/c", "start", "", url];
|
|
23
|
-
if (platform === "linux")
|
|
24
|
-
return ["xdg-open", url];
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
/**
|
|
28
|
-
* Open a URL in the host's default browser. Returns false when there is no opener for this platform, the
|
|
29
|
-
* opener is not installed (a headless host, an SSH session), or it failed - the caller then prints the URL
|
|
30
|
-
* and carries on. Nothing here is allowed to throw: opening the browser for the user is the convenience, and
|
|
31
|
-
* the printed URL reaches the same interface, so a session must start either way.
|
|
32
|
-
*/
|
|
33
|
-
export function openInBrowser(url, platform = process.platform) {
|
|
34
|
-
const argv = browserOpenArgv(url, platform);
|
|
35
|
-
if (argv === null)
|
|
36
|
-
return false;
|
|
37
|
-
const [command = "", ...args] = argv;
|
|
38
|
-
const result = spawnSync(command, args, { stdio: "ignore", timeout: OPEN_TIMEOUT_MS });
|
|
39
|
-
return result.error === undefined && result.status === 0;
|
|
40
|
-
}
|
|
1
|
+
import{spawnSync as i}from"node:child_process";const u=3e3;function c(n,r=process.platform){return r==="darwin"?["open",n]:r==="win32"?["cmd","/c","start","",n]:r==="linux"?["xdg-open",n]:null}function f(n,r=process.platform){const e=c(n,r);if(e===null)return!1;const[o="",...s]=e,t=i(o,s,{stdio:"ignore",timeout:u});return t.error===void 0&&t.status===0}export{c as browserOpenArgv,f as openInBrowser};
|