@dinoxx/dinox-cli 2.9.0 → 2.11.0

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.
@@ -1,4 +1,7 @@
1
+ import crypto from 'node:crypto';
1
2
  import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import { authApiBaseFromTokenEndpoint, DevicePairCancelled, deviceNameFromHostname, pollDevicePairLogin, prepareCliDevice, renderTerminalQr, revokeDeviceBinding, saveDevicePairResult, } from '../../auth/devicePair.js';
2
5
  import { buildPendingAuthConfig, fetchUserInfoWithAuthorization, hydrateResolvedAuthIdentity, mergeAuthIdentityIntoConfig, persistedAuthStateMatches, rotatePersistedAuthStateRevision, } from '../../auth/userInfo.js';
3
6
  import { getLegacyPowerSyncDbPath, getPowerSyncDbPath } from '../../config/paths.js';
4
7
  import { loadConfig, updateConfig } from '../../config/store.js';
@@ -227,6 +230,45 @@ export async function performAuthLogin(rawToken, syncTimeout, overrides = {}) {
227
230
  }
228
231
  return outcome.value;
229
232
  }
233
+ async function loginWithDeviceQr() {
234
+ const config = await loadConfig();
235
+ const resolved = resolveConfig(config);
236
+ const apiBase = authApiBaseFromTokenEndpoint(resolved.powersync.tokenEndpoint);
237
+ const store = { load: loadConfig, update: updateConfig };
238
+ const prepared = await prepareCliDevice({
239
+ store,
240
+ apiBase,
241
+ authorization: normalizeAuthToken(config.auth?.authorization),
242
+ deviceBindingId: config.auth?.deviceBindingId,
243
+ randomUUID: crypto.randomUUID,
244
+ });
245
+ if (prepared.revokeWarning)
246
+ printHumanReadableWarning(prepared.revokeWarning);
247
+ const abort = new AbortController();
248
+ const onSigint = () => abort.abort();
249
+ process.once('SIGINT', onSigint);
250
+ try {
251
+ const paired = await pollDevicePairLogin({
252
+ apiBase,
253
+ deviceId: prepared.deviceId,
254
+ deviceName: deviceNameFromHostname(os.hostname()),
255
+ renderQr: renderTerminalQr,
256
+ writeStderr: (text) => { process.stderr.write(text); },
257
+ signal: abort.signal,
258
+ });
259
+ return { token: paired.token, deviceBindingId: paired.deviceBindingId };
260
+ }
261
+ catch (error) {
262
+ if (error instanceof DevicePairCancelled) {
263
+ process.exitCode = 130;
264
+ process.exit(130);
265
+ }
266
+ throw error;
267
+ }
268
+ finally {
269
+ process.off('SIGINT', onSigint);
270
+ }
271
+ }
230
272
  async function runAuthLogin(context) {
231
273
  const options = context.options;
232
274
  if (context.args.token && options.tokenStdin) {
@@ -234,17 +276,35 @@ async function runAuthLogin(context) {
234
276
  code: 'USAGE_ERROR',
235
277
  });
236
278
  }
237
- const token = options.tokenStdin ? await readAuthTokenFromStdin() : context.args.token;
238
- const rawToken = normalizeAuthToken(token);
239
- if (!rawToken) {
240
- throw new DinoxError('Token is required');
241
- }
279
+ const explicitToken = options.tokenStdin ? await readAuthTokenFromStdin() : context.args.token;
280
+ let rawToken = normalizeAuthToken(explicitToken);
281
+ let pairedDeviceBindingId;
242
282
  const envToken = readEnvAuthToken();
