@cordfuse/crosstalk 7.0.0-alpha.1 → 7.0.0-alpha.10

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
@@ -1,26 +1,97 @@
1
1
  # @cordfuse/crosstalk — host client
2
2
 
3
- > **README is a stub.** Full operator-facing docs land in the docs-consolidation pass. The body below is intentionally short; it covers what's actually implemented today.
3
+ The host-side CLI for the Crosstalk protocol. Pairs with [`@cordfuse/crosstalkd`](https://www.npmjs.com/package/@cordfuse/crosstalkd) the daemon running inside the [`crosstalk-server`](https://github.com/cordfuse/crosstalk/tree/main/server) container and the [transport template](https://github.com/cordfuse/crosstalk/tree/main/transport) it scaffolds.
4
4
 
5
- The host-side CLI for the Crosstalk protocol. Pairs with `@cordfuse/crosstalkd`, the daemon that runs inside the `crosstalk-server` container.
5
+ > **What Crosstalk is.** An agent-agnostic swarm communication protocol built on git. A git repo is the message bus. Full background: **[github.com/cordfuse/crosstalk](https://github.com/cordfuse/crosstalk#why-crosstalk-exists)**.
6
6
 
7
- - Operator installs this package: `npm install -g @cordfuse/crosstalk`
8
- - Operator runs `docker compose up -d` to bring up the `crosstalk-server` container (which runs `crosstalkd`)
9
- - Operator runs `crosstalk <subcommand>` — the client talks to the daemon's HTTP API on `127.0.0.1:7000`
7
+ ---
10
8
 
11
- This split mirrors `docker` (host CLI) / `dockerd` (daemon). The client never reads or writes transport files directly — all protocol operations go through the API.
9
+ ## Install
12
10
 
13
- ## Status (v7.0.0-dev.0)
11
+ ```sh
12
+ npm install -g @cordfuse/crosstalk
13
+ ```
14
14
 
15
- - `crosstalk version` works
16
- - All other subcommands — landing in P4–P6
15
+ Requires: Node.js 20 + Docker on PATH + git on PATH. Works on Linux, macOS, Windows (Docker Desktop).
17
16
 
18
- ## Install (post-publish; today this package is unpublished)
17
+ ---
18
+
19
+ ## Quickstart
19
20
 
20
21
  ```sh
21
- npm install -g @cordfuse/crosstalk
22
+ # 1. Scaffold a transport. Runtime-owned at <base>/crosstalk/transport/.
23
+ crosstalk init
24
+
25
+ # 2. Bring up the engine container.
26
+ crosstalk up
27
+ # Generates <base>/crosstalk/docker-compose.yml and runs `docker
28
+ # compose up -d` for ghcr.io/cordfuse/crosstalk-server:<latest>.
29
+ # No cwd magic — works from any directory.
30
+
31
+ # 3. Install an agent CLI inside the container, then authenticate.
32
+ crosstalk chat --shell
33
+ npm install -g @anthropic-ai/claude-code
34
+ exit
35
+ crosstalk chat --agent claude --login
36
+ # Auth URL opens in your default browser via xdg-open / open / start.
37
+ # Token saved in the /crosstalk-root volume so subsequent dispatched
38
+ # workers reuse it.
39
+
40
+ # 4. Send your first message.
41
+ crosstalk channel general
42
+ crosstalk run --type primitive --to sonnet "What is the capital of France?"
43
+ crosstalk replies <relPath printed by step above>
22
44
  ```
23
45
 
46
+ ---
47
+
48
+ ## Subcommand surface
49
+
50
+ Every name-resolved verb takes an optional `--containername <name>` (or `-c <name>`) flag. Bare verb = default container (literally named `crosstalk`).
51
+
52
+ | Group | Subcommand | What it does |
53
+ |---|---|---|
54
+ | Meta | `crosstalk version` | Print client + engine versions (warns on skew) |
55
+ | Protocol | `crosstalk init [-c <name>] [--remote <url>]` | Scaffold a transport at `<base>/<name>/transport/` |
56
+ | Protocol | `crosstalk run --type primitive\|workflow ...` | Dispatch a primitive or workflow |
57
+ | Protocol | `crosstalk replies <relPath>...` | Poll reply status |
58
+ | Protocol | `crosstalk status` | Container name, transport host path, engine state, channels |
59
+ | Protocol | `crosstalk channel <name> [--rename\|--delete]` | Channel operations |
60
+ | Lifecycle | `crosstalk up` | Generate docker-compose, start the engine container |
61
+ | Lifecycle | `crosstalk down` | Stop the engine container (keep storage) |
62
+ | Lifecycle | `crosstalk rm [--force]` | Remove container AND wipe storage (cannot undo) |
63
+ | Lifecycle | `crosstalk restart` | Down + up |
64
+ | Lifecycle | `crosstalk pull` | Refresh the engine image |
65
+ | Lifecycle | `crosstalk logs [-f]` | Stream the engine container's logs |
66
+ | Interactive | `crosstalk chat --agent <name>` | Interactive session with an agent CLI inside the container |
67
+ | Interactive | `crosstalk chat --agent <name> --login` | OAuth flow with browser-open URL handling |
68
+ | Interactive | `crosstalk chat --shell` | Drop into bash inside the container (debug, install CLIs) |
69
+
70
+ `--agent` is required for any non-shell chat — containers can have multiple installed CLIs, and the operator must pick explicitly. The error message names which ones are actually installed (queried from the engine's `/agents/installed` endpoint).
71
+
72
+ ---
73
+
74
+ ## What lives where
75
+
76
+ - **Host** — `crosstalk` binary on PATH. Per-transport storage at `<base>/<name>/` (one tree per container).
77
+ - **Container** (literally named whatever the operator picked at `init`, default `crosstalk`; image `ghcr.io/cordfuse/crosstalk-server`) — `crosstalkd` daemon, bind-mounted from:
78
+ - `<base>/<name>/transport/` → `/var/lib/crosstalk-transport` (runtime-owned git repo)
79
+ - `<base>/<name>/crosstalk-root/` → `/crosstalk-root` (operator CLIs + auth)
80
+ - `<base>/<name>/crosstalk-state/` → `/var/lib/crosstalk-state` (cursor + heartbeat)
81
+ - **`<base>`** is per-OS user-mode: `$XDG_DATA_HOME/crosstalk` on Linux, `~/Library/Application Support/crosstalk` on macOS, `%LOCALAPPDATA%\crosstalk` on Windows. `CROSSTALK_STORAGE_MODE=system` switches to `/var/lib`-style paths.
82
+ - **Engine HTTP API** — `127.0.0.1:<port>`. Default container = port 7000; named containers allocate at init from 7001 upward, recorded in `<base>/<name>/api-port`.
83
+ - **Container identification** — every crosstalkd container gets the Docker label `crosstalk.transport=true`. The client uses the label for inventory queries, not name-pattern matching.
84
+
85
+ ---
86
+
87
+ ## Architecture
88
+
89
+ Mirrors `docker` (host CLI) / `dockerd` (daemon). The split keeps the protocol-layer logic in one place (the engine) and exposes a clean operator surface on the host without forcing operators to manage Node/agent CLIs/OAuth tokens on their bare host.
90
+
91
+ Client makes plain HTTP requests to the engine over loopback. No auth (same model as ollama, postgres-on-localhost, dockerd's local socket; the loopback bind is what gates exposure). Version skew detected via `X-Crosstalk-Engine-Version` response header — client warns once per process when the operator forgot to `crosstalk pull && crosstalk restart` after upgrading the client (or vice versa).
92
+
93
+ ---
94
+
24
95
  ## License
25
96
 
26
97
  MIT.
package/bin/crosstalk.js CHANGED
@@ -30,6 +30,7 @@ const SUBCOMMANDS = {
30
30
  init: 'init.js',
31
31
  up: 'up.js',
32
32
  down: 'down.js',
33
+ rm: 'rm.js',
33
34
  restart: 'restart.js',
34
35
  pull: 'pull.js',
35
36
  logs: 'logs.js',
@@ -4,21 +4,19 @@
4
4
  // endpoints that haven't been added to the engine yet; they land
5
5
  // when the engine gains them.
6
6
 
7
- import { api } from '../lib/api-client.js';
7
+ import { apiFor } from '../lib/api-client.js';
8
8
  import { reportAndExit } from '../lib/errors.js';
9
- import { positionals, has } from '../lib/argv.js';
9
+ import { positionals, has, flag } from '../lib/argv.js';
10
10
 
11
11
  function usage(exit = 0) {
12
12
  const w = exit === 0 ? process.stdout : process.stderr;
13
13
  w.write(
14
14
  `Usage:
15
- crosstalk channel <name> # create a channel
16
- crosstalk channel list # list channels
15
+ crosstalk channel list # list channels
16
+ crosstalk channel <name> # create a channel
17
+ crosstalk channel <name-or-uuid> --rename <new> # rename
18
+ crosstalk channel <name-or-uuid> --delete # delete (with confirmation)
17
19
  crosstalk channel --help
18
-
19
- Not yet implemented (engine API needs PATCH/DELETE first):
20
- crosstalk channel <name-or-uuid> --rename <new>
21
- crosstalk channel <name-or-uuid> --delete
22
20
  `,
23
21
  );
24
22
  process.exit(exit);
@@ -27,7 +25,8 @@ Not yet implemented (engine API needs PATCH/DELETE first):
27
25
  export async function run(argv) {
28
26
  if (has(argv, '--help') || has(argv, '-h')) usage(0);
29
27
 
30
- const pos = positionals(argv, []);
28
+ const api = apiFor(argv);
29
+ const pos = positionals(argv, ['--rename', '--containername', '-c']);
31
30
  if (pos.length === 0) usage(1);
32
31
 
33
32
  // `crosstalk channel list` → list channels
@@ -50,16 +49,66 @@ export async function run(argv) {
50
49
  return 0;
51
50
  }
52
51
 
53
- // Otherwise: create a channel with the given name.
54
- if (has(argv, '--rename') || has(argv, '--delete')) {
55
- process.stderr.write('crosstalk channel: --rename and --delete are not implemented yet (engine API needs PATCH/DELETE).\n');
52
+ const handle = pos[0];
53
+ const renameTo = flag(argv, '--rename');
54
+ const wantDelete = has(argv, '--delete');
55
+
56
+ if (renameTo && wantDelete) {
57
+ process.stderr.write('crosstalk channel: --rename and --delete are mutually exclusive.\n');
56
58
  return 1;
57
59
  }
58
60
 
59
- const name = pos[0];
61
+ // Rename: PATCH /channels/<handle> { name: <new> }
62
+ if (renameTo) {
63
+ let r;
64
+ try {
65
+ r = await api.patch(`/channels/${encodeURIComponent(handle)}`, { name: renameTo });
66
+ } catch (err) {
67
+ reportAndExit(err, 'crosstalk channel');
68
+ }
69
+ if (r.noop) {
70
+ process.stdout.write(`No-op: ${handle} already named ${renameTo}.\n`);
71
+ return 0;
72
+ }
73
+ process.stdout.write(`Renamed: ${handle} -> ${r.name} (${r.uuid})\n`);
74
+ return 0;
75
+ }
76
+
77
+ // Delete: DELETE /channels/<handle>?confirm=<name>
78
+ // Engine returns 400 if confirm is missing/wrong, with the expected
79
+ // value in the error message. Client reads the channel's name first
80
+ // and passes it as confirm — operator's explicit --delete flag IS the
81
+ // confirmation for now. Interactive prompt deferred (would block on
82
+ // stdin in scripts).
83
+ if (wantDelete) {
84
+ let target;
85
+ try {
86
+ const all = await api.get('/channels');
87
+ target = all.channels.find((c) => c.uuid === handle || c.name === handle);
88
+ } catch (err) {
89
+ reportAndExit(err, 'crosstalk channel');
90
+ }
91
+ if (!target) {
92
+ process.stderr.write(`crosstalk channel: '${handle}' not found.\n`);
93
+ return 1;
94
+ }
95
+ const confirm = target.name ?? target.uuid;
96
+ let r;
97
+ try {
98
+ r = await api.delete(
99
+ `/channels/${encodeURIComponent(handle)}?confirm=${encodeURIComponent(confirm)}`,
100
+ );
101
+ } catch (err) {
102
+ reportAndExit(err, 'crosstalk channel');
103
+ }
104
+ process.stdout.write(`Deleted channel: ${r.name ?? '(unnamed)'} (${r.uuid})\n`);
105
+ return 0;
106
+ }
107
+
108
+ // No flags → create a channel with the given name.
60
109
  let r;
61
110
  try {
62
- r = await api.post('/channels', { name });
111
+ r = await api.post('/channels', { name: handle });
63
112
  } catch (err) {
64
113
  reportAndExit(err, 'crosstalk channel');
65
114
  }
package/commands/chat.js CHANGED
@@ -1,74 +1,50 @@
1
- // crosstalk chat — the single interactive entry point into the container.
1
+ // crosstalk chat — interactive entry into the container.
2
2
  //
3
- // Modes:
4
- // crosstalk chat # default agent (claude), interactive
5
- // crosstalk chat --agent <name> # interactive with a specific agent
6
- // crosstalk chat --login [--agent X] # OAuth setup flow with browser-open
7
- // crosstalk chat --shell # bash escape hatch (sysadmin / debug)
3
+ // Modes (alpha.8):
4
+ // crosstalk chat --agent <name> # interactive session with an agent CLI
5
+ // crosstalk chat --shell # bash escape hatch (debug, install CLIs, run setup commands)
8
6
  //
9
- // Login flow design (root-cause fix, not a bandaid):
10
- // The container has no DISPLAY / no browser / no xdg-open. So the agent
11
- // CLI prints a URL and expects the operator to manually open it. THIS
12
- // wrapper runs on the HOST — where the browser lives. It intercepts the
13
- // URL on the agent's stdout and opens it directly via the OS-native
14
- // browser launcher (`open` / `xdg-open` / `wslview` / `start`). Operator
15
- // never has to read or copy-paste the URL.
7
+ // --agent is required for non-shell mode. There's no implicit default;
8
+ // containers may have multiple agent CLIs installed, the operator
9
+ // picks one explicitly.
16
10
  //
17
- // COLUMNS=1000 is still set under --login as a contingency: if browser
18
- // open fails (headless SSH session, missing xdg-open, etc.), the URL
19
- // still renders unwrapped in the terminal for manual copy. That's the
20
- // fallback, not the primary mechanism.
11
+ // `--login` was removed in alpha.8. Per-agent auth is now handled via
12
+ // `data/crosstalk.yaml`'s per-provider `env_file:` (env-injected at
13
+ // agent spawn time inside the engine) plus operator-run setup commands
14
+ // for agents whose auth is a stateful CLI act (e.g. codex). Recipes are
15
+ // documented in transport/auth/README.md.
21
16
 
22
- import { spawn, spawnSync } from 'child_process';
23
- import { requireTransportRoot, containerName } from '../lib/transport.js';
17
+ import { spawnSync } from 'child_process';
18
+ import { requireInitialized } from '../lib/resolve.js';
19
+ import { apiFor, ConnectError } from '../lib/api-client.js';
24
20
  import { flag, has } from '../lib/argv.js';
25
21
 
26
22
  const KNOWN_AGENTS = ['claude', 'codex', 'gemini', 'qwen', 'opencode', 'agy'];
27
- // Conservative URL regex: matches the leading https://...token boundary,
28
- // stops at whitespace/quote/angle. False positives are bounded to --login
29
- // mode where the first URL printed is almost certainly the auth URL.
30
- const URL_RE = /https?:\/\/[^\s<>"']+/;
31
-
32
- function openInBrowser(url) {
33
- const cmd =
34
- process.platform === 'darwin' ? 'open' :
35
- process.platform === 'win32' ? 'start' :
36
- process.env['WSL_DISTRO_NAME'] ? 'wslview' :
37
- /* default Linux */ 'xdg-open';
38
- try {
39
- const r = spawnSync(cmd, [url], { stdio: 'ignore' });
40
- return r.status === 0;
41
- } catch {
42
- return false;
43
- }
44
- }
45
23
 
46
24
  function usage(exit = 0) {
47
25
  const w = exit === 0 ? process.stdout : process.stderr;
48
26
  w.write(
49
- `Usage: crosstalk chat [--agent <name>] [--login] [--shell]
27
+ `Usage: crosstalk chat --agent <name>
28
+ crosstalk chat --shell
29
+
30
+ Opens an interactive session inside the engine container.
31
+
32
+ --agent is REQUIRED for any chat mode. Containers can have multiple
33
+ agent CLIs installed; the operator must say which one to invoke.
50
34
 
51
- Opens an interactive session inside the engine container — the single
52
- PTY-wrapped entry point for everything you'd do inside the container
53
- (daily chat, OAuth setup, sysadmin).
35
+ Supported agents: ${KNOWN_AGENTS.join(', ')}.
54
36
 
55
37
  Modes:
56
- (default) Interactive with the default agent (claude)
57
- --agent <name> Use a specific agent. Supported:
58
- ${KNOWN_AGENTS.join(', ')}
59
- --login First-time auth flow. Intercepts the OAuth URL on
60
- the agent's stdout and opens it in your default
61
- browser via the OS-native launcher. Falls back to
62
- printing the URL unwrapped if no browser is
63
- reachable (headless / SSH session).
64
- --shell Drop into bash (install agent CLIs, debug,
65
- inspect state).
38
+ --agent <name> Interactive with the named agent CLI. Use the
39
+ agent's own keys/commands for first-time setup
40
+ (see transport/auth/README.md for recipes).
41
+ --shell Drop into bash (install agent CLIs, debug, run
42
+ one-time auth setup commands like
43
+ \`codex login --with-api-key\`).
66
44
 
67
45
  Examples:
68
- crosstalk chat # interactive claude
46
+ crosstalk chat --agent claude # interactive claude
69
47
  crosstalk chat --agent gemini # interactive gemini
70
- crosstalk chat --login # OAuth, claude
71
- crosstalk chat --login --agent codex # OAuth, codex
72
48
  crosstalk chat --shell # bash
73
49
  `,
74
50
  );
@@ -78,8 +54,8 @@ Examples:
78
54
  function runInteractive(container, command, env = {}) {
79
55
  // Full TTY passthrough — docker exec -it gives the container a real
80
56
  // PTY, and stdio: 'inherit' forwards stdin/stdout/stderr from the
81
- // operator's terminal. Agent CLIs get to render their rich TUI
82
- // normally.
57
+ // operator's terminal. Agent CLIs render their rich TUI normally and
58
+ // SIGWINCH propagates from the host's tty to the container's PTY.
83
59
  const envFlags = [];
84
60
  for (const [k, v] of Object.entries(env)) {
85
61
  envFlags.push('--env', `${k}=${v}`);
@@ -90,60 +66,48 @@ function runInteractive(container, command, env = {}) {
90
66
  return r.status ?? 1;
91
67
  }
92
68
 
93
- function runLogin(container, agent) {
94
- // Login mode: tee the agent's stdout so we can spot the auth URL and
95
- // open it in the operator's browser. The container still has a real
96
- // PTY (docker exec -it allocates one inside the container regardless
97
- // of host stdio configuration), so the agent CLI renders into a TTY;
98
- // we just pipe the bytes on the host side to inspect them.
99
- //
100
- // No agent-specific "login" subcommand — we just run the agent's
101
- // default interactive entry point. First-run auth flow happens
102
- // automatically for every agent we support. Operator can pass
103
- // --agent <name> to target a specific one.
104
- return new Promise((resolve) => {
105
- const child = spawn(
106
- 'docker',
107
- [
108
- 'exec', '-it',
109
- '--env', 'COLUMNS=1000', // fallback for browser-open failure
110
- '--env', 'LINES=40',
111
- container,
112
- agent,
113
- ],
114
- { stdio: ['inherit', 'pipe', 'inherit'] },
115
- );
116
- let opened = false;
117
- child.stdout.on('data', (chunk) => {
118
- process.stdout.write(chunk);
119
- if (opened) return;
120
- const match = chunk.toString().match(URL_RE);
121
- if (!match) return;
122
- const url = match[0];
123
- opened = true;
124
- const ok = openInBrowser(url);
125
- process.stdout.write(
126
- ok
127
- ? `\n[crosstalk chat] Opened auth URL in your default browser.\n`
128
- : `\n[crosstalk chat] Could not auto-open browser. Open this URL manually:\n ${url}\n`,
129
- );
130
- });
131
- child.on('exit', (code) => resolve(code ?? 0));
132
- child.on('error', () => resolve(1));
133
- });
134
- }
135
-
136
69
  export async function run(argv) {
137
70
  if (has(argv, '--help') || has(argv, '-h')) usage(0);
138
71
 
139
- const root = requireTransportRoot();
140
- const name = containerName(root);
72
+ const { name } = requireInitialized(argv);
73
+ const api = apiFor(argv);
141
74
 
142
75
  if (has(argv, '--shell')) {
143
76
  return runInteractive(name, 'bash');
144
77
  }
145
78
 
146
- const agent = flag(argv, '--agent') ?? 'claude';
79
+ const agent = flag(argv, '--agent');
80
+ if (!agent) {
81
+ // Query the engine for what's actually installed (vs the theoretical
82
+ // supported set). Gives a smarter error than "supported: claude,
83
+ // codex, gemini, qwen, opencode, agy" when only 1-2 are actually
84
+ // present in the container.
85
+ let installed = null;
86
+ try {
87
+ const r = await api.get('/agents/installed');
88
+ installed = r.installed;
89
+ } catch (err) {
90
+ if (!(err instanceof ConnectError)) {
91
+ // engine unreachable — fall through with no introspection
92
+ }
93
+ }
94
+ if (installed && installed.length === 0) {
95
+ process.stderr.write(
96
+ `crosstalk chat: no agent CLIs installed in this container.\n` +
97
+ ` Install one first:\n` +
98
+ ` crosstalk chat --shell\n` +
99
+ ` npm install -g @anthropic-ai/claude-code # or your agent's installer\n`,
100
+ );
101
+ return 1;
102
+ }
103
+ const list = installed && installed.length > 0
104
+ ? `Installed in this container: ${installed.join(', ')}.`
105
+ : `Supported agents: ${KNOWN_AGENTS.join(', ')}.`;
106
+ process.stderr.write(
107
+ `crosstalk chat: --agent <name> is required.\n ${list}\n`,
108
+ );
109
+ return 1;
110
+ }
147
111
  if (!KNOWN_AGENTS.includes(agent)) {
148
112
  process.stderr.write(
149
113
  `crosstalk chat: unknown agent '${agent}'. Supported: ${KNOWN_AGENTS.join(', ')}\n`,
@@ -151,9 +115,5 @@ export async function run(argv) {
151
115
  return 1;
152
116
  }
153
117
 
154
- if (has(argv, '--login')) {
155
- return await runLogin(name, agent);
156
- }
157
-
158
118
  return runInteractive(name, agent);
159
119
  }
package/commands/down.js CHANGED
@@ -1,18 +1,22 @@
1
- // crosstalk down — stop the engine container for this transport.
1
+ // crosstalk down — stop a transport's container, keep storage.
2
+ //
3
+ // alpha.6: name-resolved. --volumes flag retires (no named volumes in
4
+ // the bind-mount model). Reversible — next `up` resumes against the
5
+ // preserved <base>/<name>/ storage.
2
6
 
3
7
  import { existsSync } from 'fs';
4
8
  import { spawnSync } from 'child_process';
5
- import { requireTransportRoot, composeFile } from '../lib/transport.js';
6
9
  import { has } from '../lib/argv.js';
10
+ import { requireInitialized, DEFAULT_CONTAINER_NAME } from '../lib/resolve.js';
7
11
 
8
12
  function usage(exit = 0) {
9
13
  const w = exit === 0 ? process.stdout : process.stderr;
10
14
  w.write(
11
- `Usage: crosstalk down [--volumes]
15
+ `Usage: crosstalk down [--containername <name>]
12
16
 
13
- Stops the engine container for the transport in the current directory.
14
- With --volumes, also removes the persistent volumes (clears operator-
15
- installed CLIs + auth state; you'll have to re-install + re-auth).
17
+ Stops the engine container for a transport. Storage at <base>/<name>/
18
+ is preserved next 'crosstalk up' resumes against it. Use
19
+ 'crosstalk rm' if you want to wipe storage as well.
16
20
  `,
17
21
  );
18
22
  process.exit(exit);
@@ -21,17 +25,16 @@ installed CLIs + auth state; you'll have to re-install + re-auth).
21
25
  export async function run(argv) {
22
26
  if (has(argv, '--help') || has(argv, '-h')) usage(0);
23
27
 
24
- const root = requireTransportRoot();
25
- const composeYml = composeFile(root);
26
- if (!existsSync(composeYml)) {
27
- process.stderr.write(`crosstalk down: no docker-compose.yml at ${composeYml}\n`);
28
- process.stderr.write(` (this transport was probably never brought up — nothing to do)\n`);
28
+ const { name, paths } = requireInitialized(argv);
29
+ if (!existsSync(paths.composeFile)) {
30
+ process.stderr.write(
31
+ `crosstalk down: '${name}' has no compose file (never brought up). Nothing to do.\n`,
32
+ );
29
33
  return 0;
30
34
  }
31
35
 
32
- const args = ['compose', '-f', composeYml, 'down'];
33
- if (has(argv, '--volumes')) args.push('--volumes');
34
-
35
- const r = spawnSync('docker', args, { cwd: root, stdio: 'inherit' });
36
+ const r = spawnSync('docker', ['compose', '-f', paths.composeFile, 'down'], {
37
+ stdio: 'inherit',
38
+ });
36
39
  return r.status ?? 1;
37
40
  }