@phnx-labs/agents-cli 1.22.8 → 1.22.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +5 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +41 -9
  5. package/dist/commands/feed.js +38 -1
  6. package/dist/commands/harness.d.ts +40 -1
  7. package/dist/commands/harness.js +316 -76
  8. package/dist/commands/profiles.d.ts +42 -0
  9. package/dist/commands/profiles.js +91 -3
  10. package/dist/commands/sessions-picker.js +3 -3
  11. package/dist/commands/sessions-render.d.ts +12 -0
  12. package/dist/commands/sessions-render.js +124 -0
  13. package/dist/commands/sessions.js +28 -1
  14. package/dist/commands/ssh.js +26 -3
  15. package/dist/commands/teams.js +12 -1
  16. package/dist/index.js +9 -2
  17. package/dist/lib/crabbox/cli.d.ts +35 -0
  18. package/dist/lib/crabbox/cli.js +46 -0
  19. package/dist/lib/crabbox/config.d.ts +21 -0
  20. package/dist/lib/crabbox/config.js +43 -0
  21. package/dist/lib/crabbox/lease.d.ts +15 -5
  22. package/dist/lib/crabbox/lease.js +57 -17
  23. package/dist/lib/daemon.js +12 -11
  24. package/dist/lib/devices/resolve-target.d.ts +4 -3
  25. package/dist/lib/devices/resolve-target.js +4 -3
  26. package/dist/lib/hosts/passthrough.js +18 -1
  27. package/dist/lib/hosts/registry.d.ts +4 -0
  28. package/dist/lib/hosts/registry.js +16 -0
  29. package/dist/lib/hosts/remote-cmd.js +1 -0
  30. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  31. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  32. package/dist/lib/observe-aliases.d.ts +30 -0
  33. package/dist/lib/observe-aliases.js +56 -0
  34. package/dist/lib/redact.js +4 -0
  35. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  36. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  37. package/dist/lib/session/parse.d.ts +5 -1
  38. package/dist/lib/session/parse.js +23 -13
  39. package/dist/lib/session/prompt.js +5 -0
  40. package/dist/lib/session/render.d.ts +3 -0
  41. package/dist/lib/session/render.js +33 -10
  42. package/dist/lib/startup/command-registry.js +15 -1
  43. package/dist/lib/usage-refresh.d.ts +19 -11
  44. package/dist/lib/usage-refresh.js +75 -43
  45. package/dist/lib/usage.d.ts +12 -0
  46. package/dist/lib/usage.js +37 -6
  47. package/package.json +1 -1
@@ -11,9 +11,11 @@
11
11
  * native harness registry. The `agents profiles` tree stays unchanged.
12
12
  */
13
13
  import chalk from 'chalk';
14
- import { addProfile, ensureProviderToken } from './profiles.js';
15
- import { listProfiles, readProfile, writeProfile, deleteProfile, profileExists, profileHostLabel, profileProviderLabel, profileModelLabel, profileAuthLabel, profileLabel, forkProfile, profileFromHostModel, authEnvKeyForHost, getProfilePath, validateProfileName, editProfile, renameProfile, } from '../lib/profiles.js';
16
- import { listPresets } from '../lib/profiles-presets.js';
14
+ import { addProfile, ensureProviderToken, applyFromSecrets } from './profiles.js';
15
+ import { isInteractiveTerminal } from './utils.js';
16
+ import { listProfiles, readProfile, writeProfile, deleteProfile, profileExists, profileHostLabel, profileProviderLabel, profileModelLabel, profileAuthLabel, profileLabel, forkProfile, editProfile, renameProfile, profileFromHostModel, authEnvKeyForHost, getProfilePath, validateProfileName, } from '../lib/profiles.js';
17
+ import { listPresets, getPreset } from '../lib/profiles-presets.js';
18
+ import { listBundles } from '../lib/secrets/bundles.js';
17
19
  import { AGENTS, ALL_AGENT_IDS, resolveAgentName } from '../lib/agents.js';
18
20
  /** Short capability summary for a native harness — its supported run modes. */
19
21
  function nativeModes(id) {
@@ -85,6 +87,215 @@ function authEnvKeyForHostOrThrow(host) {
85
87
  }
86
88
  return key;
87
89
  }