283
+ if (options.tokenStdin || context.args.token) {
284
+ if (!rawToken) {
285
+ throw new DinoxError('Token is required');
286
+ }
287
+ }
288
+ else {
289
+ // A scanned login can never equal DINOX_TOKEN, so refuse before the phone binds this computer.
290
+ if (envToken) {
291
+ throw new DinoxError('DINOX_TOKEN is set, so a QR login would be overridden by it. Unset DINOX_TOKEN before running `dino auth login`, or log in with that token via `--token-stdin`.', { code: 'USAGE_ERROR' });
292
+ }
293
+ const paired = await loginWithDeviceQr();
294
+ rawToken = paired.token;
295
+ pairedDeviceBindingId = paired.deviceBindingId;
296
+ }
243
297
  if (envToken && envToken !== rawToken) {
244
298
  throw new DinoxError('DINOX_TOKEN is set and differs from the login token. Unset DINOX_TOKEN before running `dino auth login`, or use the same token.');
245
299
  }
246
300
  const structuredOutput = getRequestedStructuredOutputFormat() !== null;
247
301
  const result = await performAuthLogin(rawToken, context.globals.syncTimeout);
302
+ if (pairedDeviceBindingId) {
303
+ await saveDevicePairResult({
304
+ store: { load: loadConfig, update: updateConfig },
305
+ deviceBindingId: pairedDeviceBindingId,
306
+ });
307
+ }
248
308
  // After the login lock is released: the service starts the daemon under the new login.
249
309
  const daemonService = await autoInstallDaemonService();
250
310
  if (structuredOutput) {
@@ -269,6 +329,22 @@ async function runAuthLogoutLocked(options) {
269
329
  const config = await loadConfig();
270
330
  const resolved = resolveConfig(config);
271
331
  const envTokenActive = Boolean(readEnvAuthToken());
332
+ const savedToken = normalizeAuthToken(config.auth?.authorization);
333
+ const deviceBindingId = config.auth?.deviceBindingId?.trim();
334
+ if (deviceBindingId) {
335
+ if (!savedToken) {
336
+ printHumanReadableWarning(`warning: could not revoke paired device ${deviceBindingId} (no saved token). Continuing.`);
337
+ }
338
+ else {
339
+ const revoked = await revokeDeviceBinding({
340
+ apiBase: authApiBaseFromTokenEndpoint(resolved.powersync.tokenEndpoint),
341
+ deviceBindingId,
342
+ authorization: savedToken,
343
+ });
344
+ if (revoked.warning)
345
+ printHumanReadableWarning(revoked.warning);
346
+ }
347
+ }
272
348
  const daemonInfo = await getDaemonInfo();
273
349
  let initiallyResolvedUserId = resolved.auth.userId;
274
350
  if (!initiallyResolvedUserId
@@ -340,6 +416,8 @@ async function runAuthLogoutLocked(options) {
340
416
  username: undefined,
341
417
  email: undefined,
342
418
  userInfoSyncedAt: undefined,
419
+ deviceBindingId: undefined,
420
+ deviceId: undefined,
343
421
  },
344
422
  });
345
423
  }, { timeoutMs: resolved.sync.timeoutMs });
