chamba 0.3.0 → 0.4.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.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -44
  3. package/bin/chamba.js +212 -0
  4. package/dist/commands/advanced.js +278 -0
  5. package/dist/commands/dev.js +619 -0
  6. package/dist/commands/doctor.js +29 -0
  7. package/dist/commands/menu.js +80 -0
  8. package/dist/commands/onboard.js +229 -0
  9. package/dist/commands/settings.js +349 -0
  10. package/dist/lib/agent-context.js +177 -0
  11. package/dist/lib/browser.js +40 -0
  12. package/dist/lib/chamba-yaml.js +191 -0
  13. package/dist/lib/constants.js +135 -0
  14. package/dist/lib/dockerfile-builder.js +267 -0
  15. package/dist/lib/env.js +78 -0
  16. package/dist/lib/global-config.js +66 -0
  17. package/dist/lib/pnpm-store.js +19 -0
  18. package/dist/lib/ports.js +210 -0
  19. package/dist/lib/safe-rm.js +26 -0
  20. package/dist/lib/sessions.js +34 -0
  21. package/dist/lib/shadows.js +174 -0
  22. package/dist/lib/webterm.js +490 -0
  23. package/dist/lib/workspace-identity.js +260 -0
  24. package/package.json +61 -24
  25. package/schema/chamba.schema.json +65 -0
  26. package/templates/.dockerignore +3 -0
  27. package/templates/Dockerfile +173 -0
  28. package/templates/claude-statusline.sh +120 -0
  29. package/templates/context/baseline.md +13 -0
  30. package/templates/context/context-usage.md +1 -0
  31. package/templates/context/git-mode-local.md +1 -0
  32. package/templates/context/git-mode-strict.md +1 -0
  33. package/templates/context/git-mode-unrestricted.md +1 -0
  34. package/templates/context/git-unavailable.md +1 -0
  35. package/templates/context/shadow-paths.md +3 -0
  36. package/templates/context-usage.sh +249 -0
  37. package/templates/git-readonly-wrapper.mjs +309 -0
  38. package/templates/npmrc +2 -0
  39. package/templates/pnpm-config.yaml +9 -0
  40. package/templates/runtime-constants.mjs +18 -0
  41. package/templates/skills/chamba-statusline/SKILL.md +79 -0
  42. package/templates/skills/context-usage/SKILL.md +53 -0
  43. package/templates/skills/web-pane/SKILL.md +62 -0
  44. package/templates/startup-git-mode.mjs +145 -0
  45. package/templates/startup.mjs +333 -0
  46. package/templates/webpane.sh +126 -0
  47. package/templates/webterm/README.md +157 -0
  48. package/templates/webterm/artifacts.js +583 -0
  49. package/templates/webterm/config.js +269 -0
  50. package/templates/webterm/context/claude.md +14 -0
  51. package/templates/webterm/conversation.js +248 -0
  52. package/templates/webterm/package-lock.json +884 -0
  53. package/templates/webterm/package.json +17 -0
  54. package/templates/webterm/pane.js +156 -0
  55. package/templates/webterm/proc.js +89 -0
  56. package/templates/webterm/public/app/alerts.js +472 -0
  57. package/templates/webterm/public/app/cards.js +123 -0
  58. package/templates/webterm/public/app/clipboard.js +229 -0
  59. package/templates/webterm/public/app/composer.js +226 -0
  60. package/templates/webterm/public/app/connection.js +342 -0
  61. package/templates/webterm/public/app/dictation.js +98 -0
  62. package/templates/webterm/public/app/dom.js +37 -0
  63. package/templates/webterm/public/app/drafts.js +244 -0
  64. package/templates/webterm/public/app/frames.js +166 -0
  65. package/templates/webterm/public/app/main.js +82 -0
  66. package/templates/webterm/public/app/new-session.js +188 -0
  67. package/templates/webterm/public/app/note.js +24 -0
  68. package/templates/webterm/public/app/pane-frame.js +166 -0
  69. package/templates/webterm/public/app/pane.js +353 -0
  70. package/templates/webterm/public/app/state.js +51 -0
  71. package/templates/webterm/public/app/status-strip.js +170 -0
  72. package/templates/webterm/public/app/tabs.js +475 -0
  73. package/templates/webterm/public/app/terminal.js +102 -0
  74. package/templates/webterm/public/app/theme.js +46 -0
  75. package/templates/webterm/public/favicon.svg +21 -0
  76. package/templates/webterm/public/index.html +105 -0
  77. package/templates/webterm/public/styles.css +1193 -0
  78. package/templates/webterm/server.js +1142 -0
  79. package/templates/webterm/sessions.js +515 -0
  80. package/templates/webterm/snapshot.js +135 -0
  81. package/templates/webterm.sh +167 -0
  82. package/dist/cli.js +0 -1691
  83. package/dist/server.js +0 -1919
  84. package/inject/annotate.js +0 -18
  85. package/skill/README.md +0 -12
  86. package/skill/SKILL.md +0 -93
  87. package/web/assets/highlighted-body-OFNGDK62-Bn4Eu7CG.js +0 -1
  88. package/web/assets/index-B9DI4F1Z.js +0 -202
  89. package/web/assets/index-DK_n6CTo.css +0 -2
  90. package/web/assets/mermaid-GHXKKRXX-CEMduc-U.js +0 -1
  91. package/web/index.html +0 -28
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Asaf Ratzon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,67 +1,70 @@
1
1
  # chamba
