@cordfuse/crosstalk 6.0.0-alpha.9 → 7.0.0-alpha.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.
Files changed (46) hide show
  1. package/README.md +26 -0
  2. package/bin/crosstalk.js +60 -74
  3. package/commands/channel.js +69 -0
  4. package/commands/chat.js +159 -0
  5. package/commands/down.js +37 -0
  6. package/commands/init.js +105 -0
  7. package/commands/logs.js +39 -0
  8. package/commands/pull.js +24 -0
  9. package/commands/replies.js +39 -0
  10. package/commands/restart.js +24 -0
  11. package/commands/run.js +121 -0
  12. package/commands/status.js +52 -0
  13. package/commands/up.js +129 -0
  14. package/commands/version.js +30 -0
  15. package/lib/api-client.js +80 -0
  16. package/lib/argv.js +28 -0
  17. package/lib/errors.js +19 -0
  18. package/lib/transport.js +51 -0
  19. package/package.json +5 -21
  20. package/src/activation.ts +0 -104
  21. package/src/actor.ts +0 -131
  22. package/src/attach.ts +0 -118
  23. package/src/channel.ts +0 -49
  24. package/src/chat.ts +0 -142
  25. package/src/dispatch.ts +0 -531
  26. package/src/dlq.ts +0 -216
  27. package/src/filenames.ts +0 -28
  28. package/src/frontmatter.ts +0 -26
  29. package/src/init.ts +0 -138
  30. package/src/open.ts +0 -207
  31. package/src/replies.ts +0 -59
  32. package/src/send.ts +0 -122
  33. package/src/state.ts +0 -173
  34. package/src/status.ts +0 -75
  35. package/src/stop.ts +0 -37
  36. package/src/transport.ts +0 -213
  37. package/src/turnq.ts +0 -91
  38. package/src/upgrade.ts +0 -211
  39. package/src/wake.ts +0 -7
  40. package/template/CLAUDE.md +0 -12
  41. package/template/gitignore +0 -4
  42. package/template/upstream/CROSSTALK-VERSION +0 -1
  43. package/template/upstream/CROSSTALK.md +0 -298
  44. package/template/upstream/OPERATOR.md +0 -60
  45. package/template/upstream/PROTOCOL.md +0 -80
  46. package/template/upstream/actors/concierge.md +0 -36
