@cordfuse/crosstalk 7.0.0-alpha.5 → 7.0.0-alpha.6

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/commands/rm.js ADDED
@@ -0,0 +1,109 @@
1
+ // crosstalk rm — remove a transport completely.
2
+ //
3
+ // Stops the container if running, removes the container, deletes the
4
+ // entire <base>/<name>/ tree (transport git repo, installed CLIs, auth
5
+ // state, dispatcher state, compose file, port file). Confirmation
6
+ // prompt by default; --force / -f to skip for scripted use.
7
+ //
8
+ // Cannot undo. All message history is lost.
9
+
10
+ import { existsSync, rmSync } from 'fs';
11
+ import { spawnSync } from 'child_process';
12
+ import { createInterface } from 'readline';
13
+ import { has } from '../lib/argv.js';
14
+ import {
15
+ parseContainerName,
16
+ storagePaths,
17
+ isInitialized,
18
+ isRunning,
19
+ containerExists,
20
+ DEFAULT_CONTAINER_NAME,
21
+ } from '../lib/resolve.js';
22
+
23
+ function usage(exit = 0) {
24
+ const w = exit === 0 ? process.stdout : process.stderr;
25
+ w.write(
26
+ `Usage: crosstalk rm [--containername <name>] [--force]
27
+
28
+ Stops and removes the engine container, then deletes the entire
29
+ storage tree under <base>/<name>/. Includes the transport git repo
30
+ (all message history), installed agent CLIs + auth tokens, and
31
+ dispatcher state. CANNOT BE UNDONE.
32
+
33
+ Options:
34
+ --containername <name> Container to remove (default: 'crosstalk').
35
+ --force, -f Skip the confirmation prompt.
36
+ `,
37
+ );
38
+ process.exit(exit);
39
+ }
40
+
41
+ function confirm(promptText) {
42
+ return new Promise((resolve) => {
43
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
44
+ rl.question(promptText, (ans) => {
45
+ rl.close();
46
+ const yes = /^y(es)?$/i.test(ans.trim());
47
+ resolve(yes);
48
+ });
49
+ });
50
+ }
51
+
52
+ export async function run(argv) {
53
+ if (has(argv, '--help') || has(argv, '-h')) usage(0);
54
+
55
+ let name;
56
+ try {
57
+ name = parseContainerName(argv);
58
+ } catch (err) {
59
+ process.stderr.write(`crosstalk rm: ${err.message}\n`);
60
+ return 1;
61
+ }
62
+
63
+ const paths = storagePaths(name);
64
+ const haveStorage = isInitialized(name) || existsSync(paths.storageRoot);
65
+ const haveContainer = containerExists(name);
66
+
67
+ if (!haveStorage && !haveContainer) {
68
+ process.stderr.write(`crosstalk rm: transport '${name}' not found.\n`);
69
+ return 1;
70
+ }
71
+
72
+ if (!has(argv, '--force') && !has(argv, '-f')) {
73
+ process.stderr.write(
74
+ `Remove '${name}'?\n` +
75
+ ` This deletes:\n` +
76
+ ` - transport git repo at ${paths.transportDir}\n` +
77
+ ` (all message history, channels, replies)\n` +
78
+ ` - installed agent CLIs + auth tokens (claude, codex, ...)\n` +
79
+ ` - dispatcher state (cursor, heartbeat, errors.log)\n` +
80
+ ` Cannot undo.\n`,
81
+ );
82
+ const ok = await confirm('[y/N]: ');
83
+ if (!ok) {
84
+ process.stderr.write('crosstalk rm: cancelled.\n');
85
+ return 1;
86
+ }
87
+ }
88
+
89
+ // Stop + remove the container (best-effort).
90
+ if (isRunning(name) && existsSync(paths.composeFile)) {
91
+ spawnSync('docker', ['compose', '-f', paths.composeFile, 'down'], { stdio: 'inherit' });
92
+ } else if (haveContainer) {
93
+ spawnSync('docker', ['rm', '-f', name], { stdio: 'ignore' });
94
+ }
95
+
96
+ // Wipe storage.
97
+ try {
98
+ rmSync(paths.storageRoot, { recursive: true, force: true });
99
+ } catch (err) {
100
+ process.stderr.write(
101
+ `crosstalk rm: failed to remove ${paths.storageRoot} — ${err.message}\n` +
102
+ ` (container is removed, but storage cleanup needs intervention)\n`,
103
+ );
104
+ return 1;
105
+ }
106
+
107
+ process.stdout.write(`Removed '${name}'.\n`);
108
+ return 0;
109
+ }
package/commands/run.js CHANGED
@@ -8,11 +8,11 @@
8
8
  // [--new] <file|->
