totopo 0.8.0 → 0.9.0-rc-2

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/README.md CHANGED
@@ -8,15 +8,15 @@ Spin up a secure, isolated AI coding environment in any git project — in one c
8
8
 
9
9
  ## How It Works
10
10
 
11
- `npx totopo` sets up a hardened Docker container in your project with AI coding assistants (Claude, Kilo, OpenCode) pre-installed. Your code stays on your host machine. The AI tools run isolated inside the container.
11
+ `npx totopo` sets up a hardened Docker container in your project with AI coding assistants (OpenCode, Claude Code, Codex) pre-installed. Your code stays on your host machine. The AI tools run isolated inside the container.
12
12
 
13
13
  ```
14
14
  Host machine
15
15
  ├── your editor → edits files normally (bind-mounted from container)
16
16
  ├── terminal → connected to container via docker exec
17
- │ ├── claude → AI tools run here, isolated
18
- │ ├── kilo
19
- │ └── opencode
17
+ │ ├── opencode → AI tools run here, isolated
18
+ │ ├── claude
19
+ │ └── codex
20
20
  └── git push/pull → only possible from host, blocked inside container
21
21
  ```
22
22
 
@@ -74,9 +74,9 @@ your-project/
74
74
  Run inside the container terminal:
75
75
 
76
76
  ```bash
77
- claude # Claude Code (Anthropic)
78
- kilo # Kilo AI
79
77
  opencode # OpenCode
78
+ claude # Claude Code (Anthropic)
79
+ codex # Codex (OpenAI)
80
80
  status # Re-run security + readiness check
81
81
  ```
82
82
 
@@ -90,7 +90,7 @@ status # Re-run security + readiness check
90
90
  | Filesystem isolation | Only the repo is mounted — host is not visible |
91
91
  | Git remote block | `protocol.allow never` in `/etc/gitconfig` — enforced at the git layer, requires root to override |
92
92
  | No privilege escalation | `no-new-privileges:true` security opt |
93
- | Secrets never in image | API keys injected at runtime via `.env` only |
93
+ | Secrets never in image | API keys loaded at runtime from `~/.totopo/.env` — never baked into the image, never mounted into the container |
94
94
 
95
95
  Remote git operations are blocked inside the container. Push from your host terminal instead.
96
96
 
package/bin/totopo.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // =============================================================================
6
6
 
7
7
  import { execSync, spawnSync } from "node:child_process";
8
- import { existsSync, readFileSync } from "node:fs";
8
+ import { existsSync } from "node:fs";
9
9
  import { basename, dirname } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
 
@@ -60,9 +60,7 @@ const { run: onboard } = await import("../dist/commands/onboard.js");
60
60
  const { run: menu } = await import("../dist/commands/menu.js");
61
61
  const { run: dev } = await import("../dist/commands/dev.js");
62
62
  const { run: stop } = await import("../dist/commands/stop.js");
63
- const { run: rebuild } = await import("../dist/commands/rebuild.js");
64
- const { run: manage } = await import("../dist/commands/manage.js");
65
- const { run: settings } = await import("../dist/commands/settings.js");
63
+ const { run: advanced } = await import("../dist/commands/advanced.js");
66
64
 
67
65
  // ─── Onboarding ───────────────────────────────────────────────────────────────