package/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # @cordfuse/crosstalk — host client
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.
4
+
5
+ The host-side CLI for the Crosstalk protocol. Pairs with `@cordfuse/crosstalkd`, the daemon that runs inside the `crosstalk-server` container.
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`
10
+
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.
12
+
13
+ ## Status (v7.0.0-dev.0)
14
+
15
+ - `crosstalk version` — works
16
+ - All other subcommands — landing in P4–P6
17
+
18
+ ## Install (post-publish; today this package is unpublished)
19
+
20
+ ```sh
21
+ npm install -g @cordfuse/crosstalk
22
+ ```
23
+
24
+ ## License
25
+
26
+ MIT.
package/bin/crosstalk.js CHANGED
@@ -1,97 +1,83 @@
1
1
  #!/usr/bin/env node
2
- // crosstalk — subcommand dispatcher
2
+ // crosstalk — host-side client for the crosstalkd daemon.
3
3
  //
4
- // Walks up from cwd to find a Crosstalk transport (identified by
5
- // upstream/CROSSTALK-VERSION), then runs the requested subcommand from the
6
- // runtime's installed source via tsx. All args after the subcommand are
7
- // forwarded. `init` is the one subcommand that runs outside a transport
8
- // (it creates one).
4
+ // Thin subcommand dispatcher. Routes operator commands to either:
5
+ // - the engine's HTTP API on 127.0.0.1:<port> (protocol commands)
6
+ // - the local docker daemon (lifecycle: up, down, restart, pull, logs)
7
+ // - `docker exec -it <container>` via a PTY wrapper (chat, shell)
8
+ //
9
+ // Subcommands and their groups:
10
+ //
11
+ // Protocol (HTTP API): run, replies, status, channel, init
12
+ // Lifecycle (docker): up, down, restart, pull, logs
13
+ // Interactive (pty): chat, shell
14
+ // Observability: version
15
+ //
16
+ // P3 only ships `version` as a proof-of-life. Other subcommands land
17
+ // in P4 (protocol), P5 (lifecycle), P6 (interactive).
9
18
 
10
- import { existsSync, statSync } from 'fs';
11
- import { resolve, join, dirname } from 'path';
12
- import { spawnSync, spawn } from 'child_process';
19
+ import { dirname, join } from 'path';
13
20
  import { fileURLToPath } from 'url';
14
- import { createRequire } from 'module';
21
+ import { existsSync } from 'fs';
15
22
 
16
- const SUBCOMMANDS = [
17
- 'dispatch', 'stop', 'send', 'replies', 'wake', 'status', 'init', 'dlq', 'channel',
18
- 'chat', 'open', 'attach', 'upgrade',
19
- ];
20
- const STANDALONE_SUBCOMMANDS = new Set(['init']);
23
+ const SUBCOMMANDS = {
24
+ // each entry is the file in commands/ to load
25
+ version: 'version.js',
26
+ run: 'run.js',
27
+ replies: 'replies.js',
28
+ status: 'status.js',
29
+ channel: 'channel.js',
30
+ init: 'init.js',
31
+ up: 'up.js',
32
+ down: 'down.js',
33
+ restart: 'restart.js',
34
+ pull: 'pull.js',
35
+ logs: 'logs.js',
36
+ chat: 'chat.js',
37
+ };
21
38
 
22
- function findTransportRoot(startDir) {
23
- let dir = resolve(startDir);
24
- while (true) {
25
- const versionFile = join(dir, 'upstream', 'CROSSTALK-VERSION');
26
- if (existsSync(versionFile) && statSync(versionFile).isFile()) return dir;
27
- const parent = dirname(dir);
28
- if (parent === dir) return null;
29
- dir = parent;
30
- }
31
- }
39
+ const argv = process.argv.slice(2);
40
+ const thisDir = dirname(fileURLToPath(import.meta.url));
32
41
 
33
42
  function printUsage(exitCode = 0) {
34
43
  process.stdout.write(
35
- `Usage: crosstalk <subcommand> [args...]\n\nSubcommands:\n` +
36
- SUBCOMMANDS.map((s) => ` ${s}`).join('\n') +
37
- `\n\nMost subcommands require you to be inside a Crosstalk transport\n` +
38
- `(a directory containing upstream/CROSSTALK-VERSION). 'init' can run\n` +
39
- `from anywhere to scaffold a new transport.\n`,
44
+ `Usage: crosstalk <subcommand> [args...]
45
+
46
+ The host-side client for crosstalkd. Most commands talk to the engine's
47
+ HTTP API on 127.0.0.1; lifecycle commands talk to the local docker daemon.
48
+
49
+ Subcommands:
50
+ ${Object.keys(SUBCOMMANDS).map((s) => ` ${s}`).join('\n')}
51
+
52
+ If your engine listens on a non-default port, set CROSSTALK_API_PORT.
53
+
54
+ v7.0.0-dev — many subcommands are not implemented yet (see bin/crosstalk.js).
55
+ `,
40
56
  );
41
57
  process.exit(exitCode);
42
58
  }
43
59
 
44
- const argv = process.argv.slice(2);
45
60
  if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') printUsage(0);
46
61
 
47
- if (argv[0] === '--version' || argv[0] === 'version') {
48
- const require = createRequire(import.meta.url);
49
- const { version } = require('../package.json');
50
- process.stdout.write(`${version}\n`);
51
- process.exit(0);
62
+ if (argv[0] === '--version') {
63
+ // delegate to the version command so the engine-side number can also
64
+ // be queried in a uniform place
65
+ argv[0] = 'version';
52
66
  }
53
67
 
54
68
  const cmd = argv[0];
55
- if (!SUBCOMMANDS.includes(cmd)) {
56
- console.error(`crosstalk: unknown subcommand '${cmd}'\n`);
69
+ const file = SUBCOMMANDS[cmd];
70
+ if (!file) {
71
+ process.stderr.write(`crosstalk: unknown subcommand '${cmd}'\n\n`);
57
72
  printUsage(1);
58
73
  }
59
74
 
60
- const thisDir = dirname(fileURLToPath(import.meta.url));
61
- const srcFile = join(thisDir, '..', 'src', `${cmd}.ts`);
62
-
63
- let cwd = process.cwd();
64
- if (!STANDALONE_SUBCOMMANDS.has(cmd)) {
65
- const root = findTransportRoot(cwd);
66
- if (!root) {
67
- console.error(
68
- `crosstalk ${cmd}: not inside a Crosstalk transport ` +
69
- `(no upstream/CROSSTALK-VERSION found from ${cwd} upward).`,
70
- );
71
- process.exit(2);
72
- }
73
- cwd = root;
75
+ const modulePath = join(thisDir, '..', 'commands', file);
76
+ if (!existsSync(modulePath)) {
77
+ process.stderr.write(`crosstalk: command file missing for '${cmd}' (expected ${modulePath})\n`);
78
+ process.exit(2);
74
79
  }
75
80
 
76
- const require = createRequire(import.meta.url);
77
- const tsxCli = require.resolve('tsx/cli');
78
-
79
- // dispatch is long-running: use async spawn so SIGTERM/SIGINT forwarded to
80
- // the tsx child kills the whole chain cleanly. All other subcommands are
81
- // short-lived and spawnSync is fine.
82
- if (cmd === 'dispatch') {
83
- const child = spawn(process.execPath, [tsxCli, srcFile, ...argv.slice(1)], {
84
- cwd,
85
- stdio: 'inherit',
86
- });
87
- const forward = (sig) => { try { child.kill(sig); } catch {} };
88
- process.on('SIGTERM', () => forward('SIGTERM'));
89
- process.on('SIGINT', () => forward('SIGTERM'));
90
- child.on('exit', (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
91
- } else {
92
- const r = spawnSync(process.execPath, [tsxCli, srcFile, ...argv.slice(1)], {
93
- cwd,
94
- stdio: 'inherit',
95
- });
96
- process.exit(r.status ?? 1);
97
- }
81
+ const mod = await import(modulePath);
82
+ const exit = await mod.run(argv.slice(1));
83
+ process.exit(typeof exit === 'number' ? exit : 0);
@@ -0,0 +1,69 @@
1
+ // crosstalk channel <name> — create a channel via POST /channels.
2
+ //
3
+ // P4 only ships create. --rename and --delete need PATCH/DELETE
4
+ // endpoints that haven't been added to the engine yet; they land
5
+ // when the engine gains them.
6
+
7
+ import { api } from '../lib/api-client.js';
8
+ import { reportAndExit } from '../lib/errors.js';
9
+ import { positionals, has } from '../lib/argv.js';
10
+
11
+ function usage(exit = 0) {
12
+ const w = exit === 0 ? process.stdout : process.stderr;
13
+ w.write(
14
+ `Usage:
15
+ crosstalk channel <name> # create a channel
16
+ crosstalk channel list # list channels
17
+ 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
+ `,
23
+ );
24
+ process.exit(exit);
25
+ }
26
+
27
+ export async function run(argv) {
28
+ if (has(argv, '--help') || has(argv, '-h')) usage(0);
29
+
30
+ const pos = positionals(argv, []);
31
+ if (pos.length === 0) usage(1);
32
+
33
+ // `crosstalk channel list` → list channels
34
+ if (pos[0] === 'list') {
35
+ let r;
36
+ try {
37
+ r = await api.get('/channels');
38
+ } catch (err) {
39
+ reportAndExit(err, 'crosstalk channel list');
40
+ }
41
+ if (r.channels.length === 0) {
42
+ process.stdout.write('(no channels)\n');
43
+ return 0;
44
+ }
45
+ for (const ch of r.channels) {
46
+ const name = ch.name ?? '(unnamed)';
47
+ const parentSuffix = ch.parent ? ` [child of ${ch.parent.slice(0, 8)}]` : '';
48
+ process.stdout.write(`${ch.uuid} ${name}${parentSuffix}\n`);
49
+ }
50
+ return 0;
51
+ }
52
+
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');
56
+ return 1;
57
+ }
58
+
59
+ const name = pos[0];
60
+ let r;
61
+ try {
62
+ r = await api.post('/channels', { name });
63
+ } catch (err) {
64
+ reportAndExit(err, 'crosstalk channel');
65
+ }
66
+ process.stdout.write(`Created channel: ${r.uuid}\n`);
67
+ process.stdout.write(` name: ${r.name}\n`);
68
+ return 0;
69
+ }
@@ -0,0 +1,159 @@
1
+ // crosstalk chat — the single interactive entry point into the container.
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)
8
+ //
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.
16
+ //
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.
21
+
22
+ import { spawn, spawnSync } from 'child_process';
23
+ import { requireTransportRoot, containerName } from '../lib/transport.js';
24
+ import { flag, has } from '../lib/argv.js';
25
+
26
+ 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
+
46
+ function usage(exit = 0) {
47
+ const w = exit === 0 ? process.stdout : process.stderr;
48
+ w.write(
49
+ `Usage: crosstalk chat [--agent <name>] [--login] [--shell]
50
+
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).
54
+
55
+ 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).
66
+
67
+ Examples:
68
+ crosstalk chat # interactive claude
69
+ crosstalk chat --agent gemini # interactive gemini
70
+ crosstalk chat --login # OAuth, claude
71
+ crosstalk chat --login --agent codex # OAuth, codex
72
+ crosstalk chat --shell # bash
73
+ `,
74
+ );
75
+ process.exit(exit);
76
+ }
77
+
78
+ function runInteractive(container, command, env = {}) {
79
+ // Full TTY passthrough — docker exec -it gives the container a real
80
+ // PTY, and stdio: 'inherit' forwards stdin/stdout/stderr from the
81
+ // operator's terminal. Agent CLIs get to render their rich TUI
82
+ // normally.
83
+ const envFlags = [];
84
+ for (const [k, v] of Object.entries(env)) {
85
+ envFlags.push('--env', `${k}=${v}`);
86
+ }
87
+ const r = spawnSync('docker', ['exec', '-it', ...envFlags, container, command], {
88
+ stdio: 'inherit',
89
+ });
90
+ return r.status ?? 1;
91
+ }
92
+
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
+ export async function run(argv) {
137
+ if (has(argv, '--help') || has(argv, '-h')) usage(0);
138
+
139
+ const root = requireTransportRoot();
140
+ const name = containerName(root);
141
+
142
+ if (has(argv, '--shell')) {
143
+ return runInteractive(name, 'bash');
144
+ }
145
+
146
+ const agent = flag(argv, '--agent') ?? 'claude';
147
+ if (!KNOWN_AGENTS.includes(agent)) {
148
+ process.stderr.write(
149
+ `crosstalk chat: unknown agent '${agent}'. Supported: ${KNOWN_AGENTS.join(', ')}\n`,
150
+ );
151
+ return 1;
152
+ }
153
+
154
+ if (has(argv, '--login')) {
155
+ return await runLogin(name, agent);
156
+ }
157
+
158
+ return runInteractive(name, agent);
159
+ }
@@ -0,0 +1,37 @@
1
+ // crosstalk down — stop the engine container for this transport.
2
+
3
+ import { existsSync } from 'fs';
4
+ import { spawnSync } from 'child_process';
5
+ import { requireTransportRoot, composeFile } from '../lib/transport.js';
6
+ import { has } from '../lib/argv.js';
7
+
8
+ function usage(exit = 0) {
9
+ const w = exit === 0 ? process.stdout : process.stderr;
10
+ w.write(
11
+ `Usage: crosstalk down [--volumes]
12
+
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).
16
+ `,
17
+ );
18
+ process.exit(exit);
19
+ }
20
+
21
+ export async function run(argv) {
22
+ if (has(argv, '--help') || has(argv, '-h')) usage(0);
23
+
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`);
29
+ return 0;
30
+ }
31
+
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
+ return r.status ?? 1;
37
+ }
@@ -0,0 +1,105 @@
1
+ // crosstalk init <dir> — scaffold a new transport via docker.
2
+ //
3
+ // Doesn't go through the engine HTTP API — there's no running engine
4
+ // at init time. Instead, runs the engine in a one-shot container with
5
+ // a bind-mount of the target directory, and lets crosstalkd's init
6
+ // subcommand write the template files directly.
7
+ //
8
+ // docker run --rm \
9
+ // -v "$(realpath <dir>):/init-target" \
10
+ // --user "$(id -u):$(id -g)" \
11
+ // <image> \
12
+ // crosstalkd init /init-target
13
+ //
14
+ // The --user flag is critical: without it, the template files would
15
+ // land owned by root (the container's user). Setting it to the
16
+ // operator's UID/GID keeps file ownership sane.
17
+
18
+ import { mkdirSync, existsSync } from 'fs';
19
+ import { resolve } from 'path';
20
+ import { spawnSync } from 'child_process';
21
+ import { has, positionals } from '../lib/argv.js';
22
+
23
+ const DEFAULT_IMAGE = process.env.CROSSTALK_IMAGE
24
+ ?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.1';
25
+
26
+ function usage(exit = 0) {
27
+ const w = exit === 0 ? process.stdout : process.stderr;
28
+ w.write(
29
+ `Usage:
30
+ crosstalk init <dir> # scaffold a new transport at <dir>
31
+
32
+ Image: ${DEFAULT_IMAGE}
33
+ Override: CROSSTALK_IMAGE=<image-tag> crosstalk init <dir>
34
+
35
+ The image must be pullable (or already present locally). For local dev
36
+ before the GHCR publish pipeline ships, build the image first:
37
+ docker build -t crosstalk-server:7.0.0-alpha.1 -f server/Dockerfile .
38
+ Then run:
39
+ CROSSTALK_IMAGE=crosstalk-server:7.0.0-alpha.1 crosstalk init mytransport
40
+ `,
41
+ );
42
+ process.exit(exit);
43
+ }
44
+
45
+ export async function run(argv) {
46
+ if (has(argv, '--help') || has(argv, '-h')) usage(0);
47
+ const pos = positionals(argv, []);
48
+ if (pos.length === 0) usage(1);
49
+
50
+ const target = resolve(pos[0]);
51
+ if (!existsSync(target)) mkdirSync(target, { recursive: true });
52
+
53
+ // Resolve uid/gid via process.getuid() — POSIX only. On Windows the
54
+ // --user flag is omitted; Docker Desktop handles ownership differently.
55
+ const uid = typeof process.getuid === 'function' ? process.getuid() : null;
56
+ const gid = typeof process.getgid === 'function' ? process.getgid() : null;
57
+ const userArgs = (uid != null && gid != null) ? ['--user', `${uid}:${gid}`] : [];
58
+
59
+ // --entrypoint crosstalkd overrides the image's default ENTRYPOINT
60
+ // (which is the dispatcher entrypoint.sh requiring TRANSPORT_GIT_URL).
61
+ // For init we want to call crosstalkd directly with no env requirements.
62
+ const args = [
63
+ 'run', '--rm',
64
+ '-v', `${target}:/init-target`,
65
+ ...userArgs,
66
+ '--entrypoint', 'crosstalkd',
67
+ DEFAULT_IMAGE,
68
+ 'init', '/init-target',
69
+ ];
70
+
71
+ const r = spawnSync('docker', args, { stdio: 'inherit' });
72
+ if (r.status !== 0) {
73
+ process.stderr.write(`crosstalk init: docker run exited ${r.status}\n`);
74
+ process.stderr.write(` command was: docker ${args.join(' ')}\n`);
75
+ return r.status ?? 1;
76
+ }
77
+
78
+ // Auto git init + initial commit so the transport is ready for
79
+ // `crosstalk up` immediately. Engine expects a git repo (cursor +
80
+ // commit operations on every tick). Doing this on the host so the
81
+ // commit author uses the operator's local git config — not the
82
+ // container's default.
83
+ //
84
+ // Best-effort: if any git step fails, print a warning but don't fail
85
+ // the init (operator can complete the steps manually).
86
+ const gitSteps = [
87
+ ['init', '--quiet', '--initial-branch=main'],
88
+ ['add', '-A'],
89
+ ['commit', '--quiet', '-m', 'initial transport'],
90
+ ];
91
+ for (const args of gitSteps) {
92
+ const gr = spawnSync('git', args, { cwd: target, stdio: 'pipe' });
93
+ if (gr.status !== 0) {
94
+ const stderr = gr.stderr?.toString().trim() ?? '';
95
+ process.stderr.write(
96
+ `crosstalk init: \`git ${args.join(' ')}\` failed (transport scaffolded but not committed)\n`,
97
+ );
98
+ if (stderr) process.stderr.write(` ${stderr.split('\n').join('\n ')}\n`);
99
+ process.stderr.write(` Finish manually: cd ${target} && git init && git add -A && git commit -m "initial transport"\n`);
100
+ return 0; // not fatal — scaffold succeeded
101
+ }
102
+ }
103
+ process.stdout.write(`\nGit initialized; transport is ready for \`crosstalk up\`.\n`);
104
+ return 0;
105
+ }
@@ -0,0 +1,39 @@
1
+ // crosstalk logs — tail the engine container's logs.
2
+
3
+ import { existsSync } from 'fs';
4
+ import { spawnSync } from 'child_process';
5
+ import { requireTransportRoot, composeFile } from '../lib/transport.js';
6
+ import { has } from '../lib/argv.js';
7
+
8
+ function usage(exit = 0) {
9
+ const w = exit === 0 ? process.stdout : process.stderr;
10
+ w.write(
11
+ `Usage: crosstalk logs [-f|--follow] [-n <lines>]
12
+
13
+ Streams the engine container's stdout/stderr. -f to follow (Ctrl-C to stop).
14
+ `,
15
+ );
16
+ process.exit(exit);
17
+ }
18
+
19
+ export async function run(argv) {
20
+ if (has(argv, '--help') || has(argv, '-h')) usage(0);
21
+
22
+ const root = requireTransportRoot();
23
+ const composeYml = composeFile(root);
24
+ if (!existsSync(composeYml)) {
25
+ process.stderr.write(`crosstalk logs: no docker-compose.yml — run 'crosstalk up' first.\n`);
26
+ return 1;
27
+ }
28
+ const args = ['compose', '-f', composeYml, 'logs'];
29
+ if (has(argv, '-f') || has(argv, '--follow')) args.push('--follow');
30
+ // Pass through tail count if provided.
31
+ for (let i = 0; i < argv.length; i++) {
32
+ if (argv[i] === '-n' || argv[i] === '--tail') {
33
+ args.push('--tail', argv[i + 1] ?? '100');
34
+ i++;
35
+ }
36
+ }
37
+ const r = spawnSync('docker', args, { cwd: root, stdio: 'inherit' });
38
+ return r.status ?? 1;
39
+ }
@@ -0,0 +1,24 @@
1
+ // crosstalk pull — refresh the engine image.
2
+
3
+ import { existsSync } from 'fs';
4
+ import { spawnSync } from 'child_process';
5
+ import { requireTransportRoot, composeFile } from '../lib/transport.js';
6
+ import { has } from '../lib/argv.js';
7
+
8
+ export async function run(argv) {
9
+ if (has(argv, '--help') || has(argv, '-h')) {
10
+ process.stdout.write('Usage: crosstalk pull\n');
11
+ return 0;
12
+ }
13
+ const root = requireTransportRoot();
14
+ const composeYml = composeFile(root);
15
+ if (!existsSync(composeYml)) {
16
+ process.stderr.write(`crosstalk pull: no docker-compose.yml — run 'crosstalk up' first.\n`);
17
+ return 1;
18
+ }
19
+ const r = spawnSync('docker', ['compose', '-f', composeYml, 'pull'], {
20
+ cwd: root,
21
+ stdio: 'inherit',
22
+ });
23
+ return r.status ?? 1;
24
+ }
@@ -0,0 +1,39 @@
1
+ // crosstalk replies <relPath> [<relPath>...] — poll reply status.
2
+ //
3
+ // Exit 0 if all targets have REPLIED or FAILED, exit 2 while any PENDING
4
+ // — agents can poll cheaply in a loop. Mirrors crosstalkd replies.
5
+
6
+ import { api } from '../lib/api-client.js';
7
+ import { reportAndExit } from '../lib/errors.js';
8
+ import { positionals, has } from '../lib/argv.js';
9
+
10
+ function usage(exit = 0) {
11
+ const w = exit === 0 ? process.stdout : process.stderr;
12
+ w.write('Usage: crosstalk replies <relPath> [<relPath>...]\n');
13
+ process.exit(exit);
14
+ }
15
+
16
+ export async function run(argv) {
17
+ if (has(argv, '--help') || has(argv, '-h')) usage(0);
18
+ const targets = positionals(argv, []);
19
+ if (targets.length === 0) usage(1);
20
+
21
+ let resp;
22
+ try {
23
+ resp = await api.get(`/replies?relPaths=${encodeURIComponent(targets.join(','))}`);
24
+ } catch (err) {
25
+ reportAndExit(err, 'crosstalk replies');
26
+ }
27
+
28
+ let pending = 0;
29
+ for (const r of resp.replies) {
30
+ if (r.status === 'PENDING') {
31
+ process.stdout.write(`PENDING ${r.target}\n`);
32
+ pending++;
33
+ } else {
34
+ const tag = r.status === 'FAILED' ? 'FAILED ' : 'REPLIED ';
35
+ process.stdout.write(`${tag} ${r.target} <- ${r.from} (${r.replyRelPath})\n`);
36
+ }
37
+ }
38
+ return pending > 0 ? 2 : 0;
39
+ }