@@ -489,8 +567,8 @@ export const authCommandGroup = {
489
567
  commands: [
490
568
  {
491
569
  name: 'login',
492
- description: 'Save login token and verify PowerSync connectivity',
493
- arguments: [{ syntax: '[token]', description: 'Login token (omit when using --token-stdin)' }],
570
+ description: 'Log in by scanning a QR code, or save a token and verify PowerSync connectivity',
571
+ arguments: [{ syntax: '[token]', description: 'Login token (omit to scan a QR code, or when using --token-stdin)' }],
494
572
  options: [{ flags: '--token-stdin', description: 'Read the login token from piped stdin instead of argv', type: 'boolean' }],
495
573
  schema: {
496
574
  path: 'auth.login',
@@ -502,7 +580,11 @@ export const authCommandGroup = {
502
580
  summary: 'Login verification result',
503
581
  fields: ['verified', 'endpoint', 'dbPath', 'stale', 'lastSyncedAt', 'userId', 'daemonService'],
504
582
  },
505
- examples: ['dino auth login --token-stdin --format json'],
583
+ examples: [
584
+ 'dino auth login',
585
+ 'dino auth login --format json',
586
+ 'dino auth login --token-stdin --format json',
587
+ ],
506
588
  },
507
589
  action: runAuthLogin,
508
590
  },
@@ -116,8 +116,23 @@ async function runAdd(context) {
116
116
  printHumanReadableLine(`Added gateway "${gateway.name}" as device ${gateway.deviceId}.`);
117
117
  printStatuses(statuses);
118
118
  }
119
+ function formatDetectedAgent(agent) {
120
+ const launch = [agent.command, ...agent.args].join(' ');
121
+ const label = agent.verified
122
+ ? `ready${agent.agentVersion ? ` (${agent.agentName ?? agent.name} ${agent.agentVersion})` : ''}`
123
+ : agent.installHint
124
+ ? `installed, needs its ACP adapter: ${agent.installHint}`
125
+ : `not responding: ${agent.error ?? 'unknown'}`;
126
+ return `${agent.name}: ${label} · ${launch}`;
127
+ }
119
128
  async function runDetect(context) {
120
- const detected = await detectAcpAgents({ probe: true });
129
+ const structured = Boolean(getRequestedStructuredOutputFormat());
130
+ if (!structured)
131
+ printHumanReadableLine('Checking installed ACP agents...');
132
+ const detected = await detectAcpAgents({
133
+ probe: true,
134
+ onDetected: structured ? undefined : (agent) => printHumanReadableLine(formatDetectedAgent(agent)),
135
+ });
121
136
  const added = [];
122
137
  const updated = [];
123
138
  if (context.options.add) {
@@ -146,15 +161,6 @@ async function runDetect(context) {
146
161
  printHumanReadableLine('No ACP agents found on PATH.');
147
162
  return;
148
163
  }
149
- for (const agent of detected) {
150
- const launch = [agent.command, ...agent.args].join(' ');
151
- const label = agent.verified
152
- ? `ready${agent.agentVersion ? ` (${agent.agentName ?? agent.name} ${agent.agentVersion})` : ''}`
153
- : agent.installHint
154
- ? `installed, needs its ACP adapter: ${agent.installHint}`
155
- : `not responding: ${agent.error ?? 'unknown'}`;
156
- printHumanReadableLine(`${agent.name}: ${label} · ${launch}`);
157
- }
158
164
  for (const gateway of added)
159
165
  printHumanReadableLine(`Added gateway "${gateway.name}" (${gateway.deviceId}).`);
160
166
  for (const gateway of updated) {
@@ -48,6 +48,8 @@ async function runGraphCommand(context, onlineAction, offlineAction) {
48
48
  });
49
49
  }
50
50
  catch (error) {
51
+ if (error instanceof DinoxError && error.code === 'AUTH_REQUIRED')
52
+ throw error;
51
53
  const message = error instanceof Error ? error.message : String(error);
52
54
  throw new DinoxError(`Daemon execution failed: ${message}`, {
53
55
  code: 'DAEMON_UNAVAILABLE',
@@ -242,7 +242,7 @@ export function renderBundledSkillReference() {
242
242
  lines.push('```');
243
243
  lines.push('');
244
244
  if (section.title === 'Auth') {
245
- lines.push('Agent safety: the positional login-token form is legacy compatibility and is intentionally omitted from this agent-facing signature. Agent workflows must never place a Dinox authentication token in argv; use `--token-stdin` from the user\'s own shell or secret store.');
245
+ lines.push('Agent safety: the positional login-token form is legacy compatibility and is intentionally omitted from this agent-facing signature. Agent workflows must never place a Dinox authentication token in argv. Prefer `dino auth login` in the user\'s terminal (QR code; it blocks for up to 10 minutes; in Claude Code the user can type `! dino auth login`). Use `--token-stdin` only from the user\'s own shell or secret store.');
246
246
  lines.push('');
247
247
  }
248
248
  }
@@ -287,7 +287,7 @@ export function renderPortableRuntimeContract() {
287
287
  '- On a nonzero exit, parse structured stderr. Use top-level `code`, `recoverable`, `exit_code`, and `suggested_action`; inspect `error` for details. `dino schema errors --format json` lists every code and its exit status.',
288
288
  '- A direct user request for an exact write authorizes that write when the dry-run matches. Ask again before delete, overwrite, merge, bulk mutation, local-cache removal, repair, global CLI update, or whenever targets or impact are ambiguous.',
289
289
  '- A dry-run does not apply the planned mutation, but an online PowerSync runtime may flush older queued writes. Use `--offline` only when the user accepts stale local-cache semantics.',
290
- '- Do not ask for Dinox authentication credentials in chat or place a Dinox authentication token in argv. The `dino` child process must inherit `DINOX_TOKEN` or receive it from the host secret store at execution time; if the host cannot inject it safely, have the user perform persistent `--token-stdin` login in their own terminal.',
290
+ '- Do not ask for Dinox authentication credentials in chat or place a Dinox authentication token in argv. For persistent login, have the user run `dino auth login` in their own terminal and scan the QR code with the Dinox app. In Claude Code they can type `! dino auth login`. That command blocks for up to 10 minutes; say so before an agent runs it. Inherited `DINOX_TOKEN` and `dino auth login --token-stdin` remain for CI and secret stores.',
291
291
  '- Execute `dino` in an environment that can access the intended CLI binary, Dinox config/cache, and injected secret. Host-turn secret injection does not automatically cross into a sandbox or remote execution backend; never copy a secret through prompts to bridge that boundary.',
292
292
  '- If the host times out before receiving a structured envelope, treat the outcome as unknown. Verify freshness or the write postcondition before retrying.',
293
293
  '- Before claiming complete, current, all, none, latest, or an exact count, verify the command\'s freshness field is false (usually `data.stale`; note search uses `data.meta.stale`), inspect `_notice`, and check pagination or truncation fields returned by the command.',
@@ -1,12 +1,23 @@
1
1
  import { Command } from 'commander';
2
2
  import type { CommandDescriptor } from './descriptor/types.js';
3
+ import { type MissingAcpAdapter } from '../gateway/detect.js';
3
4
  type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
4
5
  type LogFn = (...parts: unknown[]) => void;
6
+ export type AcpAdapterInstallResult = {
7
+ id: string;
8
+ name: string;
9
+ package: string;
10
+ status: 'installed' | 'failed';
11
+ error: string | null;
12
+ };
5
13
  type UpdateCommandDeps = {
6
14
  detectPackageManager: () => PackageManager;
7
15
  runUpdate: (pm: PackageManager, options: {
8
16
  structuredOutput: boolean;
9
17
  }) => Promise<void>;
18
+ installAcpAdapters: (pm: PackageManager, options: {
19
+ structuredOutput: boolean;
20
+ }) => Promise<AcpAdapterInstallResult[]>;
10
21
  getVersion: () => string;
11
22
  getLatestVersion: () => Promise<string | null>;
12
23
  log: LogFn;
@@ -20,7 +31,12 @@ type UpdateSpawnSpec = {
20
31
  args: string[];
21
32
  shell: boolean;
22
33
  };
34
+ export declare function resolvePackageManagerSpawn(pm: PackageManager, args: string[], platform?: NodeJS.Platform): UpdateSpawnSpec;
23
35
  export declare function resolveUpdateSpawnSpec(pm: PackageManager, platform?: NodeJS.Platform): UpdateSpawnSpec;
36
+ /** Installs ACP adapters for local agent CLIs that do not already have one. */
37
+ export declare function installMissingAcpAdapters(pm: PackageManager, options: {
38
+ structuredOutput: boolean;
39
+ }, missing?: MissingAcpAdapter[]): Promise<AcpAdapterInstallResult[]>;
24
40
  export declare function createUpdateCommandDescriptor(overrides?: Partial<UpdateCommandDeps>): CommandDescriptor;
25
41
  export declare const updateCommandDescriptor: CommandDescriptor;
26
42
  export declare function registerUpdateCommand(program: Command, overrides?: Partial<UpdateCommandDeps>): void;
@@ -6,6 +6,7 @@ import { getSkillsReleaseMetadata } from '../constants/links.js';
6
6
  import { RETIRED_SKILLS, RETIRED_SKILLS_CLEANUP, formatRetiredSkillsSummary, } from '../skills/retired.js';
7
7
  import { getRequestedStructuredOutputFormat, printHumanReadableLine, printSuccess, } from '../utils/output.js';
8
8
  import { DinoxError } from '../utils/errors.js';
9
+ import { findMissingAcpAdapters } from '../gateway/detect.js';
9
10
  import { fetchLatestPublishedVersion } from '../utils/updateNotice.js';
10
11
  import { getPackageVersion } from '../utils/version.js';
11
12
  const PACKAGE_NAME = '@dinoxx/dinox-cli';
@@ -51,21 +52,31 @@ const PM_COMMANDS = {
51
52
  yarn: { bin: 'yarn', args: ['global', 'add', `${PACKAGE_NAME}@latest`] },
52
53
  bun: { bin: 'bun', args: ['add', '--global', `${PACKAGE_NAME}@latest`] },
53
54
  };
54
- export function resolveUpdateSpawnSpec(pm, platform = process.platform) {
55
- const { bin, args } = PM_COMMANDS[pm];
56
- const usesWindowsCommandShim = platform === 'win32' && bin !== 'bun';
55
+ export function resolvePackageManagerSpawn(pm, args, platform = process.platform) {
56
+ const usesWindowsCommandShim = platform === 'win32' && pm !== 'bun';
57
57
  if (usesWindowsCommandShim) {
58
58
  return {
59
- command: [`${bin}.cmd`, ...args].join(' '),
59
+ command: [`${pm}.cmd`, ...args].join(' '),
60
60
  args: [],
61
61
  shell: true,
62
62
  };
63
63
  }
64
- return {
65
- command: bin,
66
- args: [...args],
67
- shell: false,
68
- };
64
+ return { command: pm, args: [...args], shell: false };
65
+ }
66
+ export function resolveUpdateSpawnSpec(pm, platform = process.platform) {
67
+ return resolvePackageManagerSpawn(pm, PM_COMMANDS[pm].args, platform);
68
+ }
69
+ function globalInstallArgs(pm, packageName) {
70
+ switch (pm) {
71
+ case 'npm':
72
+ return ['install', '--global', packageName];
73
+ case 'pnpm':
74
+ return ['add', '--global', packageName];
75
+ case 'yarn':
76
+ return ['global', 'add', packageName];
77
+ case 'bun':
78
+ return ['add', '--global', packageName];
79
+ }
69
80
  }
70
81
  function appendBoundedOutput(current, chunk) {
71
82
  if (Buffer.byteLength(current, 'utf8') >= MAX_CAPTURED_UPDATE_OUTPUT_BYTES) {
@@ -74,8 +85,8 @@ function appendBoundedOutput(current, chunk) {
74
85
  const remaining = MAX_CAPTURED_UPDATE_OUTPUT_BYTES - Buffer.byteLength(current, 'utf8');
75
86
  return current + chunk.subarray(0, remaining).toString('utf8');
76
87
  }
77
- function runUpdate(pm, options) {
78
- const { command, args, shell } = resolveUpdateSpawnSpec(pm);
88
+ function runPackageManager(pm, packageArgs, options) {
89
+ const { command, args, shell } = resolvePackageManagerSpawn(pm, packageArgs);
79
90
  const invocation = [command, ...args].join(' ');
80
91
  return new Promise((resolve, reject) => {
81
92
  let stdout = '';
@@ -120,10 +131,38 @@ function runUpdate(pm, options) {
120
131
  });
121
132
  });
122
133
  }
134
+ function runUpdate(pm, options) {
135
+ return runPackageManager(pm, PM_COMMANDS[pm].args, options);
136
+ }
137
+ function adapterErrorMessage(error) {
138
+ return error instanceof Error ? error.message : String(error);
139
+ }
140
+ /** Installs ACP adapters for local agent CLIs that do not already have one. */
141
+ export async function installMissingAcpAdapters(pm, options, missing) {
142
+ const adapters = missing ?? await findMissingAcpAdapters();
143
+ const results = [];
144
+ for (const adapter of adapters) {
145
+ try {
146
+ await runPackageManager(pm, globalInstallArgs(pm, adapter.packageName), options);
147
+ results.push({ id: adapter.id, name: adapter.name, package: adapter.packageName, status: 'installed', error: null });
148
+ }
149
+ catch (error) {
150
+ results.push({
151
+ id: adapter.id,
152
+ name: adapter.name,
153
+ package: adapter.packageName,
154
+ status: 'failed',
155
+ error: adapterErrorMessage(error),
156
+ });
157
+ }
158
+ }
159
+ return results;
160
+ }
123
161
  function resolveDeps(overrides) {
124
162
  return {
125
163
  detectPackageManager,
126
164
  runUpdate,
165
+ installAcpAdapters: (pm, options) => installMissingAcpAdapters(pm, options),
127
166
  getVersion: getPackageVersion,
128
167
  getLatestVersion: fetchLatestPublishedVersion,
129
168
  log: logHumanReadable,
@@ -185,6 +224,7 @@ export function createUpdateCommandDescriptor(overrides = {}) {
185
224
  'skillsInstallCommand',
186
225
  'retiredSkills',
187
226
  'retiredSkillsCleanup',
227
+ 'acpAdapters',
188
228
  ],
189
229
  },
190
230
  examples: ['dino update', 'dino update --package-manager pnpm --format json'],
@@ -199,6 +239,7 @@ export function createUpdateCommandDescriptor(overrides = {}) {
199
239
  deps.log(`updating ${PACKAGE_NAME} via ${pm}...`);
200
240
  }
201
241
  await deps.runUpdate(pm, { structuredOutput });
242
+ const acpAdapters = await deps.installAcpAdapters(pm, { structuredOutput });
202
243
  const observedVersion = deps.getVersion();
203
244
  const version = observedVersion !== previousVersion
204
245
  ? observedVersion
@@ -219,10 +260,19 @@ export function createUpdateCommandDescriptor(overrides = {}) {
219
260
  skillsInstallCommand: skills.installCommand,
220
261
  retiredSkills: RETIRED_SKILLS,
221
262
  retiredSkillsCleanup: RETIRED_SKILLS_CLEANUP,
263
+ acpAdapters,
222
264
  });
223
265
  return;
224
266
  }
225
267
  deps.log(`done: ${version}`);
268
+ for (const adapter of acpAdapters) {
269
+ if (adapter.status === 'installed') {
270
+ deps.log(`installed ACP adapter ${adapter.package} for ${adapter.name}`);
271
+ }
272
+ else {
273
+ deps.log(`warning: could not install ACP adapter ${adapter.package} for ${adapter.name} (${adapter.error})`);
274
+ }
275
+ }
226
276
  printSkillsReminder(deps.log, version);
227
277
  },
228
278
  };
@@ -13,6 +13,10 @@ export type DinoxConfig = {
13
13
  userInfoSyncedAt?: string;
14
14
  /** Internal CAS token changed by every persisted login/logout transition. */
15
15
  stateRevision?: string;
16
+ /** Stable id for QR login (`cli-` + uuid). Reused for the same account on this machine. */
17
+ deviceId?: string;
18
+ /** `c_user_device.id` from the last successful QR login. */
19
+ deviceBindingId?: string;
16
20
  };
17
21
  powersync?: {
18
22
  /** PowerSync sync service endpoint (downloads/subscriptions). */
@@ -31,6 +31,13 @@ function matchesRuntimeTokenBinding(info, version, config) {
31
31
  async function ensureDaemonRunningUnlocked() {
32
32
  const expectedVersion = getPackageVersion();
33
33
  const baseConfig = resolveConfig(await loadConfig());
34
+ // A daemon cannot serve anything without a login; say so now instead of
35
+ // waiting for a launch that cannot become ready.
36
+ if (!baseConfig.auth.authorization) {
37
+ throw new DinoxError('Not logged in. Set DINOX_TOKEN or pipe a token to `dino auth login --token-stdin`.', {
38
+ code: 'AUTH_REQUIRED',
39
+ });
40
+ }
34
41
  const info = await getDaemonInfo();
35
42
  if (info && matchesRuntimeTokenBinding(info, expectedVersion, baseConfig)) {
36
43
  return;
@@ -25,6 +25,8 @@ export async function routeRead(globals, daemonMethod, daemonParams, localRun) {
25
25
  return (await sendDaemonRequest(daemonMethod, daemonParams));
26
26
  }
27
27
  catch (error) {
28
+ if (error instanceof DinoxError && error.code === 'AUTH_REQUIRED')
29
+ throw error;
28
30
  const message = error instanceof Error ? error.message : String(error);
29
31
  throw new DinoxError(`Daemon execution failed: ${message}`, {
30
32
  code: 'DAEMON_UNAVAILABLE',
@@ -32,16 +32,12 @@ export declare function addGateway(userId: string, input: {
32
32
  cwd?: string;
33
33
  env?: Record<string, string>;
34
34
  }): Promise<GatewayDefinition>;
35
- /**
36
- * Hosts a detected agent once. An agent already hosted, matched by type (or, for
37
- * gateways from before types were recorded, by executable name and arguments),
38
- * is updated in place when its executable moved, e.g. after a Node upgrade.
39
- */
40
35
  export declare function adoptDetectedAgent(userId: string, agent: {
41
36
  id: string;
42
37
  name: string;
43
38
  command: string;
44
39
  args: string[];
40
+ env?: Record<string, string>;
45
41
  }): Promise<{
46
42
  gateway: GatewayDefinition;
47
43
  change: 'added' | 'updated' | 'unchanged';
@@ -214,6 +214,19 @@ function sameLaunch(a, b) {
214
214
  * gateways from before types were recorded, by executable name and arguments),
215
215
  * is updated in place when its executable moved, e.g. after a Node upgrade.
216
216
  */
217
+ function mergeRequiredEnv(existing, required) {
218
+ if (!required)
219
+ return { env: existing, changed: false };
220
+ const env = { ...(existing ?? {}) };
221
+ let changed = false;
222
+ for (const [key, value] of Object.entries(required)) {
223
+ if (env[key] === undefined) {
224
+ env[key] = value;
225
+ changed = true;
226
+ }
227
+ }
228
+ return { env, changed };
229
+ }
217
230
  export async function adoptDetectedAgent(userId, agent) {
218
231
  const gateways = await loadGateways(userId);
219
232
  const existing = gateways.find((gateway) => gateway.agentId === agent.id) ??
@@ -223,10 +236,17 @@ export async function adoptDetectedAgent(userId, agent) {
223
236
  path.basename(gateway.command) === path.basename(agent.command) &&
224
237
  JSON.stringify(gateway.args ?? []) === JSON.stringify(agent.args));
225
238
  if (existing) {
226
- if (existing.agentId === agent.id && sameLaunch(existing, agent)) {
239
+ const requiredEnv = mergeRequiredEnv(existing.env, agent.env);
240
+ if (existing.agentId === agent.id && sameLaunch(existing, agent) && !requiredEnv.changed) {
227
241
  return { gateway: existing, change: 'unchanged' };
228
242
  }
229
- const updated = { ...existing, agentId: agent.id, command: agent.command, args: agent.args };
243
+ const updated = {
244
+ ...existing,
245
+ agentId: agent.id,
246
+ command: agent.command,
247
+ args: agent.args,
248
+ ...(requiredEnv.env ? { env: requiredEnv.env } : {}),
249
+ };
230
250
  await saveGateways(userId, gateways.map((gateway) => (gateway.id === existing.id ? updated : gateway)));
231
251
  return { gateway: updated, change: 'updated' };
232
252
  }
@@ -240,6 +260,7 @@ export async function adoptDetectedAgent(userId, agent) {
240
260
  agentId: agent.id,
241
261
  command: agent.command,
242
262
  args: agent.args,
263
+ ...(agent.env ? { env: agent.env } : {}),
243
264
  });
244
265
  return { gateway, change: 'added' };
245
266
  }
@@ -7,14 +7,22 @@ export declare const KNOWN_ACP_AGENTS: ReadonlyArray<{
7
7
  id: string;
8
8
  name: string;
9
9
  candidates: string[][];
10
- /** The agent's own CLI and the ACP adapter to install when only the CLI is present. */
10
+ /** Passed to the agent process. Saved on the gateway when this launch is adopted. */
11
+ env?: Record<string, string>;
12
+ /** The agent's own CLI, and the global package that provides ACP when that CLI has none. */
11
13
  adapter?: {
12
14
  baseBinary: string;
13
- install: string;
15
+ packageName: string;
14
16
  };
15
17
  }>;
16
18
  /** The known agent a manual launch command starts, by executable name and arguments. */
17
19
  export declare function inferAgentId(command: string | undefined, args?: string[]): string | undefined;
20
+ /** A launch stayed up but never completed the ACP handshake. Further flags of that agent are not tried. */
21
+ export declare class AcpProbeTimeoutError extends Error {
22
+ constructor();
23
+ }
24
+ /** A quick failure can be a wrong flag. A timeout means that agent is not going to answer. */
25
+ export declare function shouldTryNextAcpCandidate(error: unknown): boolean;
18
26
  export type DetectedAgent = {
19
27
  id: string;
20
28
  name: string;
@@ -27,14 +35,29 @@ export type DetectedAgent = {
27
35
  error?: string;
28
36
  /** Set when the agent's CLI is installed but its ACP adapter is not. */
29
37
  installHint?: string;
38
+ /** Environment the handshake needed. Stored on the gateway when adopted. */
39
+ env?: Record<string, string>;
30
40
  };
31
41
  export declare function findExecutable(name: string, envPath?: string): Promise<string | null>;
32
42
  /** Starts the agent, runs `initialize`, and stops it. Never leaves the process running. */
33
- export declare function probeAcpAgent(command: string, args: string[], timeoutMs?: number): Promise<{
43
+ export declare function probeAcpAgent(command: string, args: string[], timeoutMs?: number, env?: Record<string, string>): Promise<{
34
44
  agentName: string | null;
35
45
  agentVersion: string | null;
36
46
  }>;
37
47
  /** Finds installed ACP agents; with `probe`, keeps only launch variants that handshake. */
38
48
  export declare function detectAcpAgents(options?: {
39
49
  probe?: boolean;
50
+ timeoutMs?: number;
51
+ /** Called as soon as one agent finishes, so a slow handshake does not hide the ones already done. */
52
+ onDetected?: (agent: DetectedAgent) => void;
40
53
  }): Promise<DetectedAgent[]>;
54
+ export type MissingAcpAdapter = {
55
+ id: string;
56
+ name: string;
57
+ packageName: string;
58
+ };
59
+ /**
60
+ * Agents whose own CLI is on PATH but none of the ACP launch commands are.
61
+ * Those need a global adapter package before a gateway can start them.
62
+ */
63
+ export declare function findMissingAcpAdapters(envPath?: string): Promise<MissingAcpAdapter[]>;