68
66
  if (!existsSync(`${repoRoot}/.totopo/Dockerfile`)) {
@@ -98,29 +96,12 @@ const projectRunning = (projectContainerResult.stdout ?? "")
98
96
  .filter(Boolean)
99
97
  .some((n) => n === `totopo-managed-${projectName}`);
100
98
 
101
- const projectImageResult = spawnSync("docker", ["images", "-q", `totopo-managed-${projectName}`], { encoding: "utf8" });
102
- const projectImageExists = (projectImageResult.stdout ?? "").trim().length > 0;
103
-
104
- let hasKey = false;
105
- const envPath = `${repoRoot}/.totopo/.env`;
106
- if (existsSync(envPath)) {
107
- for (const line of readFileSync(envPath, "utf8").split("\n")) {
108
- const trimmed = line.trim();
109
- if (!trimmed || trimmed.startsWith("#")) continue;
110
- const value = trimmed.slice(trimmed.indexOf("=") + 1).trim();
111
- if (value) {
112
- hasKey = true;
113
- break;
114
- }
115
- }
116
- }
117
-
118
99
  // ─── Interactive menu loop ────────────────────────────────────────────────────
119
100
  let showMenu = true;
120
101
  while (showMenu) {
121
102
  showMenu = false;
122
103
 
123
- const action = await menu({ projectName, activeCount, hasKey, projectRunning, projectImageExists });
104
+ const action = await menu({ projectName, activeCount, projectRunning });
124
105
 
125
106
  switch (action) {
126
107
  case "dev":
@@ -129,20 +110,8 @@ while (showMenu) {
129
110
  case "stop":
130
111
  await stop(projectName);
131
112
  break;
132
- case "rebuild":
133
- await rebuild(projectName);
134
- await dev(packageDir, repoRoot);
135
- break;
136
- case "manage": {
137
- const result = await manage(projectName, repoRoot);
138
- if (result === "back") showMenu = true;
139
- break;
140
- }
141
- case "doctor":
142
- await doctor(repoRoot, true);
143
- break;
144
- case "settings": {
145
- const result = await settings(packageDir, repoRoot);
113
+ case "advanced": {
114
+ const result = await advanced(packageDir, projectName, repoRoot);
146
115
  if (result === "back") showMenu = true;
147
116
  break;
148
117
  }
@@ -0,0 +1,207 @@
1
+ // =========================================================================================================================================
2
+ // src/core/commands/advanced.ts — Advanced submenu
3
+ // Invoked by bin/totopo.js — do not run directly.
4
+ // =========================================================================================================================================
5
+ import { spawnSync } from "node:child_process";
6
+ import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { cancel, confirm, isCancel, log, multiselect, outro, select } from "@clack/prompts";
10
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
11
+ function stopAndRemoveContainer(name) {
12
+ spawnSync("docker", ["stop", name], { stdio: "inherit" });
13
+ spawnSync("docker", ["rm", name], { stdio: "inherit" });
14
+ }
15
+ // ─── Clear agent memory ───────────────────────────────────────────────────────
16
+ async function clearAgentMemory(projectName, totopoDir) {
17
+ const containerName = `totopo-managed-${projectName}`;
18
+ // Check if the container is running
19
+ const inspectResult = spawnSync("docker", ["inspect", "--format", "{{.State.Status}}", containerName], {
20
+ encoding: "utf8",
21
+ stdio: "pipe",
22
+ });
23
+ const isRunning = inspectResult.status === 0 && inspectResult.stdout.trim() === "running";
24
+ if (isRunning) {
25
+ const confirmed = await confirm({
26
+ message: `The dev container for ${projectName} is running. It must be stopped to clear agent memory. Continue?`,
27
+ });
28
+ if (isCancel(confirmed) || !confirmed) {
29
+ cancel("Cancelled.");
30
+ return;
31
+ }
32
+ log.step(`Stopping ${containerName}...`);
33
+ stopAndRemoveContainer(containerName);
34
+ }
35
+ const agentsDir = join(totopoDir, "agents");
36
+ if (existsSync(agentsDir)) {
37
+ rmSync(agentsDir, { recursive: true, force: true });
38
+ }
39
+ log.success("Agent memory cleared. Context will be regenerated on next session start.");
40
+ }
41
+ // ─── Stop containers ──────────────────────────────────────────────────────────
42
+ async function stopContainers() {
43
+ const listResult = spawnSync("docker", ["ps", "--filter", "name=totopo-managed-", "--format", "{{.Names}}"], {
44
+ encoding: "utf8",
45
+ });
46
+ const running = (listResult.stdout ?? "").trim().split("\n").filter(Boolean);
47
+ if (running.length === 0) {
48
+ log.info("No running totopo containers.");
49
+ return;
50
+ }
51
+ let toStop;
52
+ if (running.length === 1) {
53
+ toStop = running;
54
+ log.info(`Stopping ${running[0]}...`);
55
+ }
56
+ else {
57
+ const selected = await multiselect({
58
+ message: "Select containers to stop:",
59
+ options: running.map((name) => ({ value: name, label: name })),
60
+ required: false,
61
+ });
62
+ if (isCancel(selected)) {
63
+ cancel();
64
+ return;
65
+ }
66
+ toStop = selected;
67
+ }
68
+ for (const name of toStop) {
69
+ log.step(`Stopping ${name}...`);
70
+ stopAndRemoveContainer(name);
71
+ }
72
+ log.success("Done.");
73
+ }
74
+ // ─── Remove images ────────────────────────────────────────────────────────────
75
+ async function removeImages() {
76
+ const listResult = spawnSync("docker", ["images", "--filter", "label=totopo.managed=true", "--format", "{{.Repository}}\t{{.ID}}"], {
77
+ encoding: "utf8",
78
+ });
79
+ const lines = (listResult.stdout ?? "").trim().split("\n").filter(Boolean);
80
+ if (lines.length === 0) {
81
+ log.info("No totopo images found.");
82
+ return;
83
+ }
84
+ const images = lines.map((line) => {
85
+ const [repo, id] = line.split("\t");
86
+ const workspace = (repo ?? "").replace(/^totopo-managed-/, "");
87
+ return { repo: repo ?? "", id: id ?? "", workspace };
88
+ });
89
+ const selected = await multiselect({
90
+ message: "Select images to remove:",
91
+ options: images.map((img) => ({
92
+ value: img.repo,
93
+ label: `${img.workspace} (${img.repo})`,
94
+ })),
95
+ required: false,
96
+ });
97
+ if (isCancel(selected)) {
98
+ cancel();
99
+ return;
100
+ }
101
+ for (const repo of selected) {
102
+ // Stop any running container using this image first
103
+ const psResult = spawnSync("docker", ["ps", "--filter", `name=${repo}`, "--format", "{{.Names}}"], {
104
+ encoding: "utf8",
105
+ });
106
+ const containers = (psResult.stdout ?? "").trim().split("\n").filter(Boolean);
107
+ for (const c of containers) {
108
+ log.step(`Stopping container ${c} before removing image...`);
109
+ stopAndRemoveContainer(c);
110
+ }
111
+ log.step(`Removing image ${repo}...`);
112
+ spawnSync("docker", ["rmi", repo], { stdio: "inherit" });
113
+ }
114
+ log.success("Done.");
115
+ }
116
+ // ─── Uninstall ────────────────────────────────────────────────────────────────
117
+ async function uninstall(projectName, repoRoot) {
118
+ const containerName = `totopo-managed-${projectName}`;
119
+ const imageName = `totopo-managed-${projectName}`;
120
+ const confirmed = await confirm({
121
+ message: `Remove .totopo/, stop containers, and delete the image for ${projectName}?`,
122
+ });
123
+ if (isCancel(confirmed) || !confirmed) {
124
+ cancel();
125
+ return;
126
+ }
127
+ const inspectResult = spawnSync("docker", ["inspect", "--type", "container", containerName], { encoding: "utf8" });
128
+ if (inspectResult.status === 0) {
129
+ log.step(`Stopping container ${containerName}...`);
130
+ stopAndRemoveContainer(containerName);
131
+ }
132
+ const imageResult = spawnSync("docker", ["images", "-q", imageName], { encoding: "utf8" });
133
+ if ((imageResult.stdout ?? "").trim().length > 0) {
134
+ log.step(`Removing image ${imageName}...`);
135
+ spawnSync("docker", ["rmi", imageName], { stdio: "inherit" });
136
+ }
137
+ log.step("Removing .totopo/...");
138
+ rmSync(join(repoRoot, ".totopo"), { recursive: true, force: true });
139
+ outro("Uninstalled. Re-run npx totopo to set up again.");
140
+ }
141
+ // ─── Reset API keys ───────────────────────────────────────────────────────────
142
+ async function resetApiKeys(packageDir) {
143
+ const globalEnvPath = join(homedir(), ".totopo", ".env");
144
+ const confirmed = await confirm({
145
+ message: `Reset ${globalEnvPath}? This affects all totopo projects on this machine.`,
146
+ });
147
+ if (isCancel(confirmed) || !confirmed) {
148
+ cancel("Cancelled.");
149
+ return;
150
+ }
151
+ mkdirSync(join(homedir(), ".totopo"), { recursive: true });
152
+ cpSync(join(packageDir, "templates", "env"), globalEnvPath);
153
+ log.success(`API keys reset. Edit ${globalEnvPath} to add your keys.`);
154
+ }
155
+ // ─── Advanced submenu ─────────────────────────────────────────────────────────
156
+ export async function run(packageDir, projectName, repoRoot) {
157
+ // Dynamic imports to avoid circular deps — same pattern as bin/totopo.js
158
+ const { run: runSettings } = await import("./settings.js");
159
+ const { run: runRebuild } = await import("./rebuild.js");
160
+ const { run: runDoctor } = await import("./doctor.js");
161
+ const totopoDir = join(repoRoot, ".totopo");
162
+ while (true) {
163
+ const action = await select({
164
+ message: "Advanced:",
165
+ options: [
166
+ { value: "runtime-mode", label: "Runtime mode", hint: "switch between host-mirror and full" },
167
+ { value: "rebuild", label: "Rebuild container", hint: "force a fresh image build for this project" },
168
+ { value: "clear-memory", label: "Clear agent memory", hint: "wipe conversation history for this project" },
169
+ { value: "uninstall", label: "Uninstall from this project", hint: "removes .totopo/ and deletes the container and image" },
170
+ { value: "stop-containers", label: "Stop containers", hint: "all projects" },
171
+ { value: "remove-images", label: "Remove images", hint: "all projects" },
172
+ { value: "reset-keys", label: "Reset API keys", hint: "overwrites ~/.totopo/.env — affects all projects" },
173
+ { value: "doctor", label: "Doctor", hint: "check Docker and container health" },
174
+ { value: "back", label: "← Back" },
175
+ ],
176
+ });
177
+ if (isCancel(action) || action === "back") {
178
+ return "back";
179
+ }
180
+ switch (action) {
181
+ case "runtime-mode":
182
+ await runSettings(packageDir, repoRoot);
183
+ break;
184
+ case "rebuild":
185
+ await runRebuild(projectName);
186
+ break;
187
+ case "clear-memory":
188
+ await clearAgentMemory(projectName, totopoDir);
189
+ break;
190
+ case "reset-keys":
191
+ await resetApiKeys(packageDir);
192
+ break;
193
+ case "uninstall":
194
+ await uninstall(projectName, repoRoot);
195
+ return; // uninstall tears down .totopo — exit entirely
196
+ case "doctor":
197
+ await runDoctor(repoRoot, true);
198
+ break;
199
+ case "stop-containers":
200
+ await stopContainers();
201
+ break;
202
+ case "remove-images":
203
+ await removeImages();
204
+ break;
205
+ }
206
+ }
207
+ }
@@ -3,9 +3,9 @@
3
3
  // Invoked by bin/totopo.js — do not run directly.
4
4
  // =========================================================================================================================================
5
5
  import { spawnSync } from "node:child_process";
6
- import { existsSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
7
- import { tmpdir } from "node:os";
8
- import { basename, join, relative } from "node:path";
6
+ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { basename, dirname, join, relative } from "node:path";
9
9
  import { cancel, confirm, groupMultiselect, isCancel, log, multiselect, note, outro, path, select } from "@clack/prompts";
10
10
  // ─── Prompt: scope selection ──────────────────────────────────────────────────
11
11
  async function promptScope(workspaceDir, totopoDir, cwd) {
@@ -30,8 +30,24 @@ async function promptScope(workspaceDir, totopoDir, cwd) {
30
30
  // Fallback to cwd mode when no visible items exist
31
31
  return { mode: "cwd", hostCwd: cwd, selectedPaths: [] };
32
32
  }
33
+ log.warn("Scoped workspace — some context may be unavailable to the agent:\n" +
34
+ " · Your personal agent config files (~/.claude/CLAUDE.md, ~/.config/opencode/AGENTS.md, etc.)\n" +
35
+ " are not mounted from the host — only totopo's injected context is available.\n" +
36
+ " · Project-level context files (AGENTS.md, CLAUDE.md, .claude/rules/, etc.) that live\n" +
37
+ " outside your mounted paths will not be visible to the agent.\n" +
38
+ " · Git is unavailable — .git is not mounted in scoped mode (security boundary).\n" +
39
+ " The agent has been instructed to surface its limitations at session start.");
33
40
  return { mode, hostCwd: cwd, selectedPaths };
34
41
  }
42
+ if (mode === "cwd") {
43
+ log.warn("Scoped workspace — some context may be unavailable to the agent:\n" +
44
+ " · Your personal agent config files (~/.claude/CLAUDE.md, ~/.config/opencode/AGENTS.md, etc.)\n" +
45
+ " are not mounted from the host — only totopo's injected context is available.\n" +
46
+ " · Project-level context files (AGENTS.md, CLAUDE.md, .claude/rules/, etc.) that live\n" +
47
+ " outside this directory will not be visible to the agent.\n" +
48
+ " · Git is unavailable — .git is not mounted in scoped mode (security boundary).\n" +
49
+ " The agent has been instructed to surface its limitations at session start.");
50
+ }
35
51
  return { mode, hostCwd: cwd, selectedPaths: [] };
36
52
  }
37
53
  // ─── Prompt: selective path selection ─────────────────────────────────────────
@@ -209,14 +225,31 @@ function getTotopoMountPath(scope, workspaceDir) {
209
225
  return "/workspace/.totopo";
210
226
  return "/home/devuser/.totopo";
211
227
  }
228
+ // ─── Build agent mount args ───────────────────────────────────────────────────
229
+ // Creates .totopo/agents/ subdirectories on the host (lazily, on first run) and
230
+ // returns volume mount args for all supported agent tools. Each agent tool gets
231
+ // its own read-write bind mount so session data persists across container rebuilds.
232
+ function buildAgentMountArgs(totopoDir) {
233
+ const agentsDir = join(totopoDir, "agents");
234
+ const mounts = [
235
+ { host: join(agentsDir, "claude"), container: "/home/devuser/.claude" },
236
+ { host: join(agentsDir, "opencode", "config"), container: "/home/devuser/.config/opencode" },
237
+ { host: join(agentsDir, "opencode", "data"), container: "/home/devuser/.local/share/opencode" },
238
+ { host: join(agentsDir, "codex"), container: "/home/devuser/.codex" },
239
+ ];
240
+ for (const { host } of mounts)
241
+ mkdirSync(host, { recursive: true });
242
+ return mounts.flatMap(({ host, container }) => ["-v", `${host}:${container}`]);
243
+ }
212
244
  // ─── Build mount args ─────────────────────────────────────────────────────────
213
245
  function buildMountArgs(scope, workspaceDir, totopoDir, cwd) {
214
246
  const totopoMount = getTotopoMountPath(scope, workspaceDir);
247
+ const agentMounts = buildAgentMountArgs(totopoDir);
215
248
  if (scope.mode === "repo") {
216
- return ["-v", `${workspaceDir}:/workspace`];
249
+ return ["-v", `${workspaceDir}:/workspace`, ...agentMounts];
217
250
  }
218
251
  if (scope.mode === "cwd") {
219
- return ["-v", `${cwd}:/workspace`, ...(cwd !== workspaceDir ? ["-v", `${totopoDir}:${totopoMount}:ro`] : [])];
252
+ return ["-v", `${cwd}:/workspace`, ...(cwd !== workspaceDir ? ["-v", `${totopoDir}:${totopoMount}:ro`] : []), ...agentMounts];
220
253
  }
221
254
  // selective: validate all paths exist first
222
255
  for (const p of scope.selectedPaths) {
@@ -226,7 +259,12 @@ function buildMountArgs(scope, workspaceDir, totopoDir, cwd) {
226
259
  process.exit(1);
227
260
  }
228
261
  }
229
- return [...scope.selectedPaths.flatMap((p) => ["-v", `${join(cwd, p)}:/workspace/${p}`]), "-v", `${totopoDir}:${totopoMount}:ro`];
262
+ return [
263
+ ...scope.selectedPaths.flatMap((p) => ["-v", `${join(cwd, p)}:/workspace/${p}`]),
264
+ "-v",
265
+ `${totopoDir}:${totopoMount}:ro`,
266
+ ...agentMounts,
267
+ ];
230
268
  }
231
269
  // ─── Build scope env args ─────────────────────────────────────────────────────
232
270
  function buildScopeEnvArgs(scope) {
@@ -290,47 +328,102 @@ function scopesMatch(selected, existing, workspaceDir) {
290
328
  }
291
329
  return true;
292
330
  }
293
- // ─── Build agent context document ─────────────────────────────────────────────
294
- // Designed for future extension: also inject AGENTS.md alongside CLAUDE.md.
295
- function buildAgentContextDoc(scope, workspaceDir) {
331
+ function buildAgentContextDocs(scope) {
332
+ // ── Scope section ──────────────────────────────────────────────────────────
296
333
  let scopeSection;
297
334
  if (scope.mode === "repo") {
298
335
  scopeSection = `## Workspace scope: repo
299
336
 
300
- You are running inside a totopo dev container. The full repository is accessible at \`/workspace\`. Some operations (git push, system-level changes) require running on the host.`;
337
+ You have access to the full repository at \`/workspace\`. Some operations (git push, system-level changes) require running on the host.`;
301
338
  }
302
339
  else if (scope.mode === "cwd") {
303
340
  scopeSection = `## Workspace scope: cwd
304
341
 
305
- Workspace is scoped to one directory (\`${scope.hostCwd}\`). Files outside it are not visible. Commands that depend on absent files will fail.`;
342
+ Workspace is scoped to one directory (\`${scope.hostCwd}\`). Files outside it are not visible to you. Commands that depend on absent files will fail.`;
306
343
  }
307
344
  else {
308
345
  const pathList = scope.selectedPaths.map((p) => `- \`/workspace/${p}\``).join("\n");
309
346
  scopeSection = `## Workspace scope: selective
310
347
 
311
- Workspace is selectively scoped. The following paths are mounted:\n\n${pathList}`;
348
+ Workspace is selectively scoped. Only the following paths are mounted:\n\n${pathList}`;
349
+ }
350
+ // ── Git section ────────────────────────────────────────────────────────────
351
+ let gitSection;
352
+ if (scope.mode === "repo") {
353
+ gitSection = `## Git availability
354
+
355
+ Git is fully available for local operations (commit, branch, log, diff, status, etc.).
356
+
357
+ Remote access (push, pull, fetch, clone) is **blocked at the system level** by design — \`protocol.allow = never\` is enforced in \`/etc/gitconfig\` and cannot be overridden without root. This is a deliberate security boundary: the container has no access to remote repositories. Ask the user to run any remote git operations from the host.`;
358
+ }
359
+ else {
360
+ gitSection = `## Git availability
361
+
362
+ Git local operations are **not available** in this scope — \`.git\` is not mounted. This is intentional: mounting \`.git\` would expose the full commit history of all repository files, including those outside your current mount, defeating the security boundary of scoped access.
363
+
364
+ Remote access is also **blocked container-wide** by design (\`protocol.allow = never\` in \`/etc/gitconfig\`).
365
+
366
+ If git operations are needed, ask the user to run them on the host.`;
312
367
  }
313
- const constraintsSection = `## Constraints
368
+ // ── Selective-only warning ─────────────────────────────────────────────────
369
+ const selectiveWarning = scope.mode === "selective"
370
+ ? `\n\n## Selective scope: file creation warning
371
+
372
+ Any file you create **outside your mounted paths** (e.g. at \`/\`, \`/tmp\`, or any path not listed above) will **not be visible on the host** and will be lost when the container is rebuilt.
373
+
374
+ If the user asks you to create or modify a file at such a location:
375
+ 1. Notify the user that the path is outside your mounted workspace.
376
+ 2. Explain that files created there will not sync to the host.
377
+ 3. Suggest the user run the command on the host instead, or confirm they want the file only inside the container (understanding it will be lost on rebuild).`
378
+ : "";
379
+ // ── Responsibilities section ───────────────────────────────────────────────
380
+ const responsibilitiesSection = `## Your responsibilities at session start
381
+
382
+ At the start of every session:
383
+ - Briefly surface your current workspace scope and its limitations to the user.
384
+ - Tell the user what you cannot access in this session (files, git, remotes).`;
385
+ // ── Assemble per-tool — only the self-referencing path differs ─────────────
386
+ function build(toolPath) {
387
+ const constraintsSection = `## Constraints
314
388
 
315
389
  - Files outside mounted paths cannot be read, written, or executed.
316
390
  - If a command fails because of missing files, tell the user: "I have limited workspace scope — please run \`<command>\` on the host."
317
- - This file (\`~/.claude/CLAUDE.md\`) is container-generated. Edits will not persist to the host.
318
- - \`.totopo/\` is read-only inside the container.`;
319
- const repoClaudeMdPath = join(workspaceDir, "CLAUDE.md");
320
- const baseContent = existsSync(repoClaudeMdPath) ? readFileSync(repoClaudeMdPath, "utf8").trim() : null;
321
- const parts = ["# totopo Workspace Context\n\nYou are running inside a totopo dev container.\n", scopeSection, constraintsSection];
322
- if (baseContent) {
323
- parts.push("---\n", baseContent);
324
- }
325
- return `${parts.join("\n\n")}\n`;
391
+ - \`.totopo/\` is read-only inside the container.
392
+ - This file (\`${toolPath}\`) is managed by totopo and overwritten on every session start. Do not edit it.`;
393
+ return ([
394
+ "# totopo Workspace Context\n\nYou are running inside a totopo dev container.\n",
395
+ scopeSection,
396
+ gitSection,
397
+ constraintsSection,
398
+ responsibilitiesSection,
399
+ ].join("\n\n") +
400
+ selectiveWarning +
401
+ "\n");
402
+ }
403
+ return {
404
+ claude: build("~/.claude/CLAUDE.md"),
405
+ opencode: build("~/.config/opencode/AGENTS.md"),
406
+ codex: build("~/.codex/AGENTS.md"),
407
+ };
326
408
  }
327
- // ─── Inject agent context into container ──────────────────────────────────────
328
- function injectAgentContext(name, content) {
329
- const tmpPath = join(tmpdir(), `totopo-claude-md-${Date.now()}.md`);
330
- writeFileSync(tmpPath, content);
331
- spawnSync("docker", ["exec", name, "mkdir", "-p", "/home/devuser/.claude"]);
332
- spawnSync("docker", ["cp", tmpPath, `${name}:/home/devuser/.claude/CLAUDE.md`]);
333
- unlinkSync(tmpPath);
409
+ // ─── Inject agent context ─────────────────────────────────────────────────────
410
+ // Writes context files directly to .totopo/agents/ on the host. The agent dirs
411
+ // are created on demand (recursive mkdir) so this is safe to call on first run
412
+ // before any directories exist, as well as on subsequent runs where it simply
413
+ // overwrites existing files with the latest context. The agent dirs are served
414
+ // into the container via volume mounts — no docker cp required. Called before
415
+ // every container start/resume so context always reflects the current scope.
416
+ function injectAgentContext(totopoDir, docs) {
417
+ const a = join(totopoDir, "agents");
418
+ const files = [
419
+ { path: join(a, "claude", "CLAUDE.md"), content: docs.claude },
420
+ { path: join(a, "opencode", "config", "AGENTS.md"), content: docs.opencode },
421
+ { path: join(a, "codex", "AGENTS.md"), content: docs.codex },
422
+ ];
423
+ for (const { path, content } of files) {
424
+ mkdirSync(dirname(path), { recursive: true });
425
+ writeFileSync(path, content);
426
+ }
334
427
  }
335
428
  // ─── Run post-start ───────────────────────────────────────────────────────────
336
429
  function runPostStart(name, totopoMountPath) {
@@ -348,8 +441,22 @@ function removeContainer(name) {
348
441
  spawnSync("docker", ["stop", name], { stdio: "pipe" });
349
442
  spawnSync("docker", ["rm", name], { stdio: "pipe" });
350
443
  }
444
+ // ─── Ensure global env file exists ───────────────────────────────────────────
445
+ // ~/.totopo/.env lives outside all project repos so it is never mounted into
446
+ // the container and cannot be read by agents. Created empty on first run so
447
+ // --env-file always has a valid target.
448
+ function ensureGlobalEnvFile() {
449
+ const globalTotopoDir = join(homedir(), ".totopo");
450
+ const envFile = join(globalTotopoDir, ".env");
451
+ mkdirSync(globalTotopoDir, { recursive: true });
452
+ if (!existsSync(envFile)) {
453
+ writeFileSync(envFile, "");
454
+ }
455
+ return envFile;
456
+ }
351
457
  // ─── Run container ────────────────────────────────────────────────────────────
352
458
  function runContainer(scope, containerName, imageName, workspaceDir, totopoDir, cwd) {
459
+ const envFile = ensureGlobalEnvFile();
353
460
  const run = spawnSync("docker", [
354
461
  "run",
355
462
  "-d",
@@ -357,7 +464,7 @@ function runContainer(scope, containerName, imageName, workspaceDir, totopoDir,
357
464
  containerName,
358
465
  ...buildMountArgs(scope, workspaceDir, totopoDir, cwd),
359
466
  "--env-file",
360
- `${workspaceDir}/.totopo/.env`,
467
+ envFile,
361
468
  ...buildScopeEnvArgs(scope),
362
469
  ...buildScopeLabelArgs(scope),
363
470
  "--security-opt",
@@ -396,10 +503,10 @@ export async function run(_packageDir, repoRoot) {
396
503
  process.exit(build.status ?? 1);
397
504
  }
398
505
  const totopoMountPath = getTotopoMountPath(scope, repoRoot);
506
+ log.step("Preparing agent context...");
507
+ injectAgentContext(totopoDir, buildAgentContextDocs(scope));
399
508
  log.step("Starting dev container...");
400
509
  runContainer(scope, containerName, imageName, repoRoot, totopoDir, cwd);
401
- log.step("Injecting agent context...");
402
- injectAgentContext(containerName, buildAgentContextDoc(scope, repoRoot));
403
510
  runPostStart(containerName, totopoMountPath);
404
511
  }
405
512
  else if (containerStatus === "exited") {
@@ -407,22 +514,22 @@ export async function run(_packageDir, repoRoot) {
407
514
  const existingScope = readContainerScopeLabel(containerName);
408
515
  const totopoMountPath = getTotopoMountPath(scope, repoRoot);
409
516
  if (scopesMatch(scope, existingScope, repoRoot)) {
517
+ log.step("Preparing agent context...");
518
+ injectAgentContext(totopoDir, buildAgentContextDocs(scope));
410
519
  log.step("Resuming dev container...");
411
520
  const start = spawnSync("docker", ["start", containerName], { stdio: "inherit" });
412
521
  if (start.status !== 0) {
413
522
  outro("Failed to start dev container.");
414
523
  process.exit(start.status ?? 1);
415
524
  }
416
- log.step("Injecting agent context...");
417
- injectAgentContext(containerName, buildAgentContextDoc(scope, repoRoot));
418
525
  runPostStart(containerName, totopoMountPath);
419
526
  }
420
527
  else {
528
+ log.step("Preparing agent context...");
529
+ injectAgentContext(totopoDir, buildAgentContextDocs(scope));
421
530
  log.step("Recreating dev container with new scope...");
422
531
  removeContainer(containerName);
423
532
  runContainer(scope, containerName, imageName, repoRoot, totopoDir, cwd);
424
- log.step("Injecting agent context...");
425
- injectAgentContext(containerName, buildAgentContextDoc(scope, repoRoot));
426
533
  runPostStart(containerName, totopoMountPath);
427
534
  }
428
535
  }
@@ -431,14 +538,19 @@ export async function run(_packageDir, repoRoot) {
431
538
  const existingScope = readContainerScopeLabel(containerName);
432
539
  if (!scopesMatch(scope, existingScope, repoRoot)) {
433
540
  const totopoMountPath = getTotopoMountPath(scope, repoRoot);
541
+ log.step("Preparing agent context...");
542
+ injectAgentContext(totopoDir, buildAgentContextDocs(scope));
434
543
  log.step("Recreating dev container with new scope...");
435
544
  removeContainer(containerName);
436
545
  runContainer(scope, containerName, imageName, repoRoot, totopoDir, cwd);
437
- log.step("Injecting agent context...");
438
- injectAgentContext(containerName, buildAgentContextDoc(scope, repoRoot));
439
546
  runPostStart(containerName, totopoMountPath);
440
547
  }
441
- // same scope — connect directly (fall through)
548
+ else {
549
+ // Same scope and container already running — refresh context in place.
550
+ log.step("Refreshing agent context...");
551
+ injectAgentContext(totopoDir, buildAgentContextDocs(scope));
552
+ }
553
+ // fall through to connect
442
554
  }
443
555
  // ─── Connect ──────────────────────────────────────────────────────────────────
444
556
  const exec = spawnSync("docker", ["exec", "-it", "-w", "/workspace", containerName, "bash", "--login"], {
@@ -4,28 +4,22 @@
4
4
  // =========================================================================================================================================
5
5
  import { box, cancel, isCancel, select } from "@clack/prompts";
6
6
  export async function run(args) {
7
- const { projectName, activeCount, projectRunning, projectImageExists } = args;
8
- // ─── Status box ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
7
+ const { projectName, activeCount, projectRunning } = args;
8
+ // ─── Status box ──────────────────────────────────────────────────────────────
9
9
  const containersLabel = activeCount === 0 ? "none" : activeCount === 1 ? "1 running" : `${activeCount} running`;
10
- const lines = [];
11
- lines.push(`workspace: ${projectName}`);
12
- lines.push(`containers: ${containersLabel}`);
13
- box(lines.join("\n"), " totopo ", {
10
+ box(`workspace: ${projectName}\ncontainers: ${containersLabel}\nkeys: ~/.totopo/.env`, " totopo ", {
14
11
  contentAlign: "center",
15
12
  titleAlign: "center",
16
13
  width: "auto",
17
14
  rounded: true,
18
15
  });
19
- // ─── Menu ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
16
+ // ─── Menu ─────────────────────────────────────────────────────────────────────
20
17
  const action = await select({
21
18
  message: "Menu:",
22
19
  options: [
23
- { value: "dev", label: "Start session" },
24
- ...(projectRunning ? [{ value: "stop", label: "Stop" }] : []),
25
- ...(projectImageExists ? [{ value: "rebuild", label: "Rebuild" }] : []),
26
- { value: "settings", label: "Settings" },
27
- { value: "manage", label: "Manage workspaces" },
28
- { value: "doctor", label: "Doctor" },
20
+ { value: "dev", label: "Open session", hint: "start or resume the dev container" },
21
+ ...(projectRunning ? [{ value: "stop", label: "Stop dev container", hint: "stops this project's container" }] : []),
22
+ { value: "advanced", label: "Advanced", hint: "rebuild, memory, settings, and more" },
29
23
  { value: "quit", label: "Quit" },
30
24
  ],
31
25
  });
@@ -3,6 +3,7 @@
3
3
  // Invoked by bin/totopo.js when no .totopo/ config is found in the project.
4
4
  // =========================================================================================================================================
5
5
  import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
6
7
  import { basename, join } from "node:path";
7
8
  import { box, cancel, confirm, intro, isCancel, log, outro, select } from "@clack/prompts";
8
9
  import { writeSettings } from "../lib/config.js";
@@ -31,6 +32,7 @@ export async function run(packageDir, repoRoot) {
31
32
  mkdirSync(totopoDir, { recursive: true });
32
33
  cpSync(join(templatesDir, "Dockerfile"), join(totopoDir, "Dockerfile"));
33
34
  cpSync(join(templatesDir, "post-start.mjs"), join(totopoDir, "post-start.mjs"));
35
+ cpSync(join(templatesDir, "README.md"), join(totopoDir, "README.md"));
34
36
  log.success("Copied config templates to .totopo/");
35
37
  // ─── Runtime mode ────────────────────────────────────────────────────────────
36
38
  const modeChoice = await select({
@@ -66,14 +68,17 @@ export async function run(packageDir, repoRoot) {
66
68
  return false;
67
69
  }
68
70
  const commitScope = scopeChoice;
69
- // ─── Create .env ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
70
- const envPath = join(totopoDir, ".env");
71
- if (existsSync(envPath)) {
72
- log.info(".totopo/.env already exists leaving it untouched");
71
+ // ─── Create global .env ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────
72
+ // ~/.totopo/.env lives outside all project repos — never mounted into containers, never readable by agents.
73
+ const globalTotopoDir = join(homedir(), ".totopo");
74
+ const globalEnvPath = join(globalTotopoDir, ".env");
75
+ mkdirSync(globalTotopoDir, { recursive: true });
76
+ if (existsSync(globalEnvPath)) {
77
+ log.info(`${globalEnvPath} already exists — leaving it untouched`);
73
78
  }
74
79
  else {
75
- cpSync(join(templatesDir, "env"), envPath);
76
- log.success("Created .totopo/.env");
80
+ cpSync(join(templatesDir, "env"), globalEnvPath);
81
+ log.success(`Created ${globalEnvPath}`);
77
82
  }
78
83
  // ─── Gitignore ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
79
84
  const gitignorePath = join(repoRoot, ".gitignore");
@@ -91,18 +96,20 @@ export async function run(packageDir, repoRoot) {
91
96
  }
92
97
  }
93
98
  else {
94
- const entry = ".totopo/.env";
95
- const addition = "\n# totopo — API keys must never be committed\n.totopo/.env\n";
96
- if (gitignoreContent?.includes(entry)) {
97
- log.info(".totopo/.env already in .gitignore");
99
+ const agentsEntry = ".totopo/agents/";
100
+ let content = gitignoreContent ?? "";
101
+ if (gitignoreContent?.includes(agentsEntry)) {
102
+ log.info(".totopo/agents/ already in .gitignore");
98
103
  }
99
104
  else {
100
- const newContent = gitignoreContent !== null ? gitignoreContent + addition : addition;
101
- writeFileSync(gitignorePath, newContent);
102
- log.success("Added .totopo/.env to .gitignore");
105
+ content += "\n# totopo agent session data is local only\n.totopo/agents/\n";
106
+ log.success("Added .totopo/agents/ to .gitignore");
107
+ }
108
+ if (content !== (gitignoreContent ?? "")) {
109
+ writeFileSync(gitignorePath, content);
103
110
  }
104
111
  }
105
- log.info("Optionally add API keys to .totopo/.env before starting the container.");
112
+ log.info(`Add API keys to ${globalEnvPath} before starting the container.`);
106
113
  outro("Setup complete.");
107
114
  return true;
108
115
  }
@@ -34,7 +34,7 @@ function buildHostMirrorDockerfile(selectedTools, host) {
34
34
  sections.push(`# =============================================================================
35
35
  # Secure AI Dev Container — host-mirror mode
36
36
  # =============================================================================
37
- # Non-root user, no git remote access, AI tools: claude, kilo, opencode
37
+ # Non-root user, no git remote access, AI tools: opencode, claude, codex
38
38
  # Runtimes: selected by totopo host-mirror (regenerated on each session start)
39
39
  # =============================================================================
40
40
 
@@ -147,9 +147,9 @@ RUN git config --system protocol.allow never && \
147
147
  # ---------------------------------------------------------------------------
148
148
  RUN npm install -g \
149
149
  pnpm \
150
- @anthropic-ai/claude-code \
151
- @kilocode/cli \
152
150
  opencode-ai \
151
+ @anthropic-ai/claude-code \
152
+ @openai/codex \
153
153
  && npm cache clean --force`);
154
154
  // ── Layer 10 — Non-root user ─────────────────────────────────────────────
155
155
  sections.push(String.raw `# ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totopo",
3
- "version": "0.8.0",
3
+ "version": "0.9.0-rc-2",
4
4
  "description": "Secure AI Box — isolated dev environments for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  # =============================================================================
2
2
  # Secure AI Dev Container — General-purpose multi-stack
3
3
  # =============================================================================
4
- # Non-root user, no git remote access, AI tools: claude, kilo, opencode
4
+ # Non-root user, no git remote access, AI tools: opencode, claude, codex
5
5
  # Runtimes: Node.js, Python, Go, Rust, Java (Temurin 21), Bun
6
6
  # =============================================================================
7
7
 
@@ -93,9 +93,9 @@ RUN git config --system protocol.allow never && \
93
93
  # ---------------------------------------------------------------------------
94
94
  RUN npm install -g \
95
95
  pnpm \
96
- @anthropic-ai/claude-code \
97
- @kilocode/cli \
98
96
  opencode-ai \
97
+ @anthropic-ai/claude-code \
98
+ @openai/codex \
99
99
  && npm cache clean --force
100
100
 
101
101
  # ---------------------------------------------------------------------------
@@ -0,0 +1,76 @@
1
+ # .totopo — Reference
2
+
3
+ Created by `npx totopo`. Manages the secure dev container for this project.
4
+
5
+ ---
6
+
7
+ ## Files
8
+
9
+ | File | Purpose |
10
+ | ---------------- | ---------------------------------------------------------- |
11
+ | `Dockerfile` | Builds the container image |
12
+ | `post-start.mjs` | Runs on every start — security checks + readiness summary |
13
+ | `settings.json` | Runtime mode + selected tools (committed with project) |
14
+ | `agents/` | Agent session data — created on first session start |
15
+
16
+ ---
17
+
18
+ ## agents/
19
+
20
+ Initialised automatically the first time you run a dev session. Contains
21
+ per-tool subdirectories for each supported agent, mounted into the container
22
+ so session history and conversation data persist across rebuilds:
23
+
24
+ ```
25
+ agents/claude/ → ~/.claude/ (Claude Code)
26
+ agents/opencode/config/ → ~/.config/opencode/ (OpenCode)
27
+ agents/opencode/data/ → ~/.local/share/opencode/
28
+ agents/codex/ → ~/.codex/ (Codex)
29
+ ```
30
+
31
+ Context files (`CLAUDE.md` / `AGENTS.md`) are written into these directories
32
+ by totopo on every session start and overwritten automatically — do not edit them.
33
+
34
+ `agents/` is gitignored — session data stays local to this machine.
35
+
36
+ To reset agent memory: **Advanced → Clear agent memory** from the totopo menu.
37
+
38
+ ---
39
+
40
+ ## Security model
41
+
42
+ - **Non-root user** (`devuser`, uid 1001) — cannot modify system-level config
43
+ - **Git remote access blocked** via `protocol.allow = never` in `/etc/gitconfig` — push, pull, fetch, and clone are all refused; local operations work normally
44
+ - **No host credentials forwarded** — host git credentials are never copied into the container
45
+ - **API keys passed at runtime** via `--env-file ~/.totopo/.env` — never baked into the image and never mounted into the container
46
+ - **No privilege escalation** — `no-new-privileges:true` prevents any process from gaining elevated permissions
47
+
48
+ ---
49
+
50
+ ## AI tools
51
+
52
+ | Command | Package |
53
+ | ---------- | --------------------------- |
54
+ | `opencode` | `opencode-ai` |
55
+ | `claude` | `@anthropic-ai/claude-code` |
56
+ | `codex` | `@openai/codex` |
57
+
58
+ Tools are installed during image build. To update a tool version: edit `Dockerfile`,
59
+ then use **Advanced → Rebuild container** from the totopo menu.
60
+
61
+ ---
62
+
63
+ ## Startup check
64
+
65
+ `post-start.mjs` runs on every container start and validates:
66
+
67
+ 1. Running as non-root
68
+ 2. Git remote block active in `/etc/gitconfig`
69
+ 3. `git push` functionally blocked
70
+ 4. All AI tools installed and reachable
71
+
72
+ Re-run manually anytime from inside the container:
73
+
74
+ ```bash
75
+ status
76
+ ```
package/templates/env CHANGED
@@ -1,19 +1,30 @@
1
1
  # =============================================================================
2
2
  # totopo — API Keys
3
3
  # =============================================================================
4
- # Fill in your keys before starting the container.
5
- # Add .totopo/.env to your project's .gitignore keys must never be committed.
4
+ #
5
+ # WARNING: This file is loaded into every totopo container at runtime via
6
+ # --env-file. It lives at ~/.totopo/.env on your host machine and is never
7
+ # mounted into the container filesystem — agents cannot read it directly.
8
+ #
9
+ # However, treat this file as sensitive. Do not commit it, do not share it,
10
+ # and do not paste it into agent sessions. If you believe a key has been
11
+ # exposed, rotate it immediately from your provider's dashboard.
6
12
  # =============================================================================
7
13
 
8
- # Anthropic (Claude Code optional if using Pro subscription via browser auth)
9
- ANTHROPIC_API_KEY=
14
+ # OpenAIGPT models, used by OpenCode and Codex
15
+ OPENAI_API_KEY=
10
16
 
11
- # Kilo AI
12
- KILO_API_KEY=
17
+ # Anthropic — Claude models, used by Claude Code and OpenCode
18
+ ANTHROPIC_API_KEY=
13
19
 
14
- # OpenCode supports multiple providers add whichever you use
15
- OPENAI_API_KEY=
20
+ # GoogleGemini models
16
21
  GEMINI_API_KEY=
17
22
 
18
- # Optional: OpenRouter (gives access to many models via one key)
23
+ # xAI Grok models
24
+ XAI_API_KEY=
25
+
26
+ # Groq — fast inference for open models (Llama, Mixtral, etc.)
27
+ GROQ_API_KEY=
28
+
29
+ # OpenRouter — unified gateway to 200+ models from multiple providers
19
30
  OPENROUTER_API_KEY=
@@ -77,9 +77,9 @@ const checkTool = (cmd) => {
77
77
  }
78
78
  };
79
79
 
80
- checkTool("claude");
81
- checkTool("kilo");
82
80
  checkTool("opencode");
81
+ checkTool("claude");
82
+ checkTool("codex");
83
83
 
84
84
  // ─── Runtimes ────────────────────────────────────────────────────────────────
85
85
  section("Runtimes");