@cordfuse/crosstalk 7.0.0-alpha.7 → 7.0.0-alpha.9

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/bin/crosstalk.js CHANGED
@@ -31,7 +31,6 @@ const SUBCOMMANDS = {
31
31
  up: 'up.js',
32
32
  down: 'down.js',
33
33
  rm: 'rm.js',
34
- auth: 'auth.js',
35
34
  restart: 'restart.js',
36
35
  pull: 'pull.js',
37
36
  logs: 'logs.js',
package/commands/chat.js CHANGED
@@ -1,110 +1,33 @@
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 --agent <name> # interactive with a specific agent
5
- // crosstalk chat --agent <name> --login # OAuth setup flow with browser-open
6
- // 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)
7
6
  //
8
- // --agent is required for non-shell modes. There's no implicit default
9
- // (containers may have multiple agent CLIs installed; the operator
10
- // must say which one to invoke).
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.
11
10
  //
12
- // Login flow design (root-cause fix, not a bandaid):
13
- // The container has no DISPLAY / no browser / no xdg-open. So the agent
14
- // CLI prints a URL and expects the operator to manually open it. THIS
15
- // wrapper runs on the HOST where the browser lives. It intercepts the
16
- // URL on the agent's stdout and opens it directly via the OS-native
17
- // browser launcher (`open` / `xdg-open` / `wslview` / `start`). Operator
18
- // never has to read or copy-paste the URL.
19
- //
20
- // COLUMNS=1000 is still set under --login as a contingency: if browser
21
- // open fails (headless SSH session, missing xdg-open, etc.), the URL
22
- // still renders unwrapped in the terminal for manual copy. That's the
23
- // 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.
24
16
 
25
- import { spawn, spawnSync } from 'child_process';
17
+ import { spawnSync } from 'child_process';
26
18
  import { requireInitialized } from '../lib/resolve.js';
27
19
  import { apiFor, ConnectError } from '../lib/api-client.js';
28
20
  import { flag, has } from '../lib/argv.js';
29
21
 
30
22
  const KNOWN_AGENTS = ['claude', 'codex', 'gemini', 'qwen', 'opencode', 'agy'];
31
23
 
32
- // URL extraction that survives agent TUI rendering. Naive regex against
33
- // raw stdout fails because agent CLIs (Claude in particular) draw a box
34
- // around the URL with line wraps every ~N chars; the captured URL is
35
- // truncated at the first wrap, with ANSI control chars leaking in at
36
- // the cut point. Real example from alpha.3 macOS test: extracted URL
37
- // ended with `%18%6b…` (CAN char + box-drawing artifact) instead of
38
- // the `redirect_uri=…` param, breaking OAuth.
39
- //
40
- // Approach:
41
- // 1. ANSI-strip everything (CSI / OSC / SS3 escapes).
42
- // 2. Strip control chars (0x00-0x1F except LF, plus DEL).
43
- // 3. Join line wraps that occur INSIDE a URL-safe character run —
44
- // `<url-char>\n<url-char>` → `<url-char><url-char>`. Preserves real
45
- // `<url-char>\n<non-url-char>` boundaries.
46
- // 4. Match `https?://<url-chars>+` against the joined buffer.
47
- // 5. Only fire when the character AFTER the matched URL is a true
48
- // terminator (whitespace, end-of-buffer) — guards against acting
49
- // on a partial URL still being written.
50
-
51
- const URL_SAFE_CLASS = "a-zA-Z0-9%/?#&=+\\-._~:@!$()*,;";
52
- const URL_RE = new RegExp(`https?://[${URL_SAFE_CLASS}]+`);
53
-
54
- function extractCompleteUrl(rawBuffer) {
55
- // Strip ANSI CSI escapes (\x1B[ ... terminator).
56
- let clean = rawBuffer.replace(/\x1B\[[0-9;?]*[a-zA-Z]/g, '');
57
- // Strip ANSI OSC escapes (\x1B] ... BEL or ST).
58
- clean = clean.replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)/g, '');
59
- // Strip SS3 escapes (\x1BO + one char).
60
- clean = clean.replace(/\x1BO./g, '');
61
- // Strip remaining single-char escapes that slipped through.
62
- clean = clean.replace(/\x1B[^a-zA-Z0-9]/g, '');
63
- // Strip control chars except LF (0x0A); we use LF for line-wrap rejoin.
64
- clean = clean.replace(/[\x00-\x09\x0B-\x1F\x7F]/g, '');
65
- // Rejoin URL-character wrap points: `<url-char>\n<url-char>` becomes
66
- // `<url-char><url-char>`. Doesn't merge `<url-char>\n<space>` etc.
67
- const safeRe = new RegExp(`[${URL_SAFE_CLASS}]\\n[${URL_SAFE_CLASS}]`, 'g');
68
- let prev;
69
- do {
70
- prev = clean;
71
- clean = clean.replace(safeRe, (m) => m[0] + m[2]);
72
- } while (clean !== prev);
73
-
74
- const match = clean.match(URL_RE);
75
- if (!match) return null;
76
- // Require a terminator after the URL — without it, the URL may still
77
- // be growing in subsequent stdout chunks. Terminator = whitespace,
78
- // quote/angle, or end-of-buffer-with-trailing-newline.
79
- const after = clean.slice(match.index + match[0].length);
80
- if (after.length === 0) return null; // EOF-as-grow case, wait
81
- if (!/^[\s<>"']/.test(after)) return null; // URL still continuing
82
- return match[0];
83
- }
84
-
85
- function openInBrowser(url) {
86
- const cmd =
87
- process.platform === 'darwin' ? 'open' :
88
- process.platform === 'win32' ? 'start' :
89
- process.env['WSL_DISTRO_NAME'] ? 'wslview' :
90
- /* default Linux */ 'xdg-open';
91
- try {
92
- const r = spawnSync(cmd, [url], { stdio: 'ignore' });
93
- return r.status === 0;
94
- } catch {
95
- return false;
96
- }
97
- }
98
-
99
24
  function usage(exit = 0) {
100
25
  const w = exit === 0 ? process.stdout : process.stderr;
101
26
  w.write(
102
- `Usage: crosstalk chat --agent <name> [--login]
27
+ `Usage: crosstalk chat --agent <name>
103
28
  crosstalk chat --shell
104
29
 
105
- Opens an interactive session inside the engine container — the single
106
- PTY-wrapped entry point for everything you'd do inside the container
107
- (daily chat, OAuth setup, sysadmin).
30
+ Opens an interactive session inside the engine container.
108
31
 
109
32
  --agent is REQUIRED for any chat mode. Containers can have multiple
110
33
  agent CLIs installed; the operator must say which one to invoke.
@@ -112,42 +35,27 @@ agent CLIs installed; the operator must say which one to invoke.
112
35
  Supported agents: ${KNOWN_AGENTS.join(', ')}.
113
36
 
114
37
  Modes:
115
- --agent <name> Interactive with the named agent.
116
- --agent <name> --login First-time auth flow. Intercepts the OAuth
117
- URL on the agent's stdout and opens it in your
118
- default browser via the OS-native launcher.
119
- Falls back to printing the URL unwrapped if no
120
- browser is reachable (headless / SSH session).
121
- --shell Drop into bash (install agent CLIs, debug,
122
- inspect state). No --agent needed.
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\`).
123
44
 
124
45
  Examples:
125
46
  crosstalk chat --agent claude # interactive claude
126
47
  crosstalk chat --agent gemini # interactive gemini
127
- crosstalk chat --agent claude --login # OAuth, claude
128
- crosstalk chat --agent codex --login # OAuth, codex
129
48
  crosstalk chat --shell # bash
130
49
  `,
131
50
  );
132
51
  process.exit(exit);
133
52
  }
134
53
 
135
- // Detect host terminal dims with sane fallbacks. Pass into the
136
- // container via -e COLUMNS/LINES so the agent CLI renders at the
137
- // operator's real terminal size instead of docker's 80×24 fallback.
138
- // (When stdout is piped — like in --login — docker can't auto-detect
139
- // the host's stdout terminal, which is mac's alpha.6 bug D.)
140
- function hostTermDims() {
141
- const cols = process.stdout.columns || process.stderr.columns || 120;
142
- const rows = process.stdout.rows || process.stderr.rows || 40;
143
- return { cols, rows };
144
- }
145
-
146
54
  function runInteractive(container, command, env = {}) {
147
55
  // Full TTY passthrough — docker exec -it gives the container a real
148
56
  // PTY, and stdio: 'inherit' forwards stdin/stdout/stderr from the
149
- // operator's terminal. Agent CLIs get to render their rich TUI
150
- // 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.
151
59
  const envFlags = [];
152
60
  for (const [k, v] of Object.entries(env)) {
153
61
  envFlags.push('--env', `${k}=${v}`);
@@ -158,106 +66,6 @@ function runInteractive(container, command, env = {}) {
158
66
  return r.status ?? 1;
159
67
  }
160
68
 
161
- // Per-agent login command. Returns null for agents without an OAuth
162
- // path (gemini, qwen, opencode) — these need env-var auth, not browser
163
- // OAuth. The full audit (cachy 2026-06-14) confirmed each agent's
164
- // surface:
165
- // claude → `claude auth login` (clean single-line URL output)
166
- // codex → `codex login` (clean single-line URL output)
167
- // agy → `agy --print init` (triggers OAuth, clean URL)
168
- // We use the explicit auth subcommand instead of bare invocation —
169
- // that's mac's bug D fix root cause. Bare `claude` drops into a TUI
170
- // with line wraps + winsize sensitivity that broke URL extraction
171
- // across alpha.3 → alpha.6. The official auth subcommand prints a
172
- // plain single-line URL on stdout, no TUI involved.
173
- function loginCommand(agent) {
174
- if (agent === 'claude') return ['claude', 'auth', 'login'];
175
- if (agent === 'codex') return ['codex', 'login'];
176
- if (agent === 'agy') return ['agy', '--print', 'init'];
177
- return null; // gemini, qwen, opencode — env-based
178
- }
179
-
180
- // Per-agent env-var hint for the no-OAuth agents.
181
- function envHint(agent) {
182
- if (agent === 'gemini') return 'GEMINI_API_KEY';
183
- if (agent === 'qwen') return 'OPENAI_API_KEY (qwen --auth-type openai)';
184
- if (agent === 'opencode') return 'per-provider keys in opencode config';
185
- return null;
186
- }
187
-
188
- function runLogin(container, agent) {
189
- const cmd = loginCommand(agent);
190
- if (!cmd) {
191
- const env = envHint(agent);
192
- process.stderr.write(
193
- `crosstalk chat --login --agent ${agent}: this agent has no browser OAuth flow.\n` +
194
- (env ? ` Use environment-based auth instead:\n crosstalk auth paste --agent ${agent}\n (or set ${env} directly in docker-compose.yml)\n` : '') +
195
- ` Documented in GUIDE-CLI.md § Agent auth.\n`,
196
- );
197
- return Promise.resolve(1);
198
- }
199
-
200
- // Pass host terminal dims explicitly. Docker exec -t normally sizes
201
- // the container PTY off its stdout terminal, but we PIPE stdout (to
202
- // scrape the OAuth URL), so docker has no TTY to size from and falls
203
- // back to 80×24 — that's mac's bug D root cause. Setting COLUMNS/LINES
204
- // env vars gets the agent CLI to render at the right size from the
205
- // start. (Live SIGWINCH updates aren't propagated — docker env is
206
- // set-at-spawn — but OAuth flows are short; operators don't typically
207
- // resize the terminal mid-auth.)
208
- const { cols, rows } = hostTermDims();
209
-
210
- return new Promise((resolve) => {
211
- const child = spawn(
212
- 'docker',
213
- ['exec', '-it',
214
- '-e', `COLUMNS=${cols}`,
215
- '-e', `LINES=${rows}`,
216
- '-e', 'TERM=xterm-256color',
217
- container, ...cmd],
218
- { stdio: ['inherit', 'pipe', 'inherit'] },
219
- );
220
-
221
- // SIGWINCH handler: docker env is set-at-spawn so we can't dynamically
222
- // resize the in-container PTY without a control channel (would need
223
- // node-pty or a sidecar process). Best-effort: surface that a resize
224
- // happened so the operator knows why the TUI may not have responded.
225
- const onWinch = () => {
226
- const { cols: c, rows: r } = hostTermDims();
227
- process.stderr.write(
228
- `\n[crosstalk chat] Terminal resized to ${c}×${r} — in-container PTY won't update mid-session. ` +
229
- `Exit (Ctrl-C) and retry if rendering is off.\n`,
230
- );
231
- };
232
- process.on('SIGWINCH', onWinch);
233
-
234
- let opened = false;
235
- let buffer = '';
236
- child.stdout.on('data', (chunk) => {
237
- process.stdout.write(chunk);
238
- if (opened) return;
239
- buffer += chunk.toString();
240
- const url = extractCompleteUrl(buffer);
241
- if (!url) return;
242
- opened = true;
243
- const ok = openInBrowser(url);
244
- process.stdout.write(
245
- ok
246
- ? `\n[crosstalk chat] Opened auth URL in your default browser.\n`
247
- : `\n[crosstalk chat] Could not auto-open browser. Open this URL manually:\n ${url}\n`,
248
- );
249
- });
250
- child.on('exit', (code) => {
251
- process.removeListener('SIGWINCH', onWinch);
252
- resolve(code ?? 0);
253
- });
254
- child.on('error', () => {
255
- process.removeListener('SIGWINCH', onWinch);
256
- resolve(1);
257
- });
258
- });
259
- }
260
-
261
69
  export async function run(argv) {
262
70
  if (has(argv, '--help') || has(argv, '-h')) usage(0);
263
71
 
@@ -307,9 +115,5 @@ export async function run(argv) {
307
115
  return 1;
308
116
  }
309
117
 
310
- if (has(argv, '--login')) {
311
- return await runLogin(name, agent);
312
- }
313
-
314
118
  return runInteractive(name, agent);
315
119
  }
package/commands/init.js CHANGED
@@ -34,7 +34,7 @@ import {
34
34
  } from '../lib/resolve.js';
35
35
 
36
36
  const DEFAULT_IMAGE = process.env.CROSSTALK_IMAGE
37
- ?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.7';
37
+ ?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.8';
38
38
 
39
39
  function usage(exit = 0) {
40
40
  const w = exit === 0 ? process.stdout : process.stderr;
@@ -129,13 +129,6 @@ export async function run(argv) {
129
129
  }
130
130
  }
131
131
 
132
- // Ensure an empty auth.env exists so compose's env_file: can reference
133
- // it without erroring on missing file. Operator-owned, 0600 — even
134
- // when empty it's where `crosstalk auth paste` will land tokens.
135
- if (!existsSync(paths.authEnvFile)) {
136
- writeFileSync(paths.authEnvFile, '', { mode: 0o600 });
137
- }
138
-
139
132
  // ── Phase 2: Transport scaffold ────────────────────────────────────
140
133
  // The template-scaffold check is "does CROSSTALK-VERSION exist?" NOT
141
134
  // ".git exists" — git init is a separate phase, and the engine refuses
package/commands/run.js CHANGED
@@ -10,7 +10,7 @@
10
10
  import { readFileSync, existsSync, statSync } from 'fs';
11
11
  import { apiFor } from '../lib/api-client.js';
12
12
  import { reportAndExit } from '../lib/errors.js';
13
- import { flag, has } from '../lib/argv.js';
13
+ import { flag, has, positionals } from '../lib/argv.js';
14
14
 
15
15
  const FLAGS_WITH_VALUE = new Set(['--type', '--to', '--as', '--fanout', '--channel', '--from', '--containername', '-c']);
16
16
 
@@ -56,18 +56,11 @@ export async function run(argv) {
56
56
  const type = flag(argv, '--type');
57
57
  if (type !== 'primitive' && type !== 'workflow') usage(1);
58
58
 
59
- // The body is the last non-flag arg. Walk argv backwards skipping
60
- // flag values.
61
- let bodyArg;
62
- for (let i = argv.length - 1; i >= 0; i--) {
63
- const a = argv[i];
64
- if (a.startsWith('-')) break;
65
- // Make sure we're not the value of a flag like `--to <value>`
66
- const prev = argv[i - 1];
67
- if (prev && FLAGS_WITH_VALUE.has(prev)) continue;
68
- bodyArg = a;
69
- break;
70
- }
59
+ // Body is the last positional. positionals() skips flag values so the
60
+ // body resolves regardless of flag order — `--containername a8 hello`
61
+ // and `hello --containername a8` both work.
62
+ const pos = positionals(argv, [...FLAGS_WITH_VALUE]);
63
+ const bodyArg = pos[pos.length - 1];
71
64
 
72
65
  const body = resolveBody(bodyArg);
73
66
  if (body == null || body.length === 0) {
@@ -34,10 +34,10 @@ export async function run(argv) {
34
34
  if (installed && installed.length > 0) {
35
35
  const verb = installed.length === 1 ? 'references it' : 'references them';
36
36
  process.stdout.write(
37
- ` (${installed.join(', ')} installed in the container but no data/models.yaml entry ${verb} — add one to claim)\n`,
37
+ ` (${installed.join(', ')} installed in the container but no data/crosstalk.yaml entry ${verb} — add one to claim)\n`,
38
38
  );
39
39
  } else {
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');
40
+ process.stdout.write(' (no model CLIs found inside the container — install one via `crosstalk chat --shell` and add an entry to data/crosstalk.yaml)\n');
41
41
  }
42
42
  } else {
43
43
  for (const m of s.claimed_models) process.stdout.write(` - ${m}\n`);
package/commands/up.js CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  } from '../lib/resolve.js';
25
25
 
26
26
  const DEFAULT_IMAGE = process.env.CROSSTALK_IMAGE
27
- ?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.7';
27
+ ?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.8';
28
28
 
29
29
  function usage(exit = 0) {
30
30
  const w = exit === 0 ? process.stdout : process.stderr;
@@ -69,8 +69,6 @@ services:
69
69
  - ${paths.transportDir}:/var/lib/crosstalk-transport
70
70
  - ${paths.crosstalkRoot}:/crosstalk-root
71
71
  - ${paths.crosstalkState}:/var/lib/crosstalk-state
72
- env_file:
73
- - ${paths.authEnvFile}
74
72
  environment:
75
73
  CROSSTALK_ALIAS: ${alias}
76
74
  CROSSTALK_UID: "${uid}"
package/lib/resolve.js CHANGED
@@ -66,7 +66,6 @@ export function storagePaths(name, mode = storageMode()) {
66
66
  crosstalkState: join(root, 'crosstalk-state'),
67
67
  composeFile: join(root, 'docker-compose.yml'),
68
68
  portFile: join(root, 'api-port'),
69
- authEnvFile: join(root, 'auth.env'),
70
69
  };
71
70
  }
72
71
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cordfuse/crosstalk",
3
- "version": "7.0.0-alpha.7",
3
+ "version": "7.0.0-alpha.9",
4
4
  "description": "Crosstalk client — host-side CLI that talks to the crosstalkd daemon running inside the crosstalk-server container.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/commands/auth.js DELETED
@@ -1,218 +0,0 @@
1
- // crosstalk auth — manage agent auth tokens/keys.
2
- //
3
- // One subverb: `paste`. Operator runs the agent's headless-token
4
- // generator on their host (`claude setup-token`, etc.), pastes the
5
- // resulting token at our prompt, and we persist it in the container's
6
- // env so subsequent dispatch + chat invocations are authenticated.
7
- //
8
- // Storage: <base>/<name>/auth.env (0600, operator-UID owned). Docker
9
- // Compose's `env_file:` references it; each line is `KEY=value`.
10
- // Existing keys are replaced in-place; new keys append. Empty lines
11
- // and `#` comments preserved.
12
- //
13
- // Why this exists: 5 of the 6 supported agents work cleanly with env-
14
- // var auth (the headless path), but none of them have a uniform "set
15
- // the token" UX inside the container. This verb is the operator's
16
- // one-stop shop — same UX for any agent.
17
- //
18
- // Per-agent env var (the audit on 2026-06-14 confirmed each):
19
- // claude → CLAUDE_CODE_OAUTH_TOKEN (preferred: `claude setup-token`)
20
- // ANTHROPIC_API_KEY (alternate: Console API key)
21
- // codex → OPENAI_API_KEY (preferred)
22
- // CODEX_ACCESS_TOKEN (alternate: device-auth output)
23
- // gemini → GEMINI_API_KEY
24
- // qwen → OPENAI_API_KEY (qwen --auth-type openai)
25
- // agy → none (interactive OAuth only; errors with hint)
26
-
27
- import { existsSync, readFileSync, writeFileSync } from 'fs';
28
- import { createInterface } from 'readline';
29
- import { has, flag } from '../lib/argv.js';
30
- import { requireInitialized, DEFAULT_CONTAINER_NAME } from '../lib/resolve.js';
31
-
32
- const AGENT_ENV = {
33
- claude: { default: 'CLAUDE_CODE_OAUTH_TOKEN', label: 'OAuth token' },
34
- codex: { default: 'OPENAI_API_KEY', label: 'API key' },
35
- gemini: { default: 'GEMINI_API_KEY', label: 'API key' },
36
- qwen: { default: 'OPENAI_API_KEY', label: 'API key' },
37
- opencode: { default: 'OPENAI_API_KEY', label: 'provider API key' },
38
- };
39
-
40
- const AGENT_GEN_CMD = {
41
- claude: 'claude setup-token # on a host with a browser',
42
- codex: 'echo $OPENAI_API_KEY # or platform.openai.com/api-keys',
43
- gemini: 'echo $GEMINI_API_KEY # or aistudio.google.com/apikey',
44
- qwen: 'echo $OPENAI_API_KEY # OpenAI-compatible',
45
- opencode: 'echo $OPENAI_API_KEY # or provider-specific',
46
- };
47
-
48
- // Control byte values used by the TTY prompt — avoid embedding raw
49
- // bytes inline so the file is editor/linter clean.
50
- const CTRL_C = String.fromCharCode(3); // 0x03 — interrupt
51
- const CTRL_D = String.fromCharCode(4); // 0x04 — EOF
52
- const BS = String.fromCharCode(8); // 0x08 — backspace
53
- const DEL = String.fromCharCode(127); // 0x7F — terminal backspace
54
-
55
- function usage(exit = 0) {
56
- const w = exit === 0 ? process.stdout : process.stderr;
57
- w.write(
58
- `Usage: crosstalk auth paste --agent <name> [--containername <name>]
59
-
60
- Persists an auth token/key for an agent CLI into the container's
61
- environment. Operator generates the value on the host (which has a
62
- browser) and pastes at the prompt; crosstalk writes it to
63
- <base>/<name>/auth.env. Subsequent dispatch + chat invocations pick
64
- it up via the engine container's env.
65
-
66
- Options:
67
- --agent <name> One of: claude, codex, gemini, qwen, opencode.
68
- agy is interactive-only; use 'crosstalk chat
69
- --agent agy --login' for OAuth instead.
70
- --containername <name> Which crosstalk container to set the env on
71
- (default: 'crosstalk').
72
- -c <name> Shorthand for --containername.
73
-
74
- Reads from stdin (hidden) — if the input isn't a TTY, the entire stdin
75
- is read as the token (e.g. piped from a setup script).
76
-
77
- After paste, run 'crosstalk restart' to pick up the new env.
78
- `,
79
- );
80
- process.exit(exit);
81
- }
82
-
83
- function promptHidden(promptText) {
84
- return new Promise((resolve) => {
85
- process.stderr.write(promptText);
86
- if (!process.stdin.isTTY) {
87
- let buf = '';
88
- process.stdin.on('data', (chunk) => { buf += chunk.toString(); });
89
- process.stdin.on('end', () => resolve(buf.trim()));
90
- return;
91
- }
92
- if (typeof process.stdin.setRawMode !== 'function') {
93
- const rl = createInterface({ input: process.stdin, output: process.stderr });
94
- rl.question('', (ans) => { rl.close(); resolve(ans.trim()); });
95
- return;
96
- }
97
- process.stdin.setRawMode(true);
98
- process.stdin.resume();
99
- process.stdin.setEncoding('utf-8');
100
- let buf = '';
101
- const onData = (chunk) => {
102
- for (const ch of chunk) {
103
- if (ch === '\r' || ch === '\n') {
104
- process.stderr.write('\n');
105
- process.stdin.setRawMode(false);
106
- process.stdin.pause();
107
- process.stdin.removeListener('data', onData);
108
- resolve(buf.trim());
109
- return;
110
- }
111
- if (ch === CTRL_C || ch === CTRL_D) {
112
- process.stderr.write('\n');
113
- process.stdin.setRawMode(false);
114
- process.exit(130);
115
- }
116
- if (ch === BS || ch === DEL) {
117
- buf = buf.slice(0, -1);
118
- continue;
119
- }
120
- buf += ch;
121
- }
122
- };
123
- process.stdin.on('data', onData);
124
- });
125
- }
126
-
127
- // Upsert a KEY=value line in env-file content. Preserves other lines +
128
- // comments. Returns the new content.
129
- function upsertEnv(existing, key, value) {
130
- const lines = existing.split('\n');
131
- let replaced = false;
132
- const out = lines.map((line) => {
133
- if (line.startsWith(`${key}=`)) {
134
- replaced = true;
135
- return `${key}=${value}`;
136
- }
137
- return line;
138
- });
139
- if (!replaced) {
140
- while (out.length > 0 && out[out.length - 1] === '') out.pop();
141
- out.push(`${key}=${value}`);
142
- out.push('');
143
- }
144
- return out.join('\n');
145
- }
146
-
147
- export async function run(argv) {
148
- if (has(argv, '--help') || has(argv, '-h')) usage(0);
149
-
150
- const pos = argv.filter((a) => !a.startsWith('-'));
151
- const subverb = pos[0];
152
- if (subverb !== 'paste') {
153
- if (subverb) {
154
- process.stderr.write(`crosstalk auth: unknown subverb '${subverb}'. Try 'paste'.\n`);
155
- } else {
156
- usage(1);
157
- }
158
- return 1;
159
- }
160
-
161
- const agent = flag(argv, '--agent');
162
- if (!agent) {
163
- process.stderr.write(`crosstalk auth paste: --agent <name> is required.\n`);
164
- return 1;
165
- }
166
-
167
- if (agent === 'agy') {
168
- process.stderr.write(
169
- `crosstalk auth paste: agy has no headless env-var path — its only auth is interactive OAuth.\n` +
170
- ` Use 'crosstalk chat --agent agy --login' to set up auth.\n`,
171
- );
172
- return 1;
173
- }
174
-
175
- const cfg = AGENT_ENV[agent];
176
- if (!cfg) {
177
- process.stderr.write(
178
- `crosstalk auth paste: unknown agent '${agent}'.\n` +
179
- ` Supported: ${Object.keys(AGENT_ENV).join(', ')}.\n`,
180
- );
181
- return 1;
182
- }
183
-
184
- const { name, paths } = requireInitialized(argv);
185
-
186
- if (process.stdin.isTTY) {
187
- process.stderr.write(
188
- `\nPasting ${cfg.label} for ${agent} into '${name}' container.\n` +
189
- ` Env var: ${cfg.default}\n` +
190
- ` Storage: ${paths.authEnvFile}\n` +
191
- `\nGenerate on host:\n ${AGENT_GEN_CMD[agent] ?? '(see agent docs)'}\n` +
192
- `\nPaste the value below (input hidden, press Enter):\n`,
193
- );
194
- }
195
- const value = await promptHidden('> ');
196
- if (!value) {
197
- process.stderr.write(`crosstalk auth paste: empty input — aborted.\n`);
198
- return 1;
199
- }
200
- if (value.includes('\n')) {
201
- process.stderr.write(
202
- `crosstalk auth paste: input contains a newline. Tokens are single-line; check copy/paste.\n`,
203
- );
204
- return 1;
205
- }
206
-
207
- const existing = existsSync(paths.authEnvFile)
208
- ? readFileSync(paths.authEnvFile, 'utf-8')
209
- : '';
210
- const updated = upsertEnv(existing, cfg.default, value);
211
- writeFileSync(paths.authEnvFile, updated, { mode: 0o600 });
212
-
213
- process.stdout.write(
214
- `\nSaved ${cfg.default} to ${paths.authEnvFile}.\n` +
215
- `Run 'crosstalk restart${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}' to pick up the new env.\n`,
216
- );
217
- return 0;
218
- }