90
+ /** Map `agents harness edit` flags onto {@link ForkProfileOptions} — the same
91
+ * override shape `editProfile`/`forkProfile` apply. `--version ''` (unpin) is
92
+ * handled by the caller ({@link buildEdit}), not here: `forkProfile`'s own
93
+ * ternary treats an empty string as "no override" and would otherwise inherit
94
+ * the source's version instead of clearing it. */
95
+ export function buildEditOverrides(opts) {
96
+ const overrides = {};
97
+ if (opts.model !== undefined)
98
+ overrides.model = opts.model;
99
+ if (opts.baseUrl !== undefined)
100
+ overrides.baseUrl = opts.baseUrl;
101
+ if (opts.authProvider !== undefined)
102
+ overrides.provider = opts.authProvider;
103
+ if (opts.version)
104
+ overrides.version = opts.version;
105
+ if (opts.description !== undefined)
106
+ overrides.description = opts.description;
107
+ return overrides;
108
+ }
109
+ /** True when at least one recognized edit flag was given. */
110
+ export function hasEditFlags(opts) {
111
+ return (opts.model !== undefined ||
112
+ opts.baseUrl !== undefined ||
113
+ opts.authProvider !== undefined ||
114
+ opts.version !== undefined ||
115
+ opts.description !== undefined ||
116
+ opts.fallbackModel !== undefined ||
117
+ opts.fromSecrets !== undefined);
118
+ }
119
+ const EDIT_FLAGS_HELP = 'No changes given. Available flags: --model, --base-url, --auth-provider, --version, --description, --fallback-model, --from-secrets.';
120
+ /**
121
+ * Build the edited harness for `agents harness edit <name>` — the profile-shape
122
+ * transform only; the caller applies `--from-secrets`/`--auth-provider` (async
123
+ * keychain side effects) and persists with `writeProfile`. Mirrors
124
+ * {@link buildFork}'s split between a pure builder and the action's IO.
125
+ */
126
+ export function buildEdit(name, opts) {
127
+ if (!profileExists(name)) {
128
+ throw new Error(`Harness '${name}' not found. Create it first: agents harness add ${name} ...`);
129
+ }
130
+ if (!hasEditFlags(opts)) {
131
+ throw new Error(EDIT_FLAGS_HELP);
132
+ }
133
+ const source = readProfile(name);
134
+ const edited = editProfile(source, buildEditOverrides(opts));
135
+ if (opts.version === '')
136
+ delete edited.host.version;
137
+ if (opts.fallbackModel !== undefined) {
138
+ if (opts.fallbackModel === '')
139
+ delete edited.fallback_model;
140
+ else
141
+ edited.fallback_model = opts.fallbackModel;
142
+ }
143
+ return edited;
144
+ }
145
+ /** True when `agents harness fork` was given too little to proceed without the wizard. */
146
+ export function forkNeedsWizard(source, name, opts) {
147
+ if (!source || !name)
148
+ return true;
149
+ if (!profileExists(source) && resolveAgentName(source) && !opts.model)
150
+ return true;
151
+ return false;
152
+ }
153
+ /** True when `agents harness add` was given too little to proceed without the wizard.
154
+ * Mirrors {@link addProfile}'s own error condition in ./profiles.js so a bare
155
+ * `agents harness add <preset-name>` (no flags) still resolves via the preset
156
+ * fallback instead of being routed into the wizard. */
157
+ export function addNeedsWizard(name, opts) {
158
+ if (!name)
159
+ return true;
160
+ if (opts.preset)
161
+ return false;
162
+ if (opts.host && opts.model)
163
+ return false;
164
+ if (opts.host || opts.model)
165
+ return true;
166
+ return !getPreset(name);
167
+ }
168
+ /**
169
+ * Shared build+persist flow for a fork — used by `agents harness fork`'s
170
+ * flag-driven path AND by the wizard (both for `fork` and, when it falls back
171
+ * to the wizard, `add`), so a wizard run and a hand-written `fork` call build an
172
+ * identical profile.
173
+ */
174
+ async function runForkFlow(source, name, opts) {
175
+ validateProfileName(name);
176
+ if (profileExists(name) && !opts.force) {
177
+ throw new Error(`Harness '${name}' already exists. Use --force to overwrite.`);
178
+ }
179
+ // Build first so a bad source/flag combination fails before prompting for a
180
+ // key (or copying one from a bundle) the user would then have stored for
181
+ // nothing.
182
+ const forked = buildFork(source, name, opts);
183
+ if (opts.fromSecrets) {
184
+ // A fork's `auth` (when present) may just be inherited by reference from
185
+ // its source, not established for this harness itself -- never trust it
186
+ // as "safe to rotate" without an explicit --auth-provider.
187
+ await applyFromSecrets(forked, opts.fromSecrets, opts.authProvider, { allowInheritedAuth: false });
188
+ }
189
+ else if (opts.authProvider) {
190
+ await ensureProviderToken(opts.authProvider, undefined, opts.keyStdin);
191
+ }
192
+ writeProfile(forked);
193
+ console.log(chalk.green(`Harness '${name}' forked from ${source}.`));
194
+ console.log(chalk.gray(`Try: agents run ${name} "hello"`));
195
+ }
196
+ /** The first `_MODEL`-suffixed env var in a preset's static env block, if any. */
197
+ function presetModel(preset) {
198
+ return Object.entries(preset.env).find(([k]) => k.endsWith('_MODEL'))?.[1];
199
+ }
200
+ /**
201
+ * Interactive `agents harness add`/`fork` wizard — runs when required info is
202
+ * missing and stdout is a TTY (see {@link forkNeedsWizard}, {@link addNeedsWizard}).
203
+ * Always resolves to the same `(source, name, opts)` shape {@link buildFork}
204
+ * accepts via {@link runForkFlow}, so a wizard run and a hand-written fork call
205
+ * build an identical profile.
206
+ */
207
+ async function runHarnessWizard() {
208
+ const { select, input } = await import('@inquirer/prompts');
209
+ const customNames = listProfiles().map((p) => p.name);
210
+ const source = await select({
211
+ message: 'Fork from',
212
+ choices: [
213
+ ...ALL_AGENT_IDS.map((id) => ({ name: `${AGENTS[id].name} ${chalk.gray('(native)')}`, value: id })),
214
+ ...customNames.map((n) => ({ name: `${n} ${chalk.gray('(custom harness)')}`, value: n })),
215
+ ],
216
+ });
217
+ const presets = listPresets();
218
+ const CUSTOM = '__custom__';
219
+ const presetChoice = await select({
220
+ message: 'Preset',
221
+ choices: [
222
+ ...presets.map((p) => ({ name: `${p.name} ${chalk.gray(p.description.slice(0, 60))}`, value: p.name })),
223
+ { name: 'Build custom (host + model + provider)', value: CUSTOM },
224
+ ],
225
+ });
226
+ let model;
227
+ let baseUrl;
228
+ let authProvider;
229
+ let defaultName = source;
230
+ if (presetChoice === CUSTOM) {
231
+ model = await input({ message: 'Model id' });
232
+ const NO_AUTH = '__none__';
233
+ const providers = [...new Set(presets.map((p) => p.provider))];
234
+ const providerChoice = await select({
235
+ message: 'Provider',
236
+ choices: [
237
+ ...providers.map((p) => ({ name: p, value: p })),
238
+ { name: 'no auth / host manages its own login', value: NO_AUTH },
239
+ ],
240
+ });
241
+ authProvider = providerChoice === NO_AUTH ? undefined : providerChoice;
242
+ const baseUrlInput = await input({ message: 'Base URL (optional)', default: '' });
243
+ baseUrl = baseUrlInput || undefined;
244
+ }
245
+ else {
246
+ const preset = getPreset(presetChoice);
247
+ model = presetModel(preset);
248
+ baseUrl = preset.env.ANTHROPIC_BASE_URL || preset.env.OPENAI_BASE_URL;
249
+ authProvider = preset.authOptional ? undefined : preset.provider;
250
+ // Pre-fill with the preset's own name (e.g. 'deepseek'), not a model
251
+ // detail, so users aren't nudged toward baking one into the identity name.
252
+ defaultName = preset.name;
253
+ }
254
+ const name = await input({
255
+ message: 'Harness name',
256
+ default: defaultName,
257
+ validate: (v) => {
258
+ try {
259
+ validateProfileName(v);
260
+ return true;
261
+ }
262
+ catch (err) {
263
+ return err.message;
264
+ }
265
+ },
266
+ });
267
+ const opts = { model, baseUrl, authProvider };
268
+ if (authProvider) {
269
+ const bundles = listBundles();
270
+ const TYPE_NOW = 'type';
271
+ const FROM_SECRETS = 'secrets';
272
+ const keySource = bundles.length > 0
273
+ ? await select({
274
+ message: `How should '${authProvider}' get its key?`,
275
+ choices: [
276
+ { name: 'Type a key now', value: TYPE_NOW },
277
+ { name: 'Use an existing agents secrets bundle', value: FROM_SECRETS },
278
+ ],
279
+ })
280
+ : TYPE_NOW;
281
+ if (keySource === FROM_SECRETS) {
282
+ const bundleName = await select({
283
+ message: 'Bundle',
284
+ choices: bundles.map((b) => ({
285
+ name: b.description ? `${b.name} ${chalk.gray(b.description)}` : b.name,
286
+ value: b.name,
287
+ })),
288
+ });
289
+ const bundle = bundles.find((b) => b.name === bundleName);
290
+ const keys = Object.keys(bundle.vars);
291
+ const key = keys.length === 1
292
+ ? keys[0]
293
+ : await select({ message: 'Key', choices: keys.map((k) => ({ name: k, value: k })) });
294
+ opts.fromSecrets = `${bundleName}:${key}`;
295
+ }
296
+ }
297
+ return { source, name, opts };
298
+ }
88
299
  export function registerHarnessCommands(program) {
89
300
  const cmd = program
90
301
  .command('harness')
@@ -110,6 +321,13 @@ Examples:
110
321
  # Per-run model override still wins
111
322
  agents run spark --model opencode/big-pickle "quick pass"
112
323
 
324
+ # Edit a harness in place, or give it a new name
325
+ agents harness edit deepseek --fallback-model deepseek/deepseek-chat-v3
326
+ agents harness rename deepseek deepseek-classic
327
+
328
+ # No args, in an interactive terminal: a wizard walks you through host, model, provider, key
329
+ agents harness add
330
+
113
331
  # See custom harnesses, addable presets, and native harnesses
114
332
  agents harness list
115
333
 
@@ -119,18 +337,27 @@ Examples:
119
337
  # Custom harnesses are just profiles — this also works via 'agents profiles'
120
338
  `);
121
339
  cmd
122
- .command('add <name>')
123
- .description('Create a custom harness from a host + model (or apply a built-in preset).')
340
+ .command('add [name]')
341
+ .description('Create a custom harness from a host + model (or apply a built-in preset). Omit flags in a terminal for the interactive wizard.')
124
342
  .option('--host <agent>', 'Host CLI to run under (opencode, claude, codex, grok, antigravity, ...) — pair with --model')
125
343
  .option('--model <id>', 'Model id to pin on the host (e.g., meta/muse-spark-1.1) — pair with --host')
126
344
  .option('--base-url <url>', 'Custom endpoint base URL (claude/codex hosts)')
127
345
  .option('--auth-provider <provider>', 'Attach a keychain-backed API key under this provider (for private endpoints)')
128
346
  .option('--preset <preset>', 'Apply a built-in preset instead of --host/--model')
129
347
  .option('--version <version>', 'Pin the host CLI version (e.g., 1.16.0)')
348
+ .option('--from-secrets <bundle>[:<key>]', "Copy a value from an agents secrets bundle into this harness's own keychain item, once (not a live link)")
130
349
  .option('--key-stdin', 'Read API key from stdin instead of prompting (for scripts/CI)')
131
350
  .option('--force', 'Overwrite an existing harness with the same name')
132
351
  .action(async (name, opts) => {
133
352
  try {
353
+ if (addNeedsWizard(name, opts)) {
354
+ if (!isInteractiveTerminal()) {
355
+ throw new Error("'agents harness add' needs --preset or --host + --model (or a name and an interactive terminal for the wizard).");
356
+ }
357
+ const wiz = await runHarnessWizard();
358
+ await runForkFlow(wiz.source, wiz.name, { ...wiz.opts, force: opts.force, keyStdin: opts.keyStdin });
359
+ return;
360
+ }
134
361
  await addProfile(name, opts, 'Harness');
135
362
  }
136
363
  catch (err) {
@@ -139,13 +366,14 @@ Examples:
139
366
  }
140
367
  });
141
368
  cmd
142
- .command('fork <source> <name>')
143
- .description('Fork a native harness (claude, opencode, ...) or an existing custom one into a new named harness.')
369
+ .command('fork [source] [name]')
370
+ .description('Fork a native harness (claude, opencode, ...) or an existing custom one into a new named harness. Omit args in a terminal for the interactive wizard.')
144
371
  .option('--model <id>', 'Model to pin on the fork (required when forking a native harness)')
145
372
  .option('--base-url <url>', 'Custom endpoint base URL (claude/codex hosts)')
146
373
  .option('--auth-provider <provider>', 'Attach a keychain-backed API key under this provider')
147
374
  .option('--version <version>', 'Pin the host CLI version (e.g., 1.16.0)')
148
375
  .option('--description <text>', 'One-line description')
376
+ .option('--from-secrets <bundle>[:<key>]', "Copy a value from an agents secrets bundle into this harness's own keychain item, once (not a live link)")
149
377
  .option('--key-stdin', 'Read the API key from stdin instead of prompting (for scripts/CI)')
150
378
  .option('--force', 'Overwrite an existing harness with the same name')
151
379
  .addHelpText('after', `
@@ -158,21 +386,92 @@ Examples:
158
386
 
159
387
  # Copy an existing harness and swap only the model
160
388
  agents harness fork deepseek deepseek-chat --model deepseek/deepseek-chat-v3
389
+
390
+ # Copy a key out of an existing secrets bundle instead of typing it
391
+ agents harness fork claude corp --model gpt-x --auth-provider corp --from-secrets prod:OPENROUTER_KEY
392
+
393
+ # No args, in an interactive terminal: walks through source, preset/model, name, key
394
+ agents harness fork
161
395
  `)
162
396
  .action(async (source, name, opts) => {
163
397
  try {
164
- validateProfileName(name);
165
- if (profileExists(name) && !opts.force) {
166
- throw new Error(`Harness '${name}' already exists. Use --force to overwrite.`);
398
+ if (forkNeedsWizard(source, name, opts)) {
399
+ if (!isInteractiveTerminal()) {
400
+ throw new Error("'agents harness fork' needs <source> and <name> (or an interactive terminal for the wizard).");
401
+ }
402
+ const wiz = await runHarnessWizard();
403
+ await runForkFlow(wiz.source, wiz.name, { ...wiz.opts, force: opts.force, keyStdin: opts.keyStdin });
404
+ return;
405
+ }
406
+ await runForkFlow(source, name, opts);
407
+ }
408
+ catch (err) {
409
+ console.error(chalk.red(err.message));
410
+ process.exit(1);
411
+ }
412
+ });
413
+ cmd
414
+ .command('edit <name>')
415
+ .description('Edit an existing custom harness in place — model, endpoint, auth, version, description, fallback.')
416
+ .option('--model <id>', 'Swap the pinned model')
417
+ .option('--base-url <url>', 'Swap the custom endpoint base URL')
418
+ .option('--auth-provider <provider>', 'Repoint auth at a different provider (keychain-backed)')
419
+ .option('--version <version>', 'Re-pin the host CLI version (pass an empty string to unpin)')
420
+ .option('--description <text>', 'Update the one-line description')
421
+ .option('--fallback-model <id>', 'Secondary model retried on the same host on a rate limit (pass an empty string to clear it)')
422
+ .option('--from-secrets <bundle>[:<key>]', "Copy a value from an agents secrets bundle into this harness's own keychain item, once (not a live link)")
423
+ .option('--key-stdin', 'Read the API key from stdin instead of prompting (for scripts/CI)')
424
+ .addHelpText('after', `
425
+ Examples:
426
+ # Swap the pinned model
427
+ agents harness edit deepseek --model deepseek/deepseek-v3.2
428
+
429
+ # Repoint auth at a different provider and re-enter the key
430
+ agents harness edit corp --auth-provider corp2
431
+
432
+ # Unpin the host CLI version
433
+ agents harness edit spark --version ""
434
+
435
+ # Add a same-host fallback model for rate-limit retries
436
+ agents harness edit deepseek --fallback-model deepseek/deepseek-chat-v3
437
+
438
+ # Copy a key out of an existing secrets bundle instead of typing it
439
+ agents harness edit corp --from-secrets prod:OPENROUTER_KEY
440
+ `)
441
+ .action(async (name, opts) => {
442
+ try {
443
+ const edited = buildEdit(name, opts);
444
+ if (opts.fromSecrets) {
445
+ await applyFromSecrets(edited, opts.fromSecrets, opts.authProvider);
167
446
  }
168
- // Build first so a bad source/flag combination fails before prompting
169
- // for a key the user would then have stored for nothing.
170
- const forked = buildFork(source, name, opts);
171
- if (opts.authProvider)
447
+ else if (opts.authProvider) {
172
448
  await ensureProviderToken(opts.authProvider, undefined, opts.keyStdin);
173
- writeProfile(forked);
174
- console.log(chalk.green(`Harness '${name}' forked from ${source}.`));
175
- console.log(chalk.gray(`Try: agents run ${name} "hello"`));
449
+ }
450
+ writeProfile(edited);
451
+ console.log(chalk.green(`Harness '${name}' updated.`));
452
+ console.log(chalk.gray(`Model: ${profileModelLabel(edited)}`));
453
+ }
454
+ catch (err) {
455
+ console.error(chalk.red(err.message));
456
+ process.exit(1);
457
+ }
458
+ });
459
+ cmd
460
+ .command('rename <old-name> <new-name>')
461
+ .description('Rename a custom harness (updates forkedFrom lineage on any harness forked from it). Errors on a name collision.')
462
+ .addHelpText('after', `
463
+ Examples:
464
+ # Rename 'spark' to 'muse'
465
+ agents harness rename spark muse
466
+
467
+ # Rename then run under the new name
468
+ agents harness rename deepseek ds && agents run ds "hello"
469
+ `)
470
+ .action((oldName, newName) => {
471
+ try {
472
+ renameProfile(oldName, newName);
473
+ console.log(chalk.green(`Harness '${oldName}' renamed to '${newName}'.`));
474
+ console.log(chalk.gray(`Try: agents run ${newName} "hello"`));
176
475
  }
177
476
  catch (err) {
178
477
  console.error(chalk.red(err.message));
@@ -246,63 +545,4 @@ Examples:
246
545
  }
247
546
  console.log(chalk.green(`Harness '${name}' removed.`));
248
547
  });
249
- cmd
250
- .command('edit <name>')
251
- .description('Edit a custom harness in place (model, endpoint, host version, description).')
252
- // Flags mirror ForkProfileOptions exactly — `editProfile` reuses
253
- // `forkProfile`'s override logic, so anything it does not accept would be a
254
- // flag that parses and does nothing. There is deliberately no --label: the
255
- // header `agents view` prints is derived from the harness name.
256
- .option('--model <id>', 'Swap the pinned model (e.g., meta/muse-spark-2.0)')
257
- .option('--base-url <url>', 'Set or replace the endpoint base URL (claude/codex hosts)')
258
- .option('--version <ver>', 'Re-pin the host CLI version. Empty string unpins (tracks latest).')
259
- .option('--description <text>', 'Set the one-line description')
260
- .addHelpText('after', `
261
- Examples:
262
- # Upgrade the pinned model
263
- agents harness edit spark --model meta/muse-spark-2.0
264
-
265
- # Point to a new private endpoint
266
- agents harness edit corp --base-url https://gw.corp/v2
267
-
268
- # Track the host's latest instead of a pinned version
269
- agents harness edit spark --version ""
270
-
271
- # The display name is derived from the harness name, so to change it, rename:
272
- agents harness rename spark muse-spark
273
- `)
274
- .action((name, opts) => {
275
- try {
276
- const edited = editProfile(readProfile(name), opts);
277
- writeProfile(edited);
278
- console.log(chalk.green(`Harness '${name}' updated.`));
279
- console.log(chalk.gray(`Model: ${profileModelLabel(edited)}`));
280
- }
281
- catch (err) {
282
- console.error(chalk.red(err.message));
283
- process.exit(1);
284
- }
285
- });
286
- cmd
287
- .command('rename <name> <new-name>')
288
- .description('Rename a custom harness — updates the file name and its internal name field.')
289
- .addHelpText('after', `
290
- Examples:
291
- # Rename 'spark' to 'muse'
292
- agents harness rename spark muse
293
-
294
- # Rename then run under the new name
295
- agents harness rename deepseek ds && agents run ds "hello"
296
- `)
297
- .action((name, newName) => {
298
- try {
299
- renameProfile(name, newName);
300
- console.log(chalk.green(`Harness '${name}' renamed to '${newName}'.`));
301
- console.log(chalk.gray(`Run: agents run ${newName} "hello"`));
302
- }
303
- catch (err) {
304
- console.error(chalk.red(err.message));
305
- process.exit(1);
306
- }
307
- });
308
548
  }
@@ -28,7 +28,49 @@ export interface AddProfileOptions {
28
28
  version?: string;
29
29
  keyStdin?: boolean;
30
30
  force?: boolean;
31
+ /** `<bundle>` or `<bundle>:<key>` — see {@link applyFromSecrets}. */
32
+ fromSecrets?: string;
31
33
  }
34
+ /**
35
+ * Copy a value from an `agents secrets` bundle into a profile's own keychain
36
+ * item — a one-time copy, not a live link. `spec` is `<bundle>` or
37
+ * `<bundle>:<key>`; the key is required only when the bundle has more than one.
38
+ *
39
+ * Reads the bundle value via {@link getKeychainToken} — this pops Touch ID on
40
+ * macOS once, for that bundle-namespaced item (`agents-cli.secrets.*`, gated by
41
+ * `keychainItemRequiresUserPresence` in `../lib/secrets/index.ts`) — then writes
42
+ * it to `keychainItemName(provider)`, a plain `agents-cli.<provider>.token`
43
+ * item. That item matches neither the `agents-cli.secrets.` nor
44
+ * `agents-cli.bundles.` prefix, so every later read of the harness's own key is
45
+ * silent — no repeat Touch ID prompt.
46
+ *
47
+ * Provider precedence: an explicit `--auth-provider` on the same call always
48
+ * wins (freshest intent); otherwise, only when the profile already has a real
49
+ * auth binding (`profile.auth` is set), its own `provider` (so editing an
50
+ * already-provisioned harness rotates its existing key without repeating
51
+ * `--auth-provider`); otherwise the bundle's own name. The `profile.auth`
52
+ * gate matters because `profileFromHostModel`/`forkProfile` default
53
+ * `profile.provider` to the *host* id (e.g. `claude`) even when no auth is
54
+ * attached yet — trusting that default here would silently overwrite the
55
+ * host's own keychain item (`agents-cli.claude.token`) on a bare `--host
56
+ * --model --from-secrets` add with no `--auth-provider`.
57
+ *
58
+ * Attaches `profile.auth` when the profile has none yet (a bare `--host
59
+ * --model` harness, or a native-host fork with no prior auth binding).
60
+ *
61
+ * `allowInheritedAuth` (default `true`) gates the "reuse `profile.auth`'s own
62
+ * provider" branch above. It must be `false` when `profile` came from
63
+ * **forking** an already-provisioned harness: `forkProfile` copies `auth` by
64
+ * reference from the source when no `--auth-provider` override is given, so
65
+ * `profile.auth` being set there means "the SOURCE harness's binding", not
66
+ * "this harness's own" — trusting it would silently overwrite the source's
67
+ * shared keychain item. Editing a harness's own already-established auth is
68
+ * the one case where reuse is genuinely correct, so callers on that path keep
69
+ * the default.
70
+ */
71
+ export declare function applyFromSecrets(profile: Profile, spec: string, explicitAuthProvider?: string, opts?: {
72
+ allowInheritedAuth?: boolean;
73
+ }): Promise<void>;
32
74
  /**
33
75
  * Create a profile ("custom harness"). Two paths:
34
76
  * - `--host <agent> --model <id>`: one-shot custom harness from a host + model
@@ -11,7 +11,9 @@ import { readStdinSync } from '../lib/format.js';
11
11
  import { spawn } from 'child_process';
12
12
  import { listProfiles, readProfile, writeProfile, deleteProfile, profileExists, profileFromPreset, profileFromHostModel, baseUrlEnvKeyForHost, authEnvKeyForHost, validateProfileName, getPresetForProfile, } from '../lib/profiles.js';
13
13
  import { getPreset, listPresets, expandPreset } from '../lib/profiles-presets.js';
14
- import { hasKeychainToken, keychainItemName, setKeychainToken, deleteKeychainToken, } from '../lib/secrets/profiles.js';
14
+ import { hasKeychainToken, keychainItemName, setKeychainToken, deleteKeychainToken, getKeychainToken, } from '../lib/secrets/profiles.js';
15
+ import { parseBundleValue, secretsKeychainItem } from '../lib/secrets/index.js';
16
+ import { readBundle } from '../lib/secrets/bundles.js';
15
17
  import { isInteractiveTerminal } from './utils.js';
16
18
  import { getAgentsInvocation } from '../lib/daemon.js';
17
19
  import { getActiveResourceProfileName, getResourceProfilePreset, listResourceProfileNames, setActiveResourceProfile, upsertResourceProfilePreset, validateResourceProfileName, } from '../lib/resource-profiles.js';
@@ -66,6 +68,87 @@ export async function ensureProviderToken(provider, signupUrl, fromStdin) {
66
68
  setKeychainToken(item, token);
67
69
  console.log(chalk.green(`Stored in keychain: ${item}`));
68
70
  }
71
+ /**
72
+ * Copy a value from an `agents secrets` bundle into a profile's own keychain
73
+ * item — a one-time copy, not a live link. `spec` is `<bundle>` or
74
+ * `<bundle>:<key>`; the key is required only when the bundle has more than one.
75
+ *
76
+ * Reads the bundle value via {@link getKeychainToken} — this pops Touch ID on
77
+ * macOS once, for that bundle-namespaced item (`agents-cli.secrets.*`, gated by
78
+ * `keychainItemRequiresUserPresence` in `../lib/secrets/index.ts`) — then writes
79
+ * it to `keychainItemName(provider)`, a plain `agents-cli.<provider>.token`
80
+ * item. That item matches neither the `agents-cli.secrets.` nor
81
+ * `agents-cli.bundles.` prefix, so every later read of the harness's own key is
82
+ * silent — no repeat Touch ID prompt.
83
+ *
84
+ * Provider precedence: an explicit `--auth-provider` on the same call always
85
+ * wins (freshest intent); otherwise, only when the profile already has a real
86
+ * auth binding (`profile.auth` is set), its own `provider` (so editing an
87
+ * already-provisioned harness rotates its existing key without repeating
88
+ * `--auth-provider`); otherwise the bundle's own name. The `profile.auth`
89
+ * gate matters because `profileFromHostModel`/`forkProfile` default
90
+ * `profile.provider` to the *host* id (e.g. `claude`) even when no auth is
91
+ * attached yet — trusting that default here would silently overwrite the
92
+ * host's own keychain item (`agents-cli.claude.token`) on a bare `--host
93
+ * --model --from-secrets` add with no `--auth-provider`.
94
+ *
95
+ * Attaches `profile.auth` when the profile has none yet (a bare `--host
96
+ * --model` harness, or a native-host fork with no prior auth binding).
97
+ *
98
+ * `allowInheritedAuth` (default `true`) gates the "reuse `profile.auth`'s own
99
+ * provider" branch above. It must be `false` when `profile` came from
100
+ * **forking** an already-provisioned harness: `forkProfile` copies `auth` by
101
+ * reference from the source when no `--auth-provider` override is given, so
102
+ * `profile.auth` being set there means "the SOURCE harness's binding", not
103
+ * "this harness's own" — trusting it would silently overwrite the source's
104
+ * shared keychain item. Editing a harness's own already-established auth is
105
+ * the one case where reuse is genuinely correct, so callers on that path keep
106
+ * the default.
107
+ */
108
+ export async function applyFromSecrets(profile, spec, explicitAuthProvider, opts) {
109
+ const sep = spec.indexOf(':');
110
+ const bundleName = sep === -1 ? spec : spec.slice(0, sep);
111
+ const requestedKey = sep === -1 ? undefined : spec.slice(sep + 1);
112
+ const bundle = readBundle(bundleName);
113
+ const keys = Object.keys(bundle.vars);
114
+ const key = requestedKey ?? (keys.length === 1 ? keys[0] : undefined);
115
+ if (!key) {
116
+ throw new Error(keys.length === 0
117
+ ? `Bundle '${bundleName}' has no keys.`
118
+ : `Bundle '${bundleName}' has ${keys.length} keys (${keys.join(', ')}); pick one with --from-secrets ${bundleName}:<key>.`);
119
+ }
120
+ if (!(key in bundle.vars)) {
121
+ throw new Error(`Bundle '${bundleName}' has no key '${key}'. Available: ${keys.join(', ') || '(none)'}.`);
122
+ }
123
+ const parsed = parseBundleValue(bundle.vars[key]);
124
+ let value;
125
+ if ('literal' in parsed) {
126
+ value = parsed.literal;
127
+ }
128
+ else if (parsed.ref.provider === 'keychain') {
129
+ value = getKeychainToken(secretsKeychainItem(bundle.name, parsed.ref.value));
130
+ }
131
+ else {
132
+ throw new Error(`Bundle '${bundleName}' key '${key}' is a '${parsed.ref.provider}:' reference, not a keychain-backed secret — --from-secrets only copies keychain-backed values.`);
133
+ }
134
+ if (profile.auth && !explicitAuthProvider && opts?.allowInheritedAuth === false) {
135
+ throw new Error(`Harness '${profile.name}' inherited its auth binding from the harness it was forked from ` +
136
+ `(provider '${profile.provider}') — pass --auth-provider <name> explicitly with --from-secrets ` +
137
+ `on a fork, so it never silently overwrites the source harness's shared keychain item.`);
138
+ }
139
+ const provider = explicitAuthProvider || (profile.auth ? profile.provider : undefined) || bundleName;
140
+ const item = keychainItemName(provider);
141
+ setKeychainToken(item, value);
142
+ if (!profile.auth) {
143
+ const envVar = authEnvKeyForHost(profile.host.agent);
144
+ if (!envVar) {
145
+ throw new Error(`Host '${profile.host.agent}' has no known auth env var; --from-secrets cannot attach auth to this harness.`);
146
+ }
147
+ profile.provider = provider;
148
+ profile.auth = { envVar, keychainItem: item };
149
+ profile.authOptional = false;
150
+ }
151
+ }
69
152
  /**
70
153
  * Create a profile ("custom harness"). Two paths:
71
154
  * - `--host <agent> --model <id>`: one-shot custom harness from a host + model
@@ -98,7 +181,8 @@ export async function addProfile(name, opts, label = 'Profile') {
98
181
  throw new Error(`--auth-provider is set but host '${host}' has no known auth env var. Use a preset or a hand-written profile YAML.`);
99
182
  }
100
183
  authEnvVar = key;
101
- await ensureProviderToken(opts.authProvider, undefined, opts.keyStdin);
184
+ if (!opts.fromSecrets)
185
+ await ensureProviderToken(opts.authProvider, undefined, opts.keyStdin);
102
186
  }
103
187
  const profile = profileFromHostModel(name, host, opts.model, {
104
188
  version: opts.version,
@@ -106,6 +190,8 @@ export async function addProfile(name, opts, label = 'Profile') {
106
190
  provider: opts.authProvider,
107
191
  authEnvVar,
108
192
  });
193
+ if (opts.fromSecrets)
194
+ await applyFromSecrets(profile, opts.fromSecrets, opts.authProvider);
109
195
  writeProfile(profile);
110
196
  console.log(chalk.green(`${label} '${name}' added — ${host} + ${opts.model}.`));
111
197
  console.log(chalk.gray(`Try: agents run ${name} "hello"`));
@@ -118,10 +204,12 @@ export async function addProfile(name, opts, label = 'Profile') {
118
204
  throw new Error(`No preset '${presetName}'.\nAvailable presets: ${listPresets().map((p) => p.name).join(', ')}\n` +
119
205
  'Or build a custom harness: --host <agent> --model <id>.');
120
206
  }
121
- if (!preset.authOptional) {
207
+ if (!opts.fromSecrets && !preset.authOptional) {
122
208
  await ensureProviderToken(preset.provider, preset.signupUrl, opts.keyStdin);
123
209
  }
124
210
  const profile = profileFromPreset(name, preset, opts.version);
211
+ if (opts.fromSecrets)
212
+ await applyFromSecrets(profile, opts.fromSecrets, opts.authProvider);
125
213
  writeProfile(profile);
126
214
  console.log(chalk.green(`${label} '${name}' added.`));
127
215
  console.log(chalk.gray(`Try: agents run ${name} "hello"`));
@@ -11,7 +11,7 @@ import chalk from 'chalk';
11
11
  import { truncate, humanDuration } from '../lib/format.js';
12
12
  import { parseSession, sanitizeForTerminal, SNAPSHOT_TODO_TOOLS } from '../lib/session/parse.js';
13
13
  import { safeTeamText } from '../lib/session/team-filter.js';
14
- import { cleanSessionPrompt, extractSessionTopic } from '../lib/session/prompt.js';
14
+ import { cleanSessionPrompt, extractSessionTopic, isSyntheticUserMessage } from '../lib/session/prompt.js';
15
15
  import { linkPath, linkUrl, relativeToCwd, shortenModel } from '../lib/session/render.js';
16
16
  import { linearIssueUrl } from '../lib/session/linear.js';
17
17
  import { extractTodoProgress, WORKTREE_RE } from '../lib/session/state.js';
@@ -383,7 +383,7 @@ function formatCompactPreview(events, session) {
383
383
  const knownToolUsage = session.usedBrowser !== undefined;
384
384
  for (const event of events) {
385
385
  if (event.type === 'message') {
386
- if (event.role === 'user' && !firstUser && event.content) {
386
+ if (event.role === 'user' && !event._synthetic && !firstUser && event.content) {
387
387
  if (!SYSTEM_MESSAGE_PATTERNS.some(p => p.test(event.content))) {
388
388
  firstUser = event.content;
389
389
  }
@@ -443,7 +443,7 @@ function formatCompactPreview(events, session) {
443
443
  lines.push(chalk.cyan('Prompt: ') + chalk.white(truncate(first.trim(), termWidth - 12)));
444
444
  }
445
445
  }
446
- else if (session.topic) {
446
+ else if (session.topic && !isSyntheticUserMessage(session.topic)) {
447
447
  lines.push(chalk.cyan('Prompt: ') + chalk.white(truncate(session.topic.trim(), termWidth - 12)));
448
448
  }
449
449
  // Compact checklist: ✓done/total · current step (RUSH-2045).