2
2
 
3
- A browser workspace beside your terminal coding agent - a rich parallel interface to the terminal chat.
3
+ Run AI coding agents in a container, one per workspace, and work with them from the browser.
4
4
 
5
- ## Quick start
5
+ Chamba is a personal tool.
6
+ It is built for one person's machine and published to npm only so that `npx` can fetch it - no support, no compatibility promises, no release notes.
6
7
 
7
- Install the skill for your agent:
8
+ > Chamba is a fork of [totopo](https://www.npmjs.com/package/totopo), taken web-first.
9
+ > Totopo stays what it is: terminal-first, public, and maintained on its own.
10
+ > The two run side by side on one host - separate config files, separate caches, separate containers - so a directory can be managed by either.
8
11
 
9
- ```
10
- npx chamba@latest skill install
11
- ```
12
-
13
- Then, in your agent, run `/chamba` (or ask it to "open chamba").
14
- It opens a browser workspace where you read rendered markdown, paste screenshots the agent can read, point at UI in the app tab, and view rich HTML pages the agent renders in the Illustration tab.
12
+ ## Quick start
15
13
 
16
- The browser is a second conversation beside the terminal: a background `chamba poll` keeps the agent listening for browser input while it stays available in the terminal, and each channel is answered where it was asked.
14
+ ```bash
15
+ npx chamba
16
+ ```
17
17
 
18
- Add `--global` to install the skill for every project (`~/.claude/skills`).
18
+ Run it from your project directory.
19
+ The first run walks through a short setup and writes a `chamba.yaml` at the workspace root; every run after that opens the workspace menu.
19
20
 
20
- ## Testing
21
+ ## How it works
21
22
 
22
- `pnpm check` is the full gate: typecheck (the packages plus the test sources, via `tsconfig.test.json`), lint, both builds (`inject`, `web`), then `pnpm test` and `pnpm test:e2e:flake`.
23
- `pnpm test` runs Vitest's two projects (`unit` + `integration`).
24
- `pnpm test:e2e` runs the Playwright suite once; `pnpm test:e2e:flake` repeats it three times with no retries so an intermittent failure cannot hide, and is the e2e variant the gate runs on every push.
25
- `pnpm test:e2e:video` runs the suite once and records a video of every flow under `test-results/e2e/`, for watching the browser interactions.
23
+ A **workspace** is any directory holding a `chamba.yaml`.
24
+ `npx chamba` walks up from where it runs to find the nearest one, so any subdirectory of a project reaches the same workspace.
26
25
 
27
- | Path | Covers | Run by |
28
- | --- | --- | --- |
29
- | `packages/*/src/**/*.test.ts` | Unit tests colocated with each package (`shared`, `server`, `cli`, `inject`): pure schema and logic. | `pnpm test` (Vitest `unit` project, parallel). |
30
- | `tests/integration/` | Integration: real server and CLI processes over real ports and the WebSocket transport (roundtrip, durability, annotations, attachments, issues, pages, references, binding, init, lifecycle). Shared helpers live in `tests/support/`. | `pnpm test` (Vitest `integration` project, sequential forks with long timeouts, since parallel port binding would flake). |
31
- | `tests/e2e/` | End-to-end: a real browser drives the production web build against a real server and CLI, proving the human and agent exchange renders live. | `pnpm test:e2e` (Playwright, serial; `pretest:e2e` installs the browser first). |
32
- | `tests/fixtures/` | Sample apps for the "Your app" surface: a Vite + React app on port 4001 and a Next.js app on port 4002. The e2e suite drives the React app as a cross-origin annotation target; both are runnable by hand for manual exploration. | `pnpm fixture:react`, `pnpm fixture:next` (see `tests/fixtures/README.md`). |
26
+ Each workspace gets one container, not one per session, so several terminals share it and reconnect fast.
27
+ The workspace directory is mounted at `/workspace`, and nothing outside it is.
33
28
 
34
- The e2e browser runs inside the dev container, which provides the Chromium system libraries and fonts it needs.
29
+ `chamba.yaml` needs only a `workspace_id`.
30
+ The optional fields - `shadow_paths`, `env`, `ports`, `profiles` - are all described in the JSON schema the generated file points at, which editors read for completion and validation.
35
31
 
36
- ## Local package testing
32
+ ## What the container gives an agent
37
33
 
38
- `pnpm -s chamba` drives the working copy: it runs the source under tsx (no build) and resolves only inside this repo, so it is always the local version, never the published package.
34
+ - **Its own filesystem.** Only the workspace is mounted, as a non-root user, with `no-new-privileges` set. No host credentials go in.
35
+ - **No git remote.** The default git mode blocks every remote operation while leaving local commits free. `strict` also blocks mutating git commands; `unrestricted` lifts the remote block, and only you can turn it on.
36
+ - **Hidden paths.** Shadow paths (`node_modules`, `.env*` by default) are overlaid with empty container-local copies, so an agent never reads the real thing.
37
+ - **The current CLIs.** claude, opencode and codex are kept on their latest versions, each with its config directory bind-mounted to the workspace cache so conversations and memory survive rebuilds.
38
+ - **A web interface.** The browser front-end relays the real agent TUI over a websocket - every session on one page, several agents at once, images and dictation. It starts with the container. Claude sessions also get a status strip above the composer: model, context used, quota left.
39
39
 
40
- ### One command
40
+ The menu has two doors into that container.
41
+ **Web interface** brings it up, opens the interface in your browser, and hands your terminal straight back - the work happens in the tab.
42
+ **Container shell** drops you into a shell inside the container and opens nothing; the greeting there still prints the interface's URL, because it is running either way.
41
43
 
42
- From the repo root:
44
+ ## The web pane
43
45
 
44
- ```
45
- pnpm try:chamba
46
- ```
46
+ Beside the terminal is a pane of pages, there from the first second of a session: until an agent has published anything it is a spine at the edge of the window, and opening it says what the pane is for and what to ask for.
47
+ An agent publishes a standalone HTML file into it - `webpane report.html --title "Port rework"` - and it shows up as a chip above the pane: a plan, a comparison table, a long answer, anything worth reading at your own pace instead of scrolling back for.
47
48
 
48
- It waits until the Next fixture is serving `:4002`, then drops you straight into a sandbox session in the same terminal tab.
49
- This tab never starts the fixture itself, so it can never leave one running in the background.
50
- If nothing is serving `:4002` yet, it prints the command to start the fixture and waits; open a separate tab in the repo root, run `pnpm dev:next` (or `pnpm re:dev:next` for a clean reinstall first), and this tab continues on its own once the port is up.
49
+ A page can also ask.
50
+ A form marked `data-feedback` is wired up by the interface, and what you submit is written as JSON next to the page, with one line typed into the agent's terminal saying where to read it.
51
51
 
52
- ### The individual pieces
52
+ Pages are kept, one directory per conversation, so resuming a conversation opens with them again.
53
+ They live under `~/.chamba/workspaces/<id>/agents/webpane/` on the host, and nothing deletes them.
53
54
 
54
- Each runs from the repo root; add `-w` (`pnpm -w dev:next`) to run it from any subdir.
55
+ ## Where state lives
55
56
 
56
- - `pnpm dev:next` - Next fixture on `:4002` (fast; assumes deps are installed via `pnpm install`).
57
- - `pnpm re:dev:next` - clean build dirs, reinstall, then the same (the container shadows `node_modules`, so this matters for a truly fresh run).
58
- - `pnpm fixtures:stop` - stop the fixture dev servers (React on `:4001`, Next on `:4002`). Run it where the fixture runs (host or container); it kills only the fixture processes, never Docker's port forwarder.
59
- - `npx totopo` - start the workspace's sandbox session; runs from anywhere under the repo.
60
- - `pnpm -s chamba open` - inside the session, opens chamba and prints its URL.
57
+ `chamba.yaml` lives in the workspace and can be committed.
58
+ Everything else is on your machine under `~/.chamba/`: one directory per workspace holding its lock file, agent config, and shadow overlays, plus a host-global `config` for settings that are yours rather than the project's.
61
59
 
62
- Confirm the workspace publishes chamba's port with `CHAMBA_HOST=0.0.0.0` and `CHAMBA_PORT=4319`; the fixture's inject script targets `:4319`.
63
- Then open the printed chamba URL, point the "Your app" tab at `:4002`, and do real work: paste a screenshot, annotate, ask the agent to show an illustration page.
60
+ ## Developing chamba
64
61
 
65
- ---
62
+ ```bash
63
+ pnpm install
64
+ pnpm start # run the CLI from source (host only - Docker is not available in the dev container)
65
+ pnpm check # typecheck, lint, repo checks, tests, build
66
+ pnpm release # check, bump, commit and publish to npm
67
+ ```
66
68
 
67
- A personal tool, published as-is with no stability promises.
69
+ `pnpm release` is the whole release process: it reads the live version off npm, offers a bump, runs `pnpm check`, commits the bump and publishes.
70
+ `AGENTS.md` carries the conventions this repo is written to.
package/bin/chamba.js ADDED
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+ // =========================================================================================================================================
3
+ // bin/chamba.js - chamba entry point
4
+ // Run this from your workspace directory (or via npx chamba).
5
+ // =========================================================================================================================================
6
+
7
+ import { execSync, spawnSync } from "node:child_process";
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { cancel, confirm, isCancel, log, select } from "@clack/prompts";
12
+ import { run as advancedMenu } from "../dist/commands/advanced.js";
13
+ import { run as dev } from "../dist/commands/dev.js";
14
+ import { run as doctor } from "../dist/commands/doctor.js";
15
+ import { run as menu } from "../dist/commands/menu.js";
16
+ import { run as onboard } from "../dist/commands/onboard.js";
17
+ import { resetImage, run as settingsMenu, stop } from "../dist/commands/settings.js";
18
+ import { repairChambaYaml } from "../dist/lib/chamba-yaml.js";
19
+ import { deriveContainerName, findChambaYamlDir, listWorkspaceIds, resolveWorkspace } from "../dist/lib/workspace-identity.js";
20
+
21
+ // --- Suppress Docker CLI hints ------------------------------------------------------------------------------------------------------------
22
+ // Docker prints a "What's next:" hint block (about `docker debug`) to stderr after commands like `docker run`. Our
23
+ // interactive docker calls inherit stderr, so the hint leaks between chamba's own log steps. chamba owns this process
24
+ // and never wants those hints, so force it off unconditionally; every docker child inherits this env.
25
+ process.env.DOCKER_CLI_HINTS = "false";
26
+
27
+ // --- Guard: inside container -------------------------------------------------------------------------------------------------------------
28
+ try {
29
+ if (execSync("whoami", { encoding: "utf8" }).trim() === "devuser") {
30
+ console.error("");
31
+ console.error(" You are running chamba from inside the dev container.");
32
+ console.error(" Open a terminal on your host machine and run:");
33
+ console.error("");
34
+ console.error(" chamba (or npx chamba from your workspace directory)");
35
+ console.error("");
36
+ process.exit(1);
37
+ }
38
+ } catch {
39
+ // whoami unavailable - not blocking
40
+ }
41
+
42
+ // --- Paths -------------------------------------------------------------------------------------------------------------------------------
43
+ const packageDir = dirname(dirname(fileURLToPath(import.meta.url)));
44
+ const cwd = process.cwd();
45
+
46
+ // --- Version -----------------------------------------------------------------------------------------------------------------------------
47
+ const { version } = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
48
+
49
+ // --- Guard: dist/ must exist -------------------------------------------------------------------------------------------------------------
50
+ if (!existsSync(new URL("../dist/commands/dev.js", import.meta.url))) {
51
+ console.error("");
52
+ console.error(" chamba: compiled output not found.");
53
+ console.error(" This should not happen with a published package.");
54
+ console.error(" If you are developing locally, run: pnpm build");
55
+ console.error("");
56
+ process.exit(1);
57
+ }
58
+
59
+ // --- Auto-repair chamba.yaml if needed ---------------------------------------------------------------------------------------------------
60
+ // Every chamba workspace onboards fresh, so there is no old shape to detect and nothing to migrate.
61
+ const yamlDir = findChambaYamlDir(cwd);
62
+ if (yamlDir) {
63
+ const result = repairChambaYaml(yamlDir);
64
+ if (result.error) {
65
+ console.error("");
66
+ console.error(` ${result.error}`);
67
+ console.error("");
68
+ process.exit(1);
69
+ }
70
+ if (result.repairedYaml) {
71
+ log.info(result.message);
72
+
73
+ // Prompt to stop container if running (config changed)
74
+ const cn = deriveContainerName(result.repairedYaml.workspace_id);
75
+ const inspect = spawnSync("docker", ["inspect", "--format", "{{.State.Status}}", cn], {
76
+ encoding: "utf8",
77
+ stdio: "pipe",
78
+ });
79
+ if (inspect.status === 0 && inspect.stdout.trim() === "running") {
80
+ const shouldStop = await confirm({
81
+ message: "Stop the running container so repaired config applies on next session?",
82
+ });
83
+ if (isCancel(shouldStop) || !shouldStop) {
84
+ log.warn("Container still running with old config.");
85
+ } else {
86
+ spawnSync("docker", ["stop", cn], { stdio: "pipe" });
87
+ spawnSync("docker", ["rm", cn], { stdio: "pipe" });
88
+ log.info("Container stopped. Changes will apply on next session.");
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ // --- Resolve workspace from CWD (walk-up looking for chamba.yaml) ------------------------------------------------------------------------
95
+ let workspace;
96
+ try {
97
+ workspace = resolveWorkspace(cwd);
98
+ } catch (err) {
99
+ console.error("");
100
+ console.error(` ${err instanceof Error ? err.message : err}`);
101
+ console.error("");
102
+ process.exit(1);
103
+ }
104
+
105
+ // --- Onboarding (if not in a registered workspace) ---------------------------------------------------------------------------------------
106
+ if (!workspace) {
107
+ // If other workspaces already exist, let the user choose setup vs manage
108
+ if (listWorkspaceIds().length > 0) {
109
+ while (true) {
110
+ process.stdout.write("\n");
111
+ const choice = await select({
112
+ message: "What would you like to do?",
113
+ options: [
114
+ { value: "setup", label: "Set up chamba for this directory" },
115
+ { value: "advanced", label: "Advanced" },
116
+ ],
117
+ });
118
+ if (isCancel(choice)) {
119
+ cancel();
120
+ process.exit(0);
121
+ }
122
+ if (choice === "advanced") {
123
+ // "Back" returns here to re-show this chooser; a terminal action (uninstall) returns
124
+ // undefined and exits, matching the main menu loop's handling below.
125
+ const result = await advancedMenu();
126
+ if (result === "back") continue;
127
+ process.exit(0);
128
+ }
129
+ // "setup" - leave the chooser and fall through to onboarding.
130
+ break;
131
+ }
132
+ }
133
+
134
+ const ctx = await onboard(cwd);
135
+ if (!ctx) process.exit(0);
136
+ workspace = ctx;
137
+ }
138
+
139
+ // --- Doctor (pre-check; silent on success, prints failed checks otherwise) ---------------------------------------------------------------
140
+ const doctorResult = await doctor();
141
+ if (!doctorResult.ok) {
142
+ console.error("");
143
+ console.error(" chamba can't start:");
144
+ for (const err of doctorResult.errors) {
145
+ console.error(` \x1b[2m•\x1b[0m ${err}`);
146
+ }
147
+ console.error("");
148
+ console.error(" Fix the issues above and re-run chamba.");
149
+ console.error("");
150
+ process.exit(1);
151
+ }
152
+
153
+ // --- Interactive menu loop ---------------------------------------------------------------------------------------------------------------
154
+ const { containerName } = workspace;
155
+ let showMenu = true;
156
+ while (showMenu) {
157
+ showMenu = false;
158
+
159
+ // Refresh container state on each loop iteration
160
+ const dockerResult = spawnSync("docker", ["ps", "--filter", "name=chamba-", "--format", "{{.Names}}"], {
161
+ encoding: "utf8",
162
+ });
163
+ const activeNames = dockerResult.stdout ? dockerResult.stdout.trim().split("\n").filter(Boolean) : [];
164
+ const activeCount = activeNames.length;
165
+ const workspaceRunning = activeNames.some((n) => n === containerName);
166
+
167
+ const action = await menu({ ctx: workspace, activeCount, workspaceRunning, version });
168
+
169
+ switch (action) {
170
+ case "web":
171
+ await dev(packageDir, workspace, { mode: "web" });
172
+ break;
173
+ case "shell":
174
+ await dev(packageDir, workspace, { mode: "shell" });
175
+ break;
176
+ case "stop":
177
+ await stop(workspace.containerName);
178
+ break;
179
+ case "settings": {
180
+ const settingsResult = await settingsMenu(workspace);
181
+ if (settingsResult === "rebuild") {
182
+ // resetImage returns false when the user declines a running-container rebuild - skip the
183
+ // build and return to the menu, leaving the live session untouched.
184
+ if (await resetImage(workspace.containerName)) {
185
+ await dev(packageDir, workspace, { mode: "web" });
186
+ } else {
187
+ showMenu = true;
188
+ }
189
+ } else if (settingsResult === "clean-rebuild") {
190
+ if (await resetImage(workspace.containerName)) {
191
+ await dev(packageDir, workspace, { noCache: true, mode: "web" });
192
+ } else {
193
+ showMenu = true;
194
+ }
195
+ } else {
196
+ showMenu = true;
197
+ }
198
+ break;
199
+ }
200
+ case "advanced": {
201
+ const result = await advancedMenu(workspace.workspaceId);
202
+ if (result === "back") showMenu = true;
203
+ break;
204
+ }
205
+ case "quit":
206
+ // Trailing blank line so the menu does not sit flush against the next shell prompt.
207
+ process.stdout.write("\n");
208
+ break;
209
+ default:
210
+ break;
211
+ }
212
+ }
@@ -0,0 +1,278 @@
1
+ // =========================================================================================================================================
2
+ // src/commands/advanced.ts - Advanced menu: less-common operations (stop containers, clear agent memory, remove images, uninstall)
3
+ // =========================================================================================================================================
4
+ import { spawnSync } from "node:child_process";
5
+ import { existsSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { cancel, confirm, isCancel, log, multiselect, outro, select, text } from "@clack/prompts";
9
+ import { AGENTS_DIR, CHAMBA_DIR, CHAMBA_YAML, CONTAINER_NAME_PREFIX, LABEL_MANAGED } from "../lib/constants.js";
10
+ import { safeRmSync } from "../lib/safe-rm.js";
11
+ import { listWorkspaces } from "../lib/workspace-identity.js";
12
+ // --- Helpers -----------------------------------------------------------------------------------------------------------------------------
13
+ /** Remove workspace cache dir and optionally chamba.yaml from the workspace root. Exported for testing. */
14
+ export function removeWorkspaceFiles(workspaceRoot, workspaceDir, removeChambaYaml) {
15
+ safeRmSync(workspaceDir, { recursive: true, force: true });
16
+ if (removeChambaYaml) {
17
+ const yamlPath = join(workspaceRoot, CHAMBA_YAML);
18
+ if (existsSync(yamlPath)) {
19
+ safeRmSync(yamlPath);
20
+ }
21
+ }
22
+ }
23
+ // Stop-only (no rm) so the next session resumes fast via the "exited" -> docker start path.
24
+ function stopContainer(name) {
25
+ spawnSync("docker", ["stop", name], { stdio: "pipe" });
26
+ }
27
+ // Stop and remove - only for flows that must recreate the container (image removal, uninstall).
28
+ function stopAndRemoveContainer(name) {
29
+ spawnSync("docker", ["stop", name], { stdio: "pipe" });
30
+ spawnSync("docker", ["rm", name], { stdio: "pipe" });
31
+ }
32
+ // --- Stop containers (multi-select across all workspaces) --------------------------------------------------------------------------------
33
+ async function stopContainers() {
34
+ const listResult = spawnSync("docker", ["ps", "--filter", `name=${CONTAINER_NAME_PREFIX}`, "--format", "{{.Names}}"], {
35
+ encoding: "utf8",
36
+ });
37
+ const running = (listResult.stdout ?? "").trim().split("\n").filter(Boolean);
38
+ if (running.length === 0) {
39
+ log.info("No running chamba containers.");
40
+ return;
41
+ }
42
+ // Always show the selector, even for a single container, so nothing is stopped without the user picking it.
43
+ const selected = await multiselect({
44
+ message: "Select containers to stop: (space to toggle, enter to confirm)",
45
+ options: running.map((name) => ({ value: name, label: name })),
46
+ required: false,
47
+ });
48
+ if (isCancel(selected)) {
49
+ cancel();
50
+ return;
51
+ }
52
+ const toStop = selected;
53
+ if (toStop.length === 0) {
54
+ log.info("Nothing selected - no containers stopped.");
55
+ return;
56
+ }
57
+ for (const name of toStop) {
58
+ log.info(`Stopping ${name}...`);
59
+ stopContainer(name);
60
+ }
61
+ log.success("Done.");
62
+ }
63
+ // --- Clear agent memory (multi-select across all workspaces) -----------------------------------------------------------------------------
64
+ async function clearAgentMemory() {
65
+ const workspaces = listWorkspaces().filter((w) => existsSync(join(w.workspaceDir, AGENTS_DIR)));
66
+ if (workspaces.length === 0) {
67
+ log.info("No agent memory found.");
68
+ return;
69
+ }
70
+ // Always show the selector, even for a single workspace, so memory is never cleared without the user picking it.
71
+ const selected = await multiselect({
72
+ message: "Select workspaces to clear agent memory for: (space to toggle, enter to confirm)",
73
+ options: workspaces.map((w) => ({ value: w.workspaceId, label: w.workspaceId, hint: w.workspaceRoot })),
74
+ required: false,
75
+ });
76
+ if (isCancel(selected)) {
77
+ cancel();
78
+ return;
79
+ }
80
+ const toClear = selected;
81
+ if (toClear.length === 0) {
82
+ log.info("Nothing selected - no agent memory cleared.");
83
+ return;
84
+ }
85
+ for (const id of toClear) {
86
+ const w = workspaces.find((x) => x.workspaceId === id);
87
+ if (!w)
88
+ continue;
89
+ const inspectResult = spawnSync("docker", ["inspect", "--format", "{{.State.Status}}", w.containerName], {
90
+ encoding: "utf8",
91
+ stdio: "pipe",
92
+ });
93
+ const isRunning = inspectResult.status === 0 && inspectResult.stdout.trim() === "running";
94
+ if (isRunning) {
95
+ const confirmed = await confirm({
96
+ message: `Container for ${w.workspaceId} is running. Stop it to clear memory?`,
97
+ });
98
+ if (isCancel(confirmed) || !confirmed)
99
+ continue;
100
+ log.info(`Stopping ${w.containerName}...`);
101
+ stopContainer(w.containerName);
102
+ }
103
+ const agentsDir = join(w.workspaceDir, AGENTS_DIR);
104
+ safeRmSync(agentsDir, { recursive: true, force: true });
105
+ log.success(`Cleared agent memory for ${w.workspaceId}.`);
106
+ }
107
+ }
108
+ // --- Remove images (multi-select across all workspaces) ----------------------------------------------------------------------------------
109
+ async function removeImages() {
110
+ const listResult = spawnSync("docker", ["images", "--filter", `label=${LABEL_MANAGED}=true`, "--format", "{{.Repository}}\t{{.ID}}"], {
111
+ encoding: "utf8",
112
+ });
113
+ const lines = (listResult.stdout ?? "").trim().split("\n").filter(Boolean);
114
+ if (lines.length === 0) {
115
+ log.info("No chamba images found.");
116
+ return;
117
+ }
118
+ const images = lines.map((line) => {
119
+ const parts = line.split("\t");
120
+ const repo = parts[0] ?? "";
121
+ const id = parts[1] ?? "";
122
+ return { repo, id };
123
+ });
124
+ const selected = await multiselect({
125
+ message: "Select images to remove: (space to toggle, enter to confirm)",
126
+ options: images.map((img) => ({ value: img.repo, label: img.repo, hint: img.id.slice(0, 12) })),
127
+ required: false,
128
+ });
129
+ if (isCancel(selected)) {
130
+ cancel();
131
+ return;
132
+ }
133
+ // Stop any running container using this image first
134
+ for (const repo of selected) {
135
+ const psResult = spawnSync("docker", ["ps", "--filter", `name=${repo}`, "--format", "{{.Names}}"], {
136
+ encoding: "utf8",
137
+ });
138
+ const containers = (psResult.stdout ?? "").trim().split("\n").filter(Boolean);
139
+ for (const c of containers) {
140
+ log.info(`Stopping container ${c} before removing image...`);
141
+ stopAndRemoveContainer(c);
142
+ }
143
+ log.info(`Removing image ${repo}...`);
144
+ spawnSync("docker", ["rmi", repo], { stdio: "pipe" });
145
+ }
146
+ log.success("Done.");
147
+ }
148
+ // --- Uninstall workspaces (multi-select, remove container + image + workspace dir) -------------------------------------------------------
149
+ async function uninstallWorkspaces(currentWorkspaceId) {
150
+ const workspaces = listWorkspaces();
151
+ if (workspaces.length === 0) {
152
+ log.info("No registered workspaces.");
153
+ return false;
154
+ }
155
+ // Show current workspace first if known
156
+ const sorted = currentWorkspaceId
157
+ ? [...workspaces].sort((a, b) => (a.workspaceId === currentWorkspaceId ? -1 : b.workspaceId === currentWorkspaceId ? 1 : 0))
158
+ : workspaces;
159
+ log.info("Uninstalling a workspace stops and removes its container and image, and deletes its data from ~/.chamba/workspaces/. Your files are untouched.");
160
+ const selected = await multiselect({
161
+ message: "Select workspaces to uninstall: (space to toggle, enter to confirm)",
162
+ options: sorted.map((w) => ({
163
+ value: w.workspaceId,
164
+ label: w.workspaceId,
165
+ hint: w.workspaceRoot + (w.workspaceId === currentWorkspaceId ? " (current)" : ""),
166
+ })),
167
+ required: false,
168
+ });
169
+ if (isCancel(selected)) {
170
+ cancel();
171
+ return false;
172
+ }
173
+ const selectedIds = selected;
174
+ for (const id of selectedIds) {
175
+ const w = workspaces.find((x) => x.workspaceId === id);
176
+ if (!w)
177
+ continue;
178
+ // Stop and remove container if it exists (running or exited)
179
+ const psResult = spawnSync("docker", ["ps", "-a", "--filter", `name=${w.containerName}`, "--format", "{{.Names}}"], {
180
+ encoding: "utf8",
181
+ });
182
+ const containers = (psResult.stdout ?? "").trim().split("\n").filter(Boolean);
183
+ for (const c of containers) {
184
+ log.info(`Stopping and removing container ${c}...`);
185
+ stopAndRemoveContainer(c);
186
+ }
187
+ log.info(`Removing image ${w.containerName}...`);
188
+ spawnSync("docker", ["rmi", w.containerName], { stdio: "inherit" });
189
+ // Ask whether to also remove chamba.yaml from the workspace root
190
+ const yamlPath = join(w.workspaceRoot, CHAMBA_YAML);
191
+ let removeChambaYaml = false;
192
+ if (existsSync(yamlPath)) {
193
+ const ans = await confirm({
194
+ message: `Also remove chamba.yaml from ${w.workspaceRoot}?`,
195
+ initialValue: true,
196
+ });
197
+ removeChambaYaml = !isCancel(ans) && ans;
198
+ }
199
+ removeWorkspaceFiles(w.workspaceRoot, w.workspaceDir, removeChambaYaml);
200
+ log.success(`Uninstalled workspace ${w.workspaceId}.`);
201
+ }
202
+ return currentWorkspaceId !== undefined && selectedIds.includes(currentWorkspaceId);
203
+ }
204
+ // --- Uninstall chamba (global) -----------------------------------------------------------------------------------------------------------
205
+ async function uninstallChamba() {
206
+ const confirmed = await text({
207
+ message: 'Type "uninstall-chamba" to confirm full uninstall:',
208
+ validate: (v) => ((v ?? "").trim() !== "uninstall-chamba" ? 'Type exactly "uninstall-chamba" to confirm' : undefined),
209
+ });
210
+ if (isCancel(confirmed) || confirmed.trim() !== "uninstall-chamba") {
211
+ cancel("Uninstall cancelled.");
212
+ return;
213
+ }
214
+ // Stop and remove all chamba containers
215
+ const psResult = spawnSync("docker", ["ps", "-a", "--filter", `name=${CONTAINER_NAME_PREFIX}`, "--format", "{{.Names}}"], {
216
+ encoding: "utf8",
217
+ });
218
+ const containers = (psResult.stdout ?? "").trim().split("\n").filter(Boolean);
219
+ for (const c of containers) {
220
+ log.info(`Stopping and removing container ${c}...`);
221
+ stopAndRemoveContainer(c);
222
+ }
223
+ // Remove all chamba images
224
+ const imagesResult = spawnSync("docker", ["images", "--filter", `label=${LABEL_MANAGED}=true`, "--format", "{{.Repository}}"], {
225
+ encoding: "utf8",
226
+ });
227
+ const imgs = (imagesResult.stdout ?? "").trim().split("\n").filter(Boolean);
228
+ for (const img of imgs) {
229
+ log.info(`Removing image ${img}...`);
230
+ spawnSync("docker", ["rmi", img], { stdio: "inherit" });
231
+ }
232
+ // Delete ~/.chamba/
233
+ const globalChambaDir = join(homedir(), CHAMBA_DIR);
234
+ if (existsSync(globalChambaDir)) {
235
+ log.info("Deleting ~/.chamba/...");
236
+ safeRmSync(globalChambaDir, { recursive: true, force: true });
237
+ }
238
+ outro("chamba uninstalled. Re-run npx chamba to set up again.");
239
+ }
240
+ // --- Advanced menu -----------------------------------------------------------------------------------------------------------------------
241
+ export async function run(currentWorkspaceId) {
242
+ while (true) {
243
+ const action = await select({
244
+ message: "Advanced:",
245
+ options: [
246
+ { value: "stop-containers", label: "Stop containers", hint: "pick running containers" },
247
+ { value: "clear-memory", label: "Clear agent memory", hint: "pick workspaces to clear" },
248
+ { value: "remove-images", label: "Remove images", hint: "pick images to remove" },
249
+ { value: "uninstall-workspace", label: "Uninstall workspaces", hint: "pick workspaces to remove" },
250
+ { value: "uninstall", label: "Uninstall chamba", hint: "wipe ~/.chamba/ and all containers/images" },
251
+ { value: "back", label: "← Back" },
252
+ ],
253
+ });
254
+ if (isCancel(action) || action === "back") {
255
+ return "back";
256
+ }
257
+ switch (action) {
258
+ case "stop-containers":
259
+ await stopContainers();
260
+ break;
261
+ case "clear-memory":
262
+ await clearAgentMemory();
263
+ break;
264
+ case "remove-images":
265
+ await removeImages();
266
+ break;
267
+ case "uninstall-workspace": {
268
+ const currentDeleted = await uninstallWorkspaces(currentWorkspaceId);
269
+ if (currentDeleted)
270
+ return undefined;
271
+ break;
272
+ }
273
+ case "uninstall":
274
+ await uninstallChamba();
275
+ return undefined;
276
+ }
277
+ }
278
+ }