9
9
 
10
10
  import { readFileSync, existsSync, statSync } from 'fs';
11
- import { api } from '../lib/api-client.js';
11
+ import { apiFor } from '../lib/api-client.js';
12
12
  import { reportAndExit } from '../lib/errors.js';
13
13
  import { flag, has } from '../lib/argv.js';
14
14
 
15
- const FLAGS_WITH_VALUE = new Set(['--type', '--to', '--as', '--fanout', '--channel', '--from']);
15
+ const FLAGS_WITH_VALUE = new Set(['--type', '--to', '--as', '--fanout', '--channel', '--from', '--containername', '-c']);
16
16
 
17
17
  function usage(exit = 0) {
18
18
  const w = exit === 0 ? process.stdout : process.stderr;
@@ -52,6 +52,7 @@ function resolveBody(arg) {
52
52
  export async function run(argv) {
53
53
  if (has(argv, '--help') || has(argv, '-h')) usage(0);
54
54
 
55
+ const api = apiFor(argv);
55
56
  const type = flag(argv, '--type');
56
57
  if (type !== 'primitive' && type !== 'workflow') usage(1);
57
58
 
@@ -60,7 +61,7 @@ export async function run(argv) {
60
61
  let bodyArg;
61
62
  for (let i = argv.length - 1; i >= 0; i--) {
62
63
  const a = argv[i];
63
- if (a.startsWith('--')) break;
64
+ if (a.startsWith('-')) break;
64
65
  // Make sure we're not the value of a flag like `--to <value>`
65
66
  const prev = argv[i - 1];
66
67
  if (prev && FLAGS_WITH_VALUE.has(prev)) continue;
@@ -1,9 +1,16 @@
1
1
  // crosstalk status — mirror of crosstalkd status output, fetched via API.
2
+ //
3
+ // alpha.6: also prints the host path of the transport git repo as an
4
+ // escape hatch for operators who want to poke around with raw git.
2
5
 
3
- import { api } from '../lib/api-client.js';
6
+ import { apiFor } from '../lib/api-client.js';
4
7
  import { reportAndExit } from '../lib/errors.js';
8
+ import { requireInitialized } from '../lib/resolve.js';
9
+
10
+ export async function run(argv) {
11
+ const { name, paths } = requireInitialized(argv);
12
+ const api = apiFor(argv);
5
13
 
6
- export async function run(_argv) {
7
14
  let s;
8
15
  try {
9
16
  s = await api.get('/status');
@@ -11,25 +18,23 @@ export async function run(_argv) {
11
18
  reportAndExit(err, 'crosstalk status');
12
19
  }
13
20
 
14
- process.stdout.write(`Crosstalk transport: ${s.transport_root}\n`);
21
+ process.stdout.write(`Container: ${name}\n`);
22
+ process.stdout.write(`Transport host path: ${paths.transportDir}\n`);
15
23
  process.stdout.write(`Engine alias: ${s.alias}\n`);
16
24
  process.stdout.write(`Engine version: ${s.version}\n`);
17
25
  process.stdout.write('\n');
18
26
 
19
27
  process.stdout.write(`Claimed models: ${s.claimed_models.length}\n`);
20
28
  if (s.claimed_models.length === 0) {
21
- // Two failure shapes look the same in claimed_models=0: (a) no CLIs
22
- // installed at all, (b) CLIs installed but data/models.yaml has no
23
- // entries referencing them. Ask /agents/installed to distinguish so
24
- // the operator gets a directly-actionable hint.
25
29
  let installed = null;
26
30
  try {
27
31
  const r = await api.get('/agents/installed');
28
32
  installed = r.installed;
29
33
  } catch { /* ignore — fall back to generic message */ }
30
34
  if (installed && installed.length > 0) {
35
+ const verb = installed.length === 1 ? 'references it' : 'references them';
31
36
  process.stdout.write(
32
- ` (${installed.join(', ')} installed in the container but no data/models.yaml entry references them — add one to claim)\n`,
37
+ ` (${installed.join(', ')} installed in the container but no data/models.yaml entry ${verb} — add one to claim)\n`,
33
38
  );
34
39
  } else {
35
40
  process.stdout.write(' (no model CLIs found inside the container — install one via `crosstalk chat --shell` and add an entry to data/models.yaml)\n');
@@ -56,9 +61,9 @@ export async function run(_argv) {
56
61
 
57
62
  process.stdout.write(`Channels: ${s.channels.length}\n`);
58
63
  for (const ch of s.channels) {
59
- const name = ch.name ?? '(unnamed)';
64
+ const chName = ch.name ?? '(unnamed)';
60
65
  const parentSuffix = ch.parent ? ` [child of ${ch.parent.slice(0, 8)}]` : '';
61
- process.stdout.write(` ${ch.uuid.slice(0, 8)}... — ${name}${parentSuffix}\n`);
66
+ process.stdout.write(` ${ch.uuid.slice(0, 8)}... — ${chName}${parentSuffix}\n`);
62
67
  }
63
68
  process.stdout.write('\n');
64
69
 
package/commands/up.js CHANGED
@@ -1,140 +1,74 @@
1
- // crosstalk up — bring up the engine container for this transport.
1
+ // crosstalk up — bring up the engine container for a transport.
2
2
  //
3
- // First-time use: generates docker-compose.yml in the transport root from
4
- // a template (operator can edit freely, subsequent `up`s won't clobber).
5
- // The compose file bind-mounts the transport root as the engine's working
6
- // tree so git operations work both ways — engine sees operator's local
7
- // commits and configured remotes immediately, operator sees engine
8
- // commits (replies, cursor advances) immediately.
3
+ // alpha.6: no cwd magic. Resolves container by --containername flag
4
+ // (default 'crosstalk') and operates against <base>/<name>/.
9
5
  //
10
- // Subsequent runs: just shell out to `docker compose up -d`.
6
+ // Three lifecycle cases:
7
+ // 1. Container already running → error (use chat/restart instead).
8
+ // 2. Storage exists, no container → resume; print one-line notice.
9
+ // 3. Storage doesn't exist → error pointing to `crosstalk init`.
10
+ //
11
+ // First-time up generates <base>/<name>/docker-compose.yml from a template.
12
+ // Subsequent ups respect operator edits — only regenerate if missing.
11
13
 
12
- import { writeFileSync, existsSync, mkdirSync, appendFileSync, readFileSync } from 'fs';
14
+ import { writeFileSync, existsSync, readFileSync } from 'fs';
13
15
  import { spawnSync } from 'child_process';
14
- import { join } from 'path';
15
- import { homedir } from 'os';
16
- import { requireTransportRoot, transportName, composeFile } from '../lib/transport.js';
17
16
  import { has } from '../lib/argv.js';
17
+ import {
18
+ requireInitialized,
19
+ isRunning,
20
+ containerExists,
21
+ apiPortFor,
22
+ DEFAULT_CONTAINER_NAME,
23
+ CROSSTALK_LABEL,
24
+ } from '../lib/resolve.js';
18
25
 
19
26
  const DEFAULT_IMAGE = process.env.CROSSTALK_IMAGE
20
- ?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.5';
21
- const DEFAULT_API_PORT = Number(process.env.CROSSTALK_API_PORT) || 7000;
22
- const STORAGE_MODE = (process.env.CROSSTALK_STORAGE_MODE ?? 'user').toLowerCase();
27
+ ?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.6';
23
28
 
24
29
  function usage(exit = 0) {
25
30
  const w = exit === 0 ? process.stdout : process.stderr;
26
31
  w.write(
27
- `Usage: crosstalk up
32
+ `Usage: crosstalk up [--containername <name>]
33
+
34
+ Starts the engine container for a transport that has already been
35
+ 'crosstalk init'd. Default container is 'crosstalk'; pass --containername
36
+ to address a named one.
28
37
 
29
- Brings up the engine container for the transport in the current directory
30
- (or any ancestor containing CROSSTALK-VERSION). First-time use generates
31
- docker-compose.yml in the transport root.
38
+ If the storage at <base>/<name>/ already exists (operator previously
39
+ brought it up then 'crosstalk down'd it), the container resumes against
40
+ that state installed agent CLIs, OAuth tokens, dispatcher cursor all
41
+ carry over.
32
42
 
33
43
  Environment:
34
- CROSSTALK_IMAGE Image to pin (default: bundled GHCR tag)
35
- CROSSTALK_API_PORT Host port to map (default: 7000)
36
- CROSSTALK_ALIAS Dispatcher's machine identity in the bus
37
- CROSSTALK_STORAGE_MODE user (default) | system
38
- Where bind-mounted crosstalk-root +
39
- crosstalk-state live on the host. 'system'
40
- uses paths under /var/lib (Linux) / /Library
41
- (macOS) / C:\\ProgramData (Windows) — requires
42
- sudo/UAC and (on macOS) Docker Desktop file-
43
- sharing allowlist. 'user' uses paths under
44
- the operator's home directory with no
45
- escalation.
44
+ CROSSTALK_IMAGE Override the engine image
45
+ CROSSTALK_ALIAS Override the engine's machine identity
46
+ (defaults to the container name)
46
47
  `,
47
48
  );
48
49
  process.exit(exit);
49
50
  }
50
51
 
51
- // Resolve the host directories that back the container's /crosstalk-root
52
- // (operator-installed CLIs + auth) and /var/lib/crosstalk-state
53
- // (dispatcher state) bind mounts.
54
- //
55
- // User-mode (default) keeps everything under the operator's home so no
56
- // sudo / UAC / Docker Desktop file-sharing allowlist gymnastics are
57
- // needed. System-mode is for headless production-style deployments
58
- // where multiple operators share a machine and a service user manages
59
- // the daemon.
60
- //
61
- // Host-side dir names use the same `crosstalk-` prefix as the container
62
- // paths so things stay identifiable wherever they surface (backup tools,
63
- // file managers, support-email screenshots).
64
- function resolveStoragePaths(transportName, mode) {
65
- const platform = process.platform;
66
- // Per-OS base directories.
67
- const bases = (platform === 'darwin')
68
- ? {
69
- user: join(homedir(), 'Library', 'Application Support', 'crosstalk'),
70
- system: '/Library/Application Support/crosstalk',
71
- }
72
- : (platform === 'win32')
73
- ? {
74
- // %LOCALAPPDATA% should always be set on supported Win versions;
75
- // fall back to ~/AppData/Local if it isn't.
76
- user: process.env['LOCALAPPDATA']
77
- ? join(process.env['LOCALAPPDATA'], 'crosstalk')
78
- : join(homedir(), 'AppData', 'Local', 'crosstalk'),
79
- system: 'C:\\ProgramData\\crosstalk',
80
- }
81
- : /* linux + other unix */ {
82
- // Honor XDG_DATA_HOME silently (no docs surface; the 99% of
83
- // operators who haven't set it get ~/.local/share/crosstalk
84
- // either way).
85
- user: join(process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share'), 'crosstalk'),
86
- system: '/var/lib/crosstalk',
87
- };
88
-
89
- if (mode !== 'user' && mode !== 'system') {
90
- throw new Error(
91
- `crosstalk up: CROSSTALK_STORAGE_MODE='${mode}' invalid — must be 'user' or 'system'.`,
92
- );
93
- }
94
- const base = bases[mode];
95
- const transportRoot = join(base, transportName);
96
- return {
97
- crosstalkRoot: join(transportRoot, 'crosstalk-root'),
98
- crosstalkState: join(transportRoot, 'crosstalk-state'),
99
- mode,
100
- };
101
- }
102
-
103
- function renderCompose({ name, image, apiPort, alias, uid, gid, crosstalkRootHost, crosstalkStateHost, storageMode }) {
104
- // Entrypoint runs as root for setup (npm prefix, SSH key import). It
105
- // then adjusts a 'crosstalkd' user to match CROSSTALK_UID/GID
106
- // (operator's host UID), chowns the bind-mounted transport, state
107
- // dir, AND /crosstalk-root to that user, then drops via setpriv before
108
- // exec'ing the dispatcher. Net effect: bind-mount files written by
109
- // the engine are operator-owned on the host, so \`git remote add\`
110
- // and \`rm -rf\` from the host both work without sudo.
111
- return `# Generated by \`crosstalk up\`. Safe to edit; subsequent \`up\`s
112
- # only regenerate when this file is missing. Compose config is machine-
113
- # local — kept out of git via .gitignore by default.
114
- #
115
- # Storage mode: ${storageMode}
116
- # crosstalk-root (container /crosstalk-root) → ${crosstalkRootHost}
117
- # crosstalk-state (container /var/lib/crosstalk-state) → ${crosstalkStateHost}
118
- # transport (container /var/lib/crosstalk-transport) → \${PWD} (bind to this directory)
52
+ function renderCompose({ name, image, apiPort, alias, uid, gid, paths }) {
53
+ return `# Generated by 'crosstalk up'. Machine-local — runtime-owned.
54
+ # Storage mode: ${paths.mode}
55
+ # transport (container /var/lib/crosstalk-transport) → ${paths.transportDir}
56
+ # crosstalk-root (container /crosstalk-root) ${paths.crosstalkRoot}
57
+ # crosstalk-state (container /var/lib/crosstalk-state) → ${paths.crosstalkState}
119
58
 
120
59
  services:
121
60
  crosstalkd:
122
61
  image: ${image}
123
- container_name: crosstalk-${name}
62
+ container_name: ${name}
124
63
  restart: unless-stopped
64
+ labels:
65
+ ${CROSSTALK_LABEL.replace('=', ': "')}"
125
66
  ports:
126
67
  - "127.0.0.1:${apiPort}:7000"
127
68
  volumes:
128
- # Transport bind-mount: \`git remote add\` from the host is visible
129
- # to the engine on the next tick. No clone-on-startup needed.
130
- - .:/var/lib/crosstalk-transport
131
- # Operator-installed agent CLIs (claude, codex, agy, ...) + auth
132
- # state. Chowned to operator UID at entrypoint so host-side
133
- # \`rm -rf\` works without sudo.
134
- - ${crosstalkRootHost}:/crosstalk-root
135
- # Dispatcher's machine-local state: cursor, heartbeat, errors.log,
136
- # pidfile, wake.signal. Operator-UID-owned (chowned by entrypoint).
137
- - ${crosstalkStateHost}:/var/lib/crosstalk-state
69
+ - ${paths.transportDir}:/var/lib/crosstalk-transport
70
+ - ${paths.crosstalkRoot}:/crosstalk-root
71
+ - ${paths.crosstalkState}:/var/lib/crosstalk-state
138
72
  environment:
139
73
  CROSSTALK_ALIAS: ${alias}
140
74
  CROSSTALK_UID: "${uid}"
@@ -145,73 +79,49 @@ services:
145
79
  `;
146
80
  }
147
81
 
148
- function ensureGitignored(transportRoot) {
149
- const path = join(transportRoot, '.gitignore');
150
- const entry = 'docker-compose.yml\n';
151
- if (!existsSync(path)) {
152
- writeFileSync(path, entry);
153
- return;
154
- }
155
- const current = readFileSync(path, 'utf-8');
156
- if (!current.split('\n').includes('docker-compose.yml')) {
157
- appendFileSync(path, (current.endsWith('\n') ? '' : '\n') + entry);
158
- }
159
- }
160
-
161
82
  export async function run(argv) {
162
83
  if (has(argv, '--help') || has(argv, '-h')) usage(0);
163
84
 
164
- const root = requireTransportRoot();
165
- const composeYml = composeFile(root);
166
- const generated = !existsSync(composeYml);
85
+ const { name, paths } = requireInitialized(argv);
86
+
87
+ // Case 1: already running → error, don't surprise the operator with
88
+ // a silent no-op or implicit restart.
89
+ if (isRunning(name)) {
90
+ process.stderr.write(
91
+ `crosstalk up: '${name}' is already running.\n` +
92
+ ` Use 'crosstalk chat${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}' to attach,\n` +
93
+ ` 'crosstalk restart${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}' to recreate,\n` +
94
+ ` or 'crosstalk down${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}' first.\n`,
95
+ );
96
+ return 1;
97
+ }
98
+
99
+ // Case "stopped but not removed" — `docker stop` without compose down.
100
+ // Silently remove the dead container so the recreate works.
101
+ if (containerExists(name)) {
102
+ spawnSync('docker', ['rm', '-f', name], { stdio: 'ignore' });
103
+ }
167
104
 
105
+ // Compose file generation. If operator edited theirs, respect it.
106
+ const generated = !existsSync(paths.composeFile);
168
107
  if (generated) {
169
- const alias = process.env.CROSSTALK_ALIAS ?? process.env.HOSTNAME ?? transportName(root);
108
+ const alias = process.env.CROSSTALK_ALIAS ?? name;
170
109
  const uid = typeof process.getuid === 'function' ? process.getuid() : 1000;
171
110
  const gid = typeof process.getgid === 'function' ? process.getgid() : 1000;
172
- let storage;
173
- try {
174
- storage = resolveStoragePaths(transportName(root), STORAGE_MODE);
175
- } catch (err) {
176
- process.stderr.write(`${err.message}\n`);
177
- return 1;
178
- }
179
- // Pre-create the host bind-mount directories. Docker would auto-
180
- // create them as root-owned otherwise, which would break the
181
- // operator-UID chown step inside the container.
182
- try {
183
- mkdirSync(storage.crosstalkRoot, { recursive: true });
184
- mkdirSync(storage.crosstalkState, { recursive: true });
185
- } catch (err) {
186
- process.stderr.write(
187
- `crosstalk up: failed to create storage directories — ${err.message}\n`,
188
- );
189
- if (storage.mode === 'system') {
190
- process.stderr.write(
191
- ` System-mode storage requires write access to ${storage.crosstalkRoot}'s parent.\n` +
192
- ` Try: sudo mkdir -p ${storage.crosstalkRoot} ${storage.crosstalkState} && sudo chown -R \$USER ${storage.crosstalkRoot} ${storage.crosstalkState}\n`,
193
- );
194
- }
195
- return 1;
196
- }
197
- const content = renderCompose({
198
- name: transportName(root),
199
- image: DEFAULT_IMAGE,
200
- apiPort: DEFAULT_API_PORT,
201
- alias,
202
- uid,
203
- gid,
204
- crosstalkRootHost: storage.crosstalkRoot,
205
- crosstalkStateHost: storage.crosstalkState,
206
- storageMode: storage.mode,
207
- });
208
- writeFileSync(composeYml, content);
209
- ensureGitignored(root);
210
- process.stdout.write(`Generated ${composeYml} (storage: ${storage.mode})\n`);
111
+ const apiPort = apiPortFor(name);
112
+ const image = DEFAULT_IMAGE;
113
+ const content = renderCompose({ name, image, apiPort, alias, uid, gid, paths });
114
+ writeFileSync(paths.composeFile, content);
115
+ }
116
+
117
+ // Resume notice when storage is non-fresh (operator brought it up
118
+ // before, has installed CLIs / auth state in crosstalk-root/). Heuristic:
119
+ // crosstalk-root has more than just a .gitkeep / nothing.
120
+ if (!generated) {
121
+ process.stdout.write(`Resuming '${name}' (existing state at ${paths.storageRoot})\n`);
211
122
  }
212
123
 
213
- const r = spawnSync('docker', ['compose', '-f', composeYml, 'up', '-d'], {
214
- cwd: root,
124
+ const r = spawnSync('docker', ['compose', '-f', paths.composeFile, 'up', '-d'], {
215
125
  stdio: 'inherit',
216
126
  });
217
127
  if (r.status !== 0) {
@@ -219,7 +129,7 @@ export async function run(argv) {
219
129
  return r.status ?? 1;
220
130
  }
221
131
  process.stdout.write(
222
- `\nEngine starting. Check status with 'crosstalk status' (give it ~5s to settle).\n`,
132
+ `\n'${name}' starting. Check with 'crosstalk status${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}' (give it ~5s).\n`,
223
133
  );
224
134
  return 0;
225
135
  }
@@ -7,14 +7,15 @@
7
7
  import { readFileSync } from 'fs';
8
8
  import { dirname, join } from 'path';
9
9
  import { fileURLToPath } from 'url';
10
- import { api, ConnectError } from '../lib/api-client.js';
10
+ import { apiFor, ConnectError } from '../lib/api-client.js';
11
11
 
12
12
  const thisDir = dirname(fileURLToPath(import.meta.url));
13
13
  const pkgPath = join(thisDir, '..', 'package.json');
14
14
  const clientPkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
15
15
 
16
- export async function run(_argv) {
16
+ export async function run(argv) {
17
17
  process.stdout.write(`crosstalk client: ${clientPkg.version}\n`);
18
+ const api = apiFor(argv);
18
19
  try {
19
20
  // Skew detection happens passively in api-client.js via the
20
21
  // X-Crosstalk-Engine-Version response header — no duplication here.
package/lib/api-client.js CHANGED
@@ -1,26 +1,27 @@
1
1
  // api-client.js — HTTP client over loopback to crosstalkd's local API.
2
2
  //
3
3
  // All operator commands route through this. The engine binds 127.0.0.1
4
- // inside the container; docker-compose maps that to the host loopback
5
- // at the same port. Client connects to http://127.0.0.1:<port>/<path>.
4
+ // inside the container; docker-compose maps that to the host loopback.
5
+ // The host port differs per container — default container 'crosstalk'
6
+ // gets 7000; named containers pick a free port at init time, recorded in
7
+ // `<base>/<name>/api-port`. apiPortFor() resolves name → port.
6
8
  //
7
- // Port resolution (in order):
8
- // 1. --port <n> CLI flag (handled by individual commands)
9
- // 2. CROSSTALK_API_PORT env var
10
- // 3. Default 7000
9
+ // Port resolution order:
10
+ // 1. opts.port (explicit override from caller)
11
+ // 2. CROSSTALK_API_PORT env var (test/dev override)
12
+ // 3. apiPortFor(name) — reads `<base>/<name>/api-port` file
13
+ // 4. DEFAULT_API_PORT (7000) as last resort
11
14
  //
12
- // No auth: the engine binds 127.0.0.1 only and there's no token. Same
13
- // model as ollama / postgres-on-localhost.
15
+ // No auth: engine binds 127.0.0.1 only, no token. Same model as ollama.
14
16
  //
15
- // Version skew: engine sets X-Crosstalk-Engine-Version on every
16
- // response. We compare it to the client's package.json version on each
17
- // call and emit one warning per process if they don't match. Pre-1.0
18
- // client and engine ship in lockstep; mismatches usually mean the
19
- // operator upgraded one without the other.
17
+ // Version skew: engine sets X-Crosstalk-Engine-Version on every response.
18
+ // We compare to client version on each call, emit one warning per process
19
+ // if mismatched. Pre-1.0 client and engine ship in lockstep.
20
20
 
21
21
  import { readFileSync } from 'fs';
22
22
  import { dirname, join } from 'path';
23
23
  import { fileURLToPath } from 'url';
24
+ import { apiPortFor, DEFAULT_API_PORT, parseContainerName } from './resolve.js';
24
25
 
25
26
  const CLIENT_VERSION = (() => {
26
27
  try {
@@ -44,12 +45,13 @@ function checkSkew(engineVersion) {
44
45
  );
45
46
  }
46
47
 
47
- export const DEFAULT_API_PORT = 7000;
48
+ export { DEFAULT_API_PORT };
48
49
 
49
- export function resolvePort(explicit) {
50
- if (typeof explicit === 'number' && Number.isInteger(explicit)) return explicit;
50
+ export function resolvePort(opts = {}) {
51
+ if (typeof opts.port === 'number' && Number.isInteger(opts.port)) return opts.port;
51
52
  const fromEnv = Number(process.env.CROSSTALK_API_PORT);
52
53
  if (Number.isInteger(fromEnv) && fromEnv > 0) return fromEnv;
54
+ if (opts.name) return apiPortFor(opts.name);
53
55
  return DEFAULT_API_PORT;
54
56
  }
55
57
 
@@ -91,7 +93,7 @@ export class ConnectError extends Error {
91
93
  }
92
94
 
93
95
  async function call(method, path, body, opts = {}) {
94
- const port = resolvePort(opts.port);
96
+ const port = resolvePort(opts);
95
97
  const init = {
96
98
  method,
97
99
  headers: body ? { 'Content-Type': 'application/json' } : undefined,
@@ -107,6 +109,23 @@ async function call(method, path, body, opts = {}) {
107
109
  return parseJsonOrThrow(res);
108
110
  }
109
111
 
112
+ // Bind an api client to a specific container name. All callers should
113
+ // either pass argv (auto-resolves name) or an explicit name. Internally
114
+ // each call resolves the port via the name's api-port file.
115
+ export function apiFor(argv) {
116
+ const name = parseContainerName(argv);
117
+ return {
118
+ name,
119
+ get: (path, opts = {}) => call('GET', path, undefined, { ...opts, name }),
120
+ post: (path, body, opts = {}) => call('POST', path, body, { ...opts, name }),
121
+ patch: (path, body, opts = {}) => call('PATCH', path, body, { ...opts, name }),
122
+ delete: (path, opts = {}) => call('DELETE', path, undefined, { ...opts, name }),
123
+ };
124
+ }
125
+
126
+ // Legacy entry point — env-driven port (CROSSTALK_API_PORT or 7000),
127
+ // no name resolution. Kept for harness compatibility; new code should
128
+ // use apiFor(argv) instead.
110
129
  export const api = {
111
130
  get: (path, opts) => call('GET', path, undefined, opts),
112
131
  post: (path, body, opts) => call('POST', path, body, opts),
package/lib/argv.js CHANGED
@@ -12,13 +12,15 @@ export function has(argv, name) {
12
12
  return argv.includes(name);
13
13
  }
14
14
 
15
- // All positional (non-flag) args, skipping flag values.
15
+ // All positional (non-flag) args, skipping flag values for the listed
16
+ // flags. Handles both --long and -short forms; flag names in
17
+ // flagsWithValue must match the form they appear in argv.
16
18
  export function positionals(argv, flagsWithValue) {
17
19
  const valueFlags = new Set(flagsWithValue);
18
20
  const out = [];
19
21
  for (let i = 0; i < argv.length; i++) {
20
22
  const a = argv[i];
21
- if (a.startsWith('--')) {
23
+ if (a.startsWith('-')) {
22
24
  if (valueFlags.has(a)) i++;
23
25
  continue;
24
26
  }