totopo 1.0.8 → 2.0.0-rc-1
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 +58 -32
- package/bin/totopo.js +81 -42
- package/dist/commands/advanced.js +124 -98
- package/dist/commands/dev.js +53 -82
- package/dist/commands/doctor.js +13 -12
- package/dist/commands/menu.js +15 -19
- package/dist/commands/onboard.js +224 -62
- package/dist/commands/rebuild.js +2 -5
- package/dist/commands/settings.js +7 -8
- package/dist/commands/stop.js +1 -3
- package/dist/commands/sync-dockerfile.js +5 -6
- package/dist/lib/config.js +2 -1
- package/dist/lib/project-identity.js +136 -0
- package/package.json +11 -7
- package/dist/commands/manage.js +0 -128
- package/dist/lib/docker-name.js +0 -7
- package/templates/README.md +0 -76
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ A simple CLI to use AI coding agents safely in your local codebase.
|
|
|
10
10
|
|
|
11
11
|
## Why totopo?
|
|
12
12
|
|
|
13
|
-
Here's the thing about AI agents: they're probabilistic. They occasionally misinterpret instructions
|
|
13
|
+
Here's the thing about AI agents: they're probabilistic. They occasionally misinterpret instructions or simply get it wrong. Most of the time they're fine. But "most of the time" isn't a great argument for giving them unrestricted access to your machine, your credentials, and your remote repositories.
|
|
14
14
|
|
|
15
15
|
totopo draws a simple boundary: agents get a full, capable environment to work in — they just can't touch anything outside the project, and they can't reach your remote. That's it. No domain whitelisting, no paranoia, no compromise on what the agent can actually do. Just a reasonable containment for non-deterministic tools.
|
|
16
16
|
|
|
@@ -18,21 +18,23 @@ Note: no sandbox substitutes for good judgment. Consider keeping any sensitive s
|
|
|
18
18
|
|
|
19
19
|
## How totopo Works
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
- Installed once per git repository in a `.totopo/` directory at the repo root.
|
|
23
|
-
- Manages one Docker container per repository.
|
|
21
|
+
totopo organises work around **projects** — any local directory you register with totopo. The first time you run `npx totopo` in a directory, it walks you through a short setup. Every subsequent run, from anywhere inside that directory tree, totopo resolves the project automatically and shows the project menu:
|
|
24
22
|
|
|
25
|
-
|
|
23
|
+
- **Open session** — choose a scope and jump into an AI coding session
|
|
24
|
+
- **Stop container** — stop the running container
|
|
25
|
+
- **Runtime Mode** — adjust runtime mode and installed tools
|
|
26
|
+
- **Rebuild container** — rebuild the docker image (upon changing runtime mode)
|
|
27
|
+
|
|
28
|
+
All config lives in `~/.totopo/` — nothing is written to your project by default.
|
|
26
29
|
|
|
27
30
|
### Concurrent Sessions
|
|
28
|
-
totopo uses one container per
|
|
31
|
+
totopo uses one Docker container per project, not one per session. You can open as many terminal sessions as you need — they all connect to the same container, keeping resource usage bounded and reconnections fast.
|
|
29
32
|
|
|
30
33
|
The tradeoff is that only one scope can be active at a time: if you reopen a session with a different scope, totopo recreates the container to match the new mounts, which would terminate any active sessions connected to the previous container.
|
|
31
34
|
|
|
32
35
|
## Requirements
|
|
33
36
|
|
|
34
|
-
- [Docker](https://www.docker.com/products/docker-desktop/)
|
|
35
|
-
- [git](https://git-scm.com/) - safeguard to ensure agents only run in projects with version control in place
|
|
37
|
+
- [Docker](https://www.docker.com/products/docker-desktop/) — used to build and run the dev container
|
|
36
38
|
|
|
37
39
|
## Quick Start
|
|
38
40
|
|
|
@@ -41,11 +43,11 @@ cd your-project
|
|
|
41
43
|
npx totopo
|
|
42
44
|
```
|
|
43
45
|
|
|
44
|
-
First-time setup — running `npx totopo` in a fresh repo, selecting a runtime mode, and waiting for the Docker image to build for the first time
|
|
45
|
-

|
|
46
|
+
<!--First-time setup — running `npx totopo` in a fresh repo, selecting a runtime mode, and waiting for the Docker image to build for the first time:-->
|
|
47
|
+
<!--  -->
|
|
46
48
|
|
|
47
|
-
Opening a session when totopo is already initialized is quick. The agent is aware of its scope and sandbox constraints
|
|
48
|
-

|
|
49
|
+
<!--Opening a session when totopo is already initialized is quick. The agent is aware of its scope and sandbox constraints:-->
|
|
50
|
+
<!--  -->
|
|
49
51
|
|
|
50
52
|
## Core features at a glance
|
|
51
53
|
|
|
@@ -75,9 +77,9 @@ Remote git operations are blocked inside the container. Push from your host term
|
|
|
75
77
|
|
|
76
78
|
### Session scope
|
|
77
79
|
|
|
78
|
-
When you open a session, totopo asks what part of the
|
|
80
|
+
When you open a session, totopo asks what part of the project to mount into the container:
|
|
79
81
|
|
|
80
|
-
- `Repo root` — the full
|
|
82
|
+
- `Repo root` — the full project directory
|
|
81
83
|
- `Current directory` — only the current directory
|
|
82
84
|
- `Selective` — specific files and folders chosen interactively
|
|
83
85
|
|
|
@@ -87,14 +89,14 @@ The selected scope is stored on the container and checked on every later `Open s
|
|
|
87
89
|
- If the requested scope is different, totopo recreates the container so the mounted paths match the new scope.
|
|
88
90
|
- Parallel terminals on the same scope are fine. totopo connects with `docker exec`, so any concurrency limits are just the normal limits of sharing one running container.
|
|
89
91
|
|
|
90
|
-
This is an intentional tradeoff: you get predictable resource usage and quick reconnects, but only one active mounted view per
|
|
92
|
+
This is an intentional tradeoff: you get predictable resource usage and quick reconnects, but only one active mounted view per project at a time.
|
|
91
93
|
|
|
92
94
|
In `Current directory` and `Selective` scopes, `.git` is intentionally not mounted. Mounting `.git` would expose the full commit history of every repository file, including files outside the mounted paths, which defeats the point of scoped access. As a result, git is unavailable inside those scoped sessions and the agent operates without repository history. The agent is instructed to surface these limitations at session start.
|
|
93
95
|
|
|
94
96
|
Scoped sessions are well-suited for focused tasks where you want to give the agent a narrow, explicit view of your codebase.
|
|
95
97
|
|
|
96
|
-
Example showcasing agent awareness of selective scope limitations
|
|
97
|
-

|
|
98
|
+
<!-- Example showcasing agent awareness of selective scope limitations:-->
|
|
99
|
+
<!--  -->
|
|
98
100
|
|
|
99
101
|
### AI CLIs with persistent sessions
|
|
100
102
|
|
|
@@ -106,7 +108,7 @@ claude # Claude Code (Anthropic)
|
|
|
106
108
|
codex # Codex (OpenAI)
|
|
107
109
|
```
|
|
108
110
|
|
|
109
|
-
Agent session data is isolated per
|
|
111
|
+
Agent session data is isolated per project, so agents do not bleed context between projects. To clear memory, run `npx totopo` and navigate to `Manage totopo > Clear agent memory` and select a project. This stops the container if running and removes the agents directory.
|
|
110
112
|
|
|
111
113
|
### Dev container runtime
|
|
112
114
|
|
|
@@ -117,27 +119,51 @@ Choose between two modes:
|
|
|
117
119
|
|
|
118
120
|
Either way, basic dev tools and all three AI CLIs are always included.
|
|
119
121
|
|
|
120
|
-
## What gets
|
|
122
|
+
## What gets installed
|
|
123
|
+
|
|
124
|
+
All totopo config lives in `~/.totopo/` on your machine — nothing is written to your project directory unless you opt in.
|
|
121
125
|
|
|
122
126
|
```text
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
├──
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
127
|
+
~/.totopo/
|
|
128
|
+
├── .env # API keys — global, never mounted into container
|
|
129
|
+
└── projects/
|
|
130
|
+
└── <id>/ # stable hash of the project root path
|
|
131
|
+
├── meta.json # project root, display name, container name
|
|
132
|
+
├── settings.json # runtime mode + selected tools
|
|
133
|
+
├── Dockerfile # container image definition
|
|
134
|
+
├── post-start.mjs # security checks + readiness summary on every start
|
|
135
|
+
└── agents/ # agent session data — created on first session start
|
|
136
|
+
├── claude/ # mounted as ~/.claude/
|
|
137
|
+
├── opencode/ # mounted as ~/.config/opencode/ + ~/.local/share/opencode/
|
|
138
|
+
└── codex/ # mounted as ~/.codex/
|
|
135
139
|
```
|
|
136
140
|
|
|
137
|
-
|
|
141
|
+
Agent session history and conversation data are persisted in the `agents/` directory across container rebuilds and restarts.
|
|
142
|
+
|
|
143
|
+
### Shared onboarding (optional)
|
|
144
|
+
|
|
145
|
+
If you want contributors to get a one-click setup experience, add a `totopo.yaml` file at your project root:
|
|
146
|
+
|
|
147
|
+
```yaml
|
|
148
|
+
# totopo.yaml — project anchor
|
|
149
|
+
#
|
|
150
|
+
# name — shown as: "Welcome to <name>."
|
|
151
|
+
# description — shown as: "<description>"
|
|
152
|
+
|
|
153
|
+
name: my-project
|
|
154
|
+
description: Our AI coding sandbox. Ask @alice for the API keys.
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
When a new contributor runs `npx totopo`, totopo reads this file to anchor the project root and displays the welcome message before prompting for setup. Without it, totopo will find the git root and suggest it as the project root, so `totopo.yaml` is purely optional.
|
|
158
|
+
|
|
159
|
+
To add a project anchor to an existing local-only totopo project, run `npx totopo` and select `Add project anchor` from the project menu.
|
|
138
160
|
|
|
139
161
|
## Limitations
|
|
140
162
|
|
|
163
|
+
**Rename or move** — moving the project directory breaks identity since totopo uses the absolute path as the project key. Re-run `npx totopo` in the new location to onboard again. Orphaned configs can be cleaned up via `Manage totopo`.
|
|
164
|
+
|
|
165
|
+
**Single machine** — `~/.totopo/` is local. Switching to a new machine requires re-onboarding each project.
|
|
166
|
+
|
|
141
167
|
**Audio / microphone** — the image includes `sox` (required by Claude Code for voice mode), but audio passthrough from the host depends on your OS. macOS, Linux, and Windows each require different device configuration. If you need voice mode, set up audio passthrough manually for your platform.
|
|
142
168
|
|
|
143
169
|
## Disclaimer
|
package/bin/totopo.js
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// =============================================================================
|
|
3
|
-
// bin/totopo.js — totopo entry point
|
|
3
|
+
// bin/totopo.js — totopo entry point (v2)
|
|
4
4
|
// Run this from your project directory (or via npx totopo).
|
|
5
5
|
// =============================================================================
|
|
6
6
|
|
|
7
7
|
import { execSync, spawnSync } from "node:child_process";
|
|
8
8
|
import { existsSync } from "node:fs";
|
|
9
|
-
import {
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { cancel, isCancel, select } from "@clack/prompts";
|
|
11
12
|
import { run as advanced } from "../dist/commands/advanced.js";
|
|
12
13
|
import { run as dev } from "../dist/commands/dev.js";
|
|
13
14
|
import { run as doctor } from "../dist/commands/doctor.js";
|
|
14
15
|
import { run as menu } from "../dist/commands/menu.js";
|
|
15
|
-
import { run as onboard } from "../dist/commands/onboard.js";
|
|
16
|
+
import { addProjectAnchor, run as onboard } from "../dist/commands/onboard.js";
|
|
17
|
+
import { run as rebuild } from "../dist/commands/rebuild.js";
|
|
18
|
+
import { run as settings } from "../dist/commands/settings.js";
|
|
16
19
|
import { run as stop } from "../dist/commands/stop.js";
|
|
17
20
|
import { run as syncDockerfile } from "../dist/commands/sync-dockerfile.js";
|
|
18
|
-
import {
|
|
21
|
+
import { listProjectIds, resolveProject } from "../dist/lib/project-identity.js";
|
|
19
22
|
|
|
20
23
|
// ─── Guard: inside container ──────────────────────────────────────────────────
|
|
21
24
|
try {
|
|
@@ -35,18 +38,7 @@ try {
|
|
|
35
38
|
// ─── Paths ────────────────────────────────────────────────────────────────────
|
|
36
39
|
// dirname(dirname(...)) walks up from bin/ to the package root.
|
|
37
40
|
const packageDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
38
|
-
|
|
39
|
-
let repoRoot;
|
|
40
|
-
try {
|
|
41
|
-
repoRoot = execSync("git rev-parse --show-toplevel", { encoding: "utf8" }).trim();
|
|
42
|
-
} catch {
|
|
43
|
-
console.error("");
|
|
44
|
-
console.error(" No git repository found.");
|
|
45
|
-
console.error("");
|
|
46
|
-
console.error(" totopo requires a git repository. Run 'git init' first, then re-run totopo.");
|
|
47
|
-
console.error("");
|
|
48
|
-
process.exit(1);
|
|
49
|
-
}
|
|
41
|
+
const cwd = process.cwd();
|
|
50
42
|
|
|
51
43
|
// ─── Guard: dist/ must exist ─────────────────────────────────────────────────
|
|
52
44
|
if (!existsSync(new URL("../dist/commands/sync-dockerfile.js", import.meta.url))) {
|
|
@@ -58,59 +50,106 @@ if (!existsSync(new URL("../dist/commands/sync-dockerfile.js", import.meta.url))
|
|
|
58
50
|
process.exit(1);
|
|
59
51
|
}
|
|
60
52
|
|
|
61
|
-
// ───
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
53
|
+
// ─── Resolve project from CWD (walk-up through ~/.totopo/projects/) ───────────
|
|
54
|
+
let project = resolveProject(cwd);
|
|
55
|
+
|
|
56
|
+
// ─── Onboarding (if not in a registered project) ─────────────────────────────
|
|
57
|
+
if (!project) {
|
|
58
|
+
// Detect project context: git root or totopo.yaml present?
|
|
59
|
+
let gitRoot = null;
|
|
60
|
+
try {
|
|
61
|
+
gitRoot = execSync("git rev-parse --show-toplevel", { encoding: "utf8", stdio: "pipe" }).trim();
|
|
62
|
+
} catch {
|
|
63
|
+
// Not in a git repo — that's fine
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const totopoJsonPath = `${gitRoot ?? cwd}/totopo.yaml`;
|
|
67
|
+
const hasTotopoYaml = existsSync(totopoJsonPath);
|
|
68
|
+
|
|
69
|
+
if (gitRoot !== null || hasTotopoYaml) {
|
|
70
|
+
// Has project context — if other projects already exist, let the user choose first
|
|
71
|
+
if (listProjectIds().length > 0) {
|
|
72
|
+
process.stdout.write("\n");
|
|
73
|
+
const choice = await select({
|
|
74
|
+
message: "What would you like to do?",
|
|
75
|
+
options: [
|
|
76
|
+
{ value: "setup", label: "Set up totopo for this directory" },
|
|
77
|
+
{ value: "manage", label: "Manage totopo →" },
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
if (isCancel(choice)) {
|
|
81
|
+
cancel();
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
if (choice === "manage") {
|
|
85
|
+
await advanced(packageDir);
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const ctx = await onboard(packageDir, cwd);
|
|
91
|
+
if (!ctx) process.exit(0); // cancelled → exit cleanly
|
|
92
|
+
project = ctx;
|
|
93
|
+
} else {
|
|
94
|
+
// No project context → show Manage totopo menu directly
|
|
95
|
+
await advanced(packageDir);
|
|
96
|
+
process.exit(0);
|
|
97
|
+
}
|
|
65
98
|
}
|
|
66
99
|
|
|
67
100
|
// ─── Sync Dockerfile with host runtimes ───────────────────────────────────────
|
|
68
|
-
await syncDockerfile(packageDir,
|
|
101
|
+
await syncDockerfile(packageDir, project);
|
|
69
102
|
|
|
70
103
|
// ─── Doctor (silent pre-check) ────────────────────────────────────────────────
|
|
71
|
-
const doctorResult = await doctor(
|
|
104
|
+
const doctorResult = await doctor(project.projectDir, false);
|
|
72
105
|
if (!doctorResult.ok) {
|
|
73
106
|
console.error(" Fix the issues above and re-run totopo.");
|
|
74
107
|
console.error("");
|
|
75
108
|
process.exit(1);
|
|
76
109
|
}
|
|
77
110
|
|
|
78
|
-
// ─── Gather state for menu
|
|
79
|
-
const
|
|
80
|
-
const dockerName = toDockerName(projectName);
|
|
111
|
+
// ─── Gather container state for menu ─────────────────────────────────────────
|
|
112
|
+
const { containerName } = project.meta;
|
|
81
113
|
|
|
82
|
-
const dockerResult = spawnSync("docker", ["ps", "--filter", "name=totopo-
|
|
114
|
+
const dockerResult = spawnSync("docker", ["ps", "--filter", "name=totopo-", "--format", "{{.Names}}"], {
|
|
83
115
|
encoding: "utf8",
|
|
84
116
|
});
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
encoding: "utf8",
|
|
89
|
-
});
|
|
90
|
-
const projectRunning = (projectContainerResult.stdout ?? "")
|
|
91
|
-
.trim()
|
|
92
|
-
.split("\n")
|
|
93
|
-
.filter(Boolean)
|
|
94
|
-
.some((n) => n === dockerName);
|
|
117
|
+
const activeNames = dockerResult.stdout ? dockerResult.stdout.trim().split("\n").filter(Boolean) : [];
|
|
118
|
+
const activeCount = activeNames.length;
|
|
119
|
+
const projectRunning = activeNames.some((n) => n === containerName);
|
|
95
120
|
|
|
96
121
|
// ─── Interactive menu loop ────────────────────────────────────────────────────
|
|
97
122
|
let showMenu = true;
|
|
98
123
|
while (showMenu) {
|
|
99
124
|
showMenu = false;
|
|
100
125
|
|
|
101
|
-
|
|
126
|
+
// Re-evaluated each iteration so menu options stay in sync (e.g. after "Add project anchor")
|
|
127
|
+
const hasTotopoYaml = existsSync(`${project.meta.projectRoot}/totopo.yaml`);
|
|
128
|
+
|
|
129
|
+
const action = await menu({ ctx: project, activeCount, projectRunning, hasTotopoYaml });
|
|
102
130
|
|
|
103
131
|
switch (action) {
|
|
104
132
|
case "dev":
|
|
105
|
-
await dev(packageDir,
|
|
133
|
+
await dev(packageDir, project);
|
|
134
|
+
break;
|
|
135
|
+
case "rebuild":
|
|
136
|
+
await rebuild(project.meta.containerName);
|
|
137
|
+
await dev(packageDir, project);
|
|
106
138
|
break;
|
|
107
139
|
case "stop":
|
|
108
|
-
await stop(
|
|
140
|
+
await stop(project.meta.containerName);
|
|
141
|
+
break;
|
|
142
|
+
case "settings":
|
|
143
|
+
await settings(packageDir, project);
|
|
144
|
+
showMenu = true;
|
|
145
|
+
break;
|
|
146
|
+
case "add-anchor":
|
|
147
|
+
await addProjectAnchor(project);
|
|
148
|
+
showMenu = true;
|
|
109
149
|
break;
|
|
110
|
-
case "
|
|
111
|
-
const result = await advanced(packageDir
|
|
150
|
+
case "manage-totopo": {
|
|
151
|
+
const result = await advanced(packageDir);
|
|
112
152
|
if (result === "back") showMenu = true;
|
|
113
|
-
if (result === "rebuild") await dev(packageDir, repoRoot);
|
|
114
153
|
break;
|
|
115
154
|
}
|
|
116
155
|
default:
|
|
@@ -1,50 +1,22 @@
|
|
|
1
1
|
// =========================================================================================================================================
|
|
2
|
-
// src/core/commands/advanced.ts —
|
|
3
|
-
// Invoked by bin/totopo.js —
|
|
2
|
+
// src/core/commands/advanced.ts — Manage totopo menu (global, all projects)
|
|
3
|
+
// Invoked by bin/totopo.js — shown directly when outside a project, or via "Manage totopo →" from the project menu.
|
|
4
4
|
// =========================================================================================================================================
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
-
import { cancel, confirm, isCancel, log, multiselect, outro, select } from "@clack/prompts";
|
|
10
|
-
import {
|
|
9
|
+
import { cancel, confirm, isCancel, log, multiselect, outro, select, text } from "@clack/prompts";
|
|
10
|
+
import { listProjects } from "../lib/project-identity.js";
|
|
11
11
|
import { run as runDoctor } from "./doctor.js";
|
|
12
|
-
import { run as runRebuild } from "./rebuild.js";
|
|
13
|
-
import { run as runSettings } from "./settings.js";
|
|
14
12
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
15
13
|
function stopAndRemoveContainer(name) {
|
|
16
14
|
spawnSync("docker", ["stop", name], { stdio: "inherit" });
|
|
17
15
|
spawnSync("docker", ["rm", name], { stdio: "inherit" });
|
|
18
16
|
}
|
|
19
|
-
// ───
|
|
20
|
-
async function clearAgentMemory(projectName, totopoDir) {
|
|
21
|
-
const containerName = toDockerName(projectName);
|
|
22
|
-
// Check if the container is running
|
|
23
|
-
const inspectResult = spawnSync("docker", ["inspect", "--format", "{{.State.Status}}", containerName], {
|
|
24
|
-
encoding: "utf8",
|
|
25
|
-
stdio: "pipe",
|
|
26
|
-
});
|
|
27
|
-
const isRunning = inspectResult.status === 0 && inspectResult.stdout.trim() === "running";
|
|
28
|
-
if (isRunning) {
|
|
29
|
-
const confirmed = await confirm({
|
|
30
|
-
message: `The dev container for ${projectName} is running. It must be stopped to clear agent memory. Continue?`,
|
|
31
|
-
});
|
|
32
|
-
if (isCancel(confirmed) || !confirmed) {
|
|
33
|
-
cancel("Cancelled.");
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
log.step(`Stopping ${containerName}...`);
|
|
37
|
-
stopAndRemoveContainer(containerName);
|
|
38
|
-
}
|
|
39
|
-
const agentsDir = join(totopoDir, "agents");
|
|
40
|
-
if (existsSync(agentsDir)) {
|
|
41
|
-
rmSync(agentsDir, { recursive: true, force: true });
|
|
42
|
-
}
|
|
43
|
-
log.success("Agent memory cleared. Context will be regenerated on next session start.");
|
|
44
|
-
}
|
|
45
|
-
// ─── Stop containers ──────────────────────────────────────────────────────────
|
|
17
|
+
// ─── Stop containers (multi-select across all projects) ──────────────────────
|
|
46
18
|
async function stopContainers() {
|
|
47
|
-
const listResult = spawnSync("docker", ["ps", "--filter", "name=totopo-
|
|
19
|
+
const listResult = spawnSync("docker", ["ps", "--filter", "name=totopo-", "--format", "{{.Names}}"], {
|
|
48
20
|
encoding: "utf8",
|
|
49
21
|
});
|
|
50
22
|
const running = (listResult.stdout ?? "").trim().split("\n").filter(Boolean);
|
|
@@ -55,7 +27,7 @@ async function stopContainers() {
|
|
|
55
27
|
let toStop;
|
|
56
28
|
if (running.length === 1) {
|
|
57
29
|
toStop = running;
|
|
58
|
-
log.info(`Stopping ${running[0]}...`);
|
|
30
|
+
log.info(`Stopping ${running[0] ?? ""}...`);
|
|
59
31
|
}
|
|
60
32
|
else {
|
|
61
33
|
const selected = await multiselect({
|
|
@@ -75,9 +47,60 @@ async function stopContainers() {
|
|
|
75
47
|
}
|
|
76
48
|
log.success("Done.");
|
|
77
49
|
}
|
|
78
|
-
// ───
|
|
50
|
+
// ─── Clear agent memory (multi-select across all projects) ───────────────────
|
|
51
|
+
async function clearAgentMemory() {
|
|
52
|
+
const projects = listProjects().filter((p) => existsSync(join(p.projectDir, "agents")));
|
|
53
|
+
if (projects.length === 0) {
|
|
54
|
+
log.info("No agent memory found.");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
let toClear; // project IDs
|
|
58
|
+
if (projects.length === 1) {
|
|
59
|
+
const p = projects[0];
|
|
60
|
+
if (p === undefined)
|
|
61
|
+
return;
|
|
62
|
+
toClear = [p.id];
|
|
63
|
+
log.info(`Clearing agent memory for ${p.meta.displayName}...`);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const selected = await multiselect({
|
|
67
|
+
message: "Select projects to clear agent memory for:",
|
|
68
|
+
options: projects.map((p) => ({ value: p.id, label: p.meta.displayName, hint: p.meta.projectRoot })),
|
|
69
|
+
required: false,
|
|
70
|
+
});
|
|
71
|
+
if (isCancel(selected)) {
|
|
72
|
+
cancel();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
toClear = selected;
|
|
76
|
+
}
|
|
77
|
+
for (const id of toClear) {
|
|
78
|
+
const p = projects.find((x) => x.id === id);
|
|
79
|
+
if (!p)
|
|
80
|
+
continue;
|
|
81
|
+
// Stop the container first if running
|
|
82
|
+
const inspectResult = spawnSync("docker", ["inspect", "--format", "{{.State.Status}}", p.meta.containerName], {
|
|
83
|
+
encoding: "utf8",
|
|
84
|
+
stdio: "pipe",
|
|
85
|
+
});
|
|
86
|
+
const isRunning = inspectResult.status === 0 && inspectResult.stdout.trim() === "running";
|
|
87
|
+
if (isRunning) {
|
|
88
|
+
const confirmed = await confirm({
|
|
89
|
+
message: `Container for ${p.meta.displayName} is running. Stop it to clear memory?`,
|
|
90
|
+
});
|
|
91
|
+
if (isCancel(confirmed) || !confirmed)
|
|
92
|
+
continue;
|
|
93
|
+
log.step(`Stopping ${p.meta.containerName}...`);
|
|
94
|
+
stopAndRemoveContainer(p.meta.containerName);
|
|
95
|
+
}
|
|
96
|
+
const agentsDir = join(p.projectDir, "agents");
|
|
97
|
+
rmSync(agentsDir, { recursive: true, force: true });
|
|
98
|
+
log.success(`Cleared agent memory for ${p.meta.displayName}.`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// ─── Remove images (multi-select across all projects) ────────────────────────
|
|
79
102
|
async function removeImages() {
|
|
80
|
-
const listResult = spawnSync("docker", ["images", "--filter", "label=totopo.managed=true", "--format", "{{.Repository}}
|
|
103
|
+
const listResult = spawnSync("docker", ["images", "--filter", "label=totopo.managed=true", "--format", "{{.Repository}}\t{{.ID}}"], {
|
|
81
104
|
encoding: "utf8",
|
|
82
105
|
});
|
|
83
106
|
const lines = (listResult.stdout ?? "").trim().split("\n").filter(Boolean);
|
|
@@ -86,16 +109,14 @@ async function removeImages() {
|
|
|
86
109
|
return;
|
|
87
110
|
}
|
|
88
111
|
const images = lines.map((line) => {
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
112
|
+
const parts = line.split("\t");
|
|
113
|
+
const repo = parts[0] ?? "";
|
|
114
|
+
const id = parts[1] ?? "";
|
|
115
|
+
return { repo, id };
|
|
92
116
|
});
|
|
93
117
|
const selected = await multiselect({
|
|
94
118
|
message: "Select images to remove:",
|
|
95
|
-
options: images.map((img) => ({
|
|
96
|
-
value: img.repo,
|
|
97
|
-
label: `${img.workspace} (${img.repo})`,
|
|
98
|
-
})),
|
|
119
|
+
options: images.map((img) => ({ value: img.repo, label: img.repo, hint: img.id.slice(0, 12) })),
|
|
99
120
|
required: false,
|
|
100
121
|
});
|
|
101
122
|
if (isCancel(selected)) {
|
|
@@ -117,32 +138,6 @@ async function removeImages() {
|
|
|
117
138
|
}
|
|
118
139
|
log.success("Done.");
|
|
119
140
|
}
|
|
120
|
-
// ─── Uninstall ────────────────────────────────────────────────────────────────
|
|
121
|
-
async function uninstall(projectName, repoRoot) {
|
|
122
|
-
const dockerName = toDockerName(projectName);
|
|
123
|
-
const containerName = dockerName;
|
|
124
|
-
const imageName = dockerName;
|
|
125
|
-
const confirmed = await confirm({
|
|
126
|
-
message: `Remove .totopo/, stop containers, and delete the image for ${projectName}?`,
|
|
127
|
-
});
|
|
128
|
-
if (isCancel(confirmed) || !confirmed) {
|
|
129
|
-
cancel();
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
const inspectResult = spawnSync("docker", ["inspect", "--type", "container", containerName], { encoding: "utf8" });
|
|
133
|
-
if (inspectResult.status === 0) {
|
|
134
|
-
log.step(`Stopping container ${containerName}...`);
|
|
135
|
-
stopAndRemoveContainer(containerName);
|
|
136
|
-
}
|
|
137
|
-
const imageResult = spawnSync("docker", ["images", "-q", imageName], { encoding: "utf8" });
|
|
138
|
-
if ((imageResult.stdout ?? "").trim().length > 0) {
|
|
139
|
-
log.step(`Removing image ${imageName}...`);
|
|
140
|
-
spawnSync("docker", ["rmi", imageName], { stdio: "inherit" });
|
|
141
|
-
}
|
|
142
|
-
log.step("Removing .totopo/...");
|
|
143
|
-
rmSync(join(repoRoot, ".totopo"), { recursive: true, force: true });
|
|
144
|
-
outro("Uninstalled. Re-run npx totopo to set up again.");
|
|
145
|
-
}
|
|
146
141
|
// ─── Reset API keys ───────────────────────────────────────────────────────────
|
|
147
142
|
async function resetApiKeys(packageDir) {
|
|
148
143
|
const globalEnvPath = join(homedir(), ".totopo", ".env");
|
|
@@ -157,21 +152,54 @@ async function resetApiKeys(packageDir) {
|
|
|
157
152
|
cpSync(join(packageDir, "templates", "env"), globalEnvPath);
|
|
158
153
|
log.success(`API keys reset. Edit ${globalEnvPath} to add your keys.`);
|
|
159
154
|
}
|
|
160
|
-
// ───
|
|
161
|
-
|
|
162
|
-
const
|
|
155
|
+
// ─── Uninstall totopo (global — wipes ~/.totopo/ and all containers/images) ──
|
|
156
|
+
async function uninstallTotopo() {
|
|
157
|
+
const confirmed = await text({
|
|
158
|
+
message: 'Type "uninstall-totopo" to confirm full uninstall:',
|
|
159
|
+
validate: (v) => ((v ?? "").trim() !== "uninstall-totopo" ? 'Type exactly "uninstall-totopo" to confirm' : undefined),
|
|
160
|
+
});
|
|
161
|
+
if (isCancel(confirmed) || confirmed.trim() !== "uninstall-totopo") {
|
|
162
|
+
cancel("Uninstall cancelled.");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
// Stop and remove all totopo containers
|
|
166
|
+
const psResult = spawnSync("docker", ["ps", "-a", "--filter", "name=totopo-", "--format", "{{.Names}}"], {
|
|
167
|
+
encoding: "utf8",
|
|
168
|
+
});
|
|
169
|
+
const containers = (psResult.stdout ?? "").trim().split("\n").filter(Boolean);
|
|
170
|
+
for (const c of containers) {
|
|
171
|
+
log.step(`Stopping and removing container ${c}...`);
|
|
172
|
+
stopAndRemoveContainer(c);
|
|
173
|
+
}
|
|
174
|
+
// Remove all totopo images
|
|
175
|
+
const imagesResult = spawnSync("docker", ["images", "--filter", "label=totopo.managed=true", "--format", "{{.Repository}}"], {
|
|
176
|
+
encoding: "utf8",
|
|
177
|
+
});
|
|
178
|
+
const imgs = (imagesResult.stdout ?? "").trim().split("\n").filter(Boolean);
|
|
179
|
+
for (const img of imgs) {
|
|
180
|
+
log.step(`Removing image ${img}...`);
|
|
181
|
+
spawnSync("docker", ["rmi", img], { stdio: "inherit" });
|
|
182
|
+
}
|
|
183
|
+
// Delete ~/.totopo/
|
|
184
|
+
const globalTotopoDir = join(homedir(), ".totopo");
|
|
185
|
+
if (existsSync(globalTotopoDir)) {
|
|
186
|
+
log.step("Deleting ~/.totopo/...");
|
|
187
|
+
rmSync(globalTotopoDir, { recursive: true, force: true });
|
|
188
|
+
}
|
|
189
|
+
outro("totopo uninstalled. Re-run npx totopo to set up again.");
|
|
190
|
+
}
|
|
191
|
+
// ─── Manage totopo menu ───────────────────────────────────────────────────────
|
|
192
|
+
export async function run(packageDir) {
|
|
163
193
|
while (true) {
|
|
164
194
|
const action = await select({
|
|
165
|
-
message: "
|
|
195
|
+
message: "Manage totopo:",
|
|
166
196
|
options: [
|
|
167
|
-
{ value: "
|
|
168
|
-
{ value: "
|
|
169
|
-
{ value: "
|
|
170
|
-
{ value: "
|
|
171
|
-
{ value: "
|
|
172
|
-
{ value: "
|
|
173
|
-
{ value: "reset-keys", label: "Reset API keys", hint: "overwrites ~/.totopo/.env — affects all projects" },
|
|
174
|
-
{ value: "doctor", label: "Doctor", hint: "check Docker and container health" },
|
|
197
|
+
{ value: "stop-containers", label: "Stop containers", hint: "pick running containers" },
|
|
198
|
+
{ value: "clear-memory", label: "Clear agent memory", hint: "pick projects to clear" },
|
|
199
|
+
{ value: "remove-images", label: "Remove images", hint: "pick images to remove" },
|
|
200
|
+
{ value: "reset-keys", label: "Reset API keys", hint: "overwrites ~/.totopo/.env" },
|
|
201
|
+
{ value: "doctor", label: "Doctor", hint: "check Docker health" },
|
|
202
|
+
{ value: "uninstall", label: "Uninstall totopo", hint: "wipe ~/.totopo/ and all containers/images" },
|
|
175
203
|
{ value: "back", label: "← Back" },
|
|
176
204
|
],
|
|
177
205
|
});
|
|
@@ -179,30 +207,28 @@ export async function run(packageDir, projectName, repoRoot) {
|
|
|
179
207
|
return "back";
|
|
180
208
|
}
|
|
181
209
|
switch (action) {
|
|
182
|
-
case "
|
|
183
|
-
await
|
|
210
|
+
case "stop-containers":
|
|
211
|
+
await stopContainers();
|
|
184
212
|
break;
|
|
185
|
-
case "rebuild":
|
|
186
|
-
await runRebuild(projectName);
|
|
187
|
-
return "rebuild";
|
|
188
213
|
case "clear-memory":
|
|
189
|
-
await clearAgentMemory(
|
|
214
|
+
await clearAgentMemory();
|
|
215
|
+
break;
|
|
216
|
+
case "remove-images":
|
|
217
|
+
await removeImages();
|
|
190
218
|
break;
|
|
191
219
|
case "reset-keys":
|
|
192
220
|
await resetApiKeys(packageDir);
|
|
193
221
|
break;
|
|
194
|
-
case "uninstall":
|
|
195
|
-
await uninstall(projectName, repoRoot);
|
|
196
|
-
return;
|
|
197
222
|
case "doctor":
|
|
198
|
-
await runDoctor(
|
|
199
|
-
|
|
200
|
-
case "stop-containers":
|
|
201
|
-
await stopContainers();
|
|
202
|
-
break;
|
|
203
|
-
case "remove-images":
|
|
204
|
-
await removeImages();
|
|
223
|
+
await runDoctor(null, true);
|
|
224
|
+
await sleep(500);
|
|
205
225
|
break;
|
|
226
|
+
case "uninstall":
|
|
227
|
+
await uninstallTotopo();
|
|
228
|
+
return undefined;
|
|
206
229
|
}
|
|
207
230
|
}
|
|
208
231
|
}
|
|
232
|
+
function sleep(ms) {
|
|
233
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
234
|
+
}
|