@agentworkforce/cli 0.9.0 → 0.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.
package/dist/cli.js CHANGED
@@ -5,10 +5,11 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync,
5
5
  import { constants, homedir } from 'node:os';
6
6
  import { dirname, isAbsolute, join, resolve as resolvePath } from 'node:path';
7
7
  import { pathToFileURL } from 'node:url';
8
- import { HARNESS_VALUES, PERSONA_TAGS, PERSONA_TIERS, personaCatalog, routingProfiles, useSelection } from '@agentworkforce/workload-router';
8
+ import { HARNESS_VALUES, PERSONA_TAGS, PERSONA_TIERS, personaCatalog, resolveSidecar, routingProfiles, useSelection } from '@agentworkforce/workload-router';
9
9
  import { buildInteractiveSpec, detectHarnesses, formatDropWarnings, MissingPersonaInputError, renderPersonaInputs, resolvePersonaInputs, resolveMcpServersLenient, resolveStringMapLenient } from '@agentworkforce/harness-kit';
10
- import { launchOnMount } from '@relayfile/local-mount';
10
+ import { launchOnMount, readAgentDotfiles } from '@relayfile/local-mount';
11
11
  import ora from 'ora';
12
+ import { startLaunchMetadataRecording } from './launch-metadata.js';
12
13
  import { buildPersonaSourceDirectories, defaultCwdPersonaDir, loadLocalPersonas, loadPersonaSourceConfig, normalizePersonaDir, savePersonaSourceConfig } from './local-personas.js';
13
14
  import { installPersonas } from './persona-install.js';
14
15
  const USAGE = `Usage: agentworkforce <command> [args...]
@@ -26,6 +27,8 @@ Commands:
26
27
  --save-default Persist --to as defaultCreateTarget in
27
28
  ~/.agentworkforce/workforce/config.json.
28
29
  --install-in-repo Same behavior as agent.
30
+ --no-launch-metadata
31
+ Same behavior as agent.
29
32
  agent [flags] <persona>[@<tier>]
30
33
  Run a persona. Tier one of: ${PERSONA_TIERS.join(' | ')}
31
34
  (default: best-value). Drops into an interactive harness
@@ -46,6 +49,10 @@ Commands:
46
49
  are hidden from the session. Codex
47
50
  sessions never mount and ignore
48
51
  this flag.
52
+ --no-launch-metadata
53
+ Disable launch metadata recording.
54
+ Also disabled by
55
+ AGENTWORKFORCE_LAUNCH_METADATA=0.
49
56
  list [flags] List available personas from the cascade (cwd →
50
57
  configured persona dirs → library). By default shows
51
58
  one row per persona at the recommended tier for its
@@ -165,7 +172,10 @@ function parseSelector(sel) {
165
172
  if ('error' in result)
166
173
  die(result.error, false);
167
174
  const kind = local.byId.has(key) ? 'local' : 'repo';
168
- return { kind, spec: result, tier };
175
+ if (kind === 'local') {
176
+ return { kind, source: local.sources.get(result.id) ?? 'cwd', spec: result, tier };
177
+ }
178
+ return { kind, source: 'library', spec: result, tier };
169
179
  }
170
180
  /**
171
181
  * Resolve the `<harness>` placeholder used in persona systemPrompts.
@@ -187,6 +197,7 @@ function buildSelection(spec, tier, kind) {
187
197
  ...rawRuntime,
188
198
  systemPrompt: resolveSystemPromptPlaceholders(rawRuntime.systemPrompt, rawRuntime.harness)
189
199
  };
200
+ const sidecar = resolveSidecar(spec, tier);
190
201
  return {
191
202
  personaId: spec.id,
192
203
  tier,
@@ -196,7 +207,18 @@ function buildSelection(spec, tier, kind) {
196
207
  ...(spec.inputs ? { inputs: spec.inputs } : {}),
197
208
  ...(spec.env ? { env: spec.env } : {}),
198
209
  ...(spec.mcpServers ? { mcpServers: spec.mcpServers } : {}),
199
- ...(spec.permissions ? { permissions: spec.permissions } : {})
210
+ ...(spec.permissions ? { permissions: spec.permissions } : {}),
211
+ ...(spec.mount ? { mount: spec.mount } : {}),
212
+ ...(sidecar.claudeMd ? { claudeMd: sidecar.claudeMd } : {}),
213
+ ...(sidecar.claudeMdContent ? { claudeMdContent: sidecar.claudeMdContent } : {}),
214
+ ...(sidecar.claudeMd || sidecar.claudeMdContent
215
+ ? { claudeMdMode: sidecar.claudeMdMode }
216
+ : {}),
217
+ ...(sidecar.agentsMd ? { agentsMd: sidecar.agentsMd } : {}),
218
+ ...(sidecar.agentsMdContent ? { agentsMdContent: sidecar.agentsMdContent } : {}),
219
+ ...(sidecar.agentsMd || sidecar.agentsMdContent
220
+ ? { agentsMdMode: sidecar.agentsMdMode }
221
+ : {})
200
222
  };
201
223
  }
202
224
  function emitDropWarnings(lines) {
@@ -368,7 +390,12 @@ export const CLEAN_IGNORED_PATTERNS = [
368
390
  'CLAUDE.md',
369
391
  'CLAUDE.local.md',
370
392
  '.claude',
371
- '.mcp.json'
393
+ '.mcp.json',
394
+ // Per-persona AGENTS.md sidecars get materialized into the mount when
395
+ // running under opencode; without this the user's real-cwd AGENTS.md
396
+ // would copy in (masking the persona content) and writes from
397
+ // onBeforeLaunch would sync back out.
398
+ 'AGENTS.md'
372
399
  ];
373
400
  /**
374
401
  * Skill-install artifacts that should never be copied into the mount nor
@@ -392,8 +419,32 @@ export const SKILL_INSTALL_IGNORED_PATTERNS = [
392
419
  '.skills',
393
420
  // provider lockfiles written at the repo root
394
421
  'prpm.lock',
395
- 'skills-lock.json'
422
+ 'skills-lock.json',
423
+ // Per-persona AGENTS.md sidecars (opencode harness) get materialized
424
+ // into the mount; hide so the real-cwd AGENTS.md isn't copied in and
425
+ // the persona-written copy doesn't sync back out.
426
+ 'AGENTS.md'
396
427
  ];
428
+ export function buildRelayfileMountPatterns(input) {
429
+ const dotfiles = readAgentDotfiles(input.projectDir, {
430
+ agentName: input.personaId
431
+ });
432
+ const builtInIgnored = input.harness === 'claude'
433
+ ? CLEAN_IGNORED_PATTERNS
434
+ : SKILL_INSTALL_IGNORED_PATTERNS;
435
+ return {
436
+ ignoredPatterns: [
437
+ ...dotfiles.ignoredPatterns,
438
+ ...(input.mount?.ignoredPatterns ?? []),
439
+ ...builtInIgnored,
440
+ ...(input.configFilePaths ?? [])
441
+ ],
442
+ readonlyPatterns: [
443
+ ...dotfiles.readonlyPatterns,
444
+ ...(input.mount?.readonlyPatterns ?? [])
445
+ ]
446
+ };
447
+ }
397
448
  /**
398
449
  * Build the block appended to `<mount>/.git/info/exclude` so untracked-and-
399
450
  * hidden files (e.g. `.claude/skills/` materialized by skill installs, or
@@ -465,6 +516,96 @@ export function configureGitForMount(mountDir, patterns) {
465
516
  }
466
517
  }
467
518
  }
519
+ /**
520
+ * Resolve the sidecar for a given selection + harness, returning the
521
+ * persona-author content the runtime should materialize into the mount.
522
+ * Returns `{}` when no sidecar applies (no path/content set, or harness
523
+ * doesn't support sidecar files at all). Read errors surface as a warning
524
+ * string so the caller can drop the sidecar gracefully rather than
525
+ * failing the whole session.
526
+ */
527
+ export function loadSidecarForSelection(selection) {
528
+ const harness = selection.runtime.harness;
529
+ if (harness !== 'claude' && harness !== 'opencode')
530
+ return {};
531
+ if (harness === 'claude') {
532
+ if (selection.claudeMdContent) {
533
+ return {
534
+ sidecar: {
535
+ mountFile: 'CLAUDE.md',
536
+ personaContent: selection.claudeMdContent,
537
+ mode: selection.claudeMdMode ?? 'overwrite'
538
+ }
539
+ };
540
+ }
541
+ if (selection.claudeMd) {
542
+ try {
543
+ const content = readFileSync(selection.claudeMd, 'utf8');
544
+ return {
545
+ sidecar: {
546
+ mountFile: 'CLAUDE.md',
547
+ personaContent: content,
548
+ mode: selection.claudeMdMode ?? 'overwrite'
549
+ }
550
+ };
551
+ }
552
+ catch (err) {
553
+ return { warning: `claudeMd: could not read ${selection.claudeMd}: ${err.message}` };
554
+ }
555
+ }
556
+ return {};
557
+ }
558
+ if (selection.agentsMdContent) {
559
+ return {
560
+ sidecar: {
561
+ mountFile: 'AGENTS.md',
562
+ personaContent: selection.agentsMdContent,
563
+ mode: selection.agentsMdMode ?? 'overwrite'
564
+ }
565
+ };
566
+ }
567
+ if (selection.agentsMd) {
568
+ try {
569
+ const content = readFileSync(selection.agentsMd, 'utf8');
570
+ return {
571
+ sidecar: {
572
+ mountFile: 'AGENTS.md',
573
+ personaContent: content,
574
+ mode: selection.agentsMdMode ?? 'overwrite'
575
+ }
576
+ };
577
+ }
578
+ catch (err) {
579
+ return { warning: `agentsMd: could not read ${selection.agentsMd}: ${err.message}` };
580
+ }
581
+ }
582
+ return {};
583
+ }
584
+ /**
585
+ * Compute the bytes to write into the mount for a sidecar. In `extend`
586
+ * mode, prepends the user's real-cwd file (if any) joined to the persona
587
+ * content with `\n\n---\n\n`. Pure — exposed for unit tests.
588
+ */
589
+ export function buildSidecarBody(sidecar, realCwdDir) {
590
+ if (sidecar.mode === 'extend') {
591
+ const realPath = join(realCwdDir, sidecar.mountFile);
592
+ try {
593
+ const realContent = readFileSync(realPath, 'utf8');
594
+ return `${realContent}\n\n---\n\n${sidecar.personaContent}`;
595
+ }
596
+ catch (err) {
597
+ // Only "missing path" errors degrade to overwrite. Real I/O
598
+ // problems (EACCES, EISDIR, …) propagate so callers see them
599
+ // instead of silently dropping the user's CLAUDE.md/AGENTS.md.
600
+ const code = err.code;
601
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
602
+ return sidecar.personaContent;
603
+ }
604
+ throw err;
605
+ }
606
+ }
607
+ return sidecar.personaContent;
608
+ }
468
609
  /**
469
610
  * Decide whether to run the interactive session inside a
470
611
  * `@relayfile/local-mount` sandbox.
@@ -482,7 +623,7 @@ export function decideCleanMode(harness, installInRepo = false) {
482
623
  }
483
624
  return { useClean: false };
484
625
  }
485
- async function runInteractive(selection, options = {}) {
626
+ async function runInteractive(selection, options) {
486
627
  const inputResolution = resolvePersonaInputs(selection.inputs, selection.inputValues, process.env);
487
628
  const renderedSystemPrompt = renderPersonaInputs(selection.runtime.systemPrompt, inputResolution.values);
488
629
  const effectiveSelection = {
@@ -500,6 +641,21 @@ async function runInteractive(selection, options = {}) {
500
641
  // below). The --install-in-repo flag forces legacy in-repo installs
501
642
  // across the board.
502
643
  const useClean = decideCleanMode(runtime.harness, options.installInRepo === true).useClean;
644
+ // Per-persona CLAUDE.md / AGENTS.md: load the author content if any. The
645
+ // file is materialized into the mount inside onBeforeLaunch (claude/
646
+ // opencode default). Without a mount (codex, --install-in-repo) we
647
+ // skip-and-warn — writing into the real cwd would pollute the user's
648
+ // repo and is explicitly out of scope.
649
+ const sidecarLookup = loadSidecarForSelection(effectiveSelection);
650
+ if (sidecarLookup.warning) {
651
+ process.stderr.write(`warning: ${sidecarLookup.warning}\n`);
652
+ }
653
+ const resolvedSidecar = useClean ? sidecarLookup.sidecar : undefined;
654
+ if (sidecarLookup.sidecar && !useClean) {
655
+ process.stderr.write(`warning: persona declares ${sidecarLookup.sidecar.mountFile} but no sandbox mount is available (` +
656
+ `${runtime.harness === 'codex' ? 'codex harness has no mount' : '--install-in-repo disengages the mount'})` +
657
+ `; skipping sidecar materialization to avoid writing into your repo.\n`);
658
+ }
503
659
  // A session dir is needed whenever we either (a) stage skills out-of-repo
504
660
  // via claude's installRoot, or (b) open a mount. Both engage for claude/
505
661
  // opencode by default; --install-in-repo disengages both.
@@ -511,6 +667,14 @@ async function runInteractive(selection, options = {}) {
511
667
  const ctx = useSelection(effectiveSelection, installRoot !== undefined ? { installRoot } : {});
512
668
  const { install } = ctx;
513
669
  process.stderr.write(`→ ${personaId} [${tier}] via ${runtime.harness} (${runtime.model})\n`);
670
+ const startLaunchMetadataForLaunch = (cwd = process.cwd()) => startLaunchMetadataRecording({
671
+ selection: effectiveSelection,
672
+ personaSpec: options.personaSpec,
673
+ personaSource: options.personaSource,
674
+ cwd,
675
+ noLaunchMetadata: options.noLaunchMetadata,
676
+ env: process.env
677
+ });
514
678
  const inputEnv = inputResolution.values;
515
679
  const callerEnv = { ...process.env, ...inputEnv };
516
680
  const envResolution = resolveStringMapLenient(effectiveSelection.env, callerEnv, 'env');
@@ -608,9 +772,7 @@ async function runInteractive(selection, options = {}) {
608
772
  // copied in from the real repo nor synced back on exit.
609
773
  if (useClean && sessionRoot) {
610
774
  const mountDir = sessionMountDir(sessionRoot);
611
- const ignoredPatterns = runtime.harness === 'claude'
612
- ? [...CLEAN_IGNORED_PATTERNS]
613
- : [...SKILL_INSTALL_IGNORED_PATTERNS];
775
+ let launchMetadata;
614
776
  // Anything we materialize into the mount via onBeforeLaunch must be
615
777
  // hidden from the mount-mirror in both directions: without this, any
616
778
  // opencode.json already present in the real repo would be copied into
@@ -618,9 +780,13 @@ async function runInteractive(selection, options = {}) {
618
780
  // fresh write from onBeforeLaunch would sync back out on exit and
619
781
  // pollute the user's working tree. Added dynamically so this stays
620
782
  // generic for any future configFile producer.
621
- for (const file of spec.configFiles) {
622
- ignoredPatterns.push(file.path);
623
- }
783
+ const { ignoredPatterns, readonlyPatterns } = buildRelayfileMountPatterns({
784
+ projectDir: process.cwd(),
785
+ personaId,
786
+ harness: runtime.harness,
787
+ mount: effectiveSelection.mount,
788
+ configFilePaths: spec.configFiles.map((file) => file.path)
789
+ });
624
790
  process.stderr.write(`• sandbox mount → ${mountDir}\n`);
625
791
  // Three-stage SIGINT handler layered on top of launchOnMount's own signal
626
792
  // forwarding. launchOnMount catches the first SIGINT to kill the child
@@ -696,6 +862,7 @@ async function runInteractive(selection, options = {}) {
696
862
  // `git push` to persist work — local-only commits evaporate with
697
863
  // the session.
698
864
  includeGit: true,
865
+ readonlyPatterns,
699
866
  // Second Ctrl-C aborts this signal → local-mount skips autosync's
700
867
  // draining reconcile and returns the partial syncBack count. Cleanup
701
868
  // still runs, so there's no leaked mount dir.
@@ -726,7 +893,7 @@ async function runInteractive(selection, options = {}) {
726
893
  process.stderr.write(`✓ ${message}\n`);
727
894
  }
728
895
  },
729
- onBeforeLaunch: (dir) => {
896
+ onBeforeLaunch: async (dir) => {
730
897
  // Run before install / configFile writes so the freshly written
731
898
  // files (e.g. `.opencode/`, `opencode.json`) aren't yet present
732
899
  // when we run `git ls-files` to pick skip-worktree candidates —
@@ -745,6 +912,11 @@ async function runInteractive(selection, options = {}) {
745
912
  mkdirSync(dirname(target), { recursive: true });
746
913
  writeFileSync(target, file.contents, 'utf8');
747
914
  }
915
+ if (resolvedSidecar) {
916
+ const body = buildSidecarBody(resolvedSidecar, process.cwd());
917
+ writeFileSync(join(dir, resolvedSidecar.mountFile), body, 'utf8');
918
+ }
919
+ launchMetadata = await startLaunchMetadataForLaunch(dir);
748
920
  }
749
921
  });
750
922
  return result.exitCode;
@@ -779,6 +951,7 @@ async function runInteractive(selection, options = {}) {
779
951
  syncSpinner.stop();
780
952
  syncSpinner = undefined;
781
953
  }
954
+ await launchMetadata?.stop();
782
955
  process.removeListener('SIGINT', forceExitHandler);
783
956
  // When the install ran inside the mount, its cleanup paths are
784
957
  // mount-relative (e.g. `.skills/<name>`, `skills/<name>`) and
@@ -792,6 +965,7 @@ async function runInteractive(selection, options = {}) {
792
965
  removeSessionRoot(sessionRoot);
793
966
  }
794
967
  }
968
+ const launchMetadata = await startLaunchMetadataForLaunch();
795
969
  return new Promise((resolve) => {
796
970
  let settled = false;
797
971
  const finish = (code) => {
@@ -800,7 +974,7 @@ async function runInteractive(selection, options = {}) {
800
974
  settled = true;
801
975
  runCleanup(install.cleanupCommand, install.cleanupCommandString);
802
976
  removeSessionRoot(sessionRoot);
803
- resolve(code);
977
+ void launchMetadata.stop().finally(() => resolve(code));
804
978
  };
805
979
  const child = spawn(spec.bin, finalArgs, {
806
980
  stdio: 'inherit',
@@ -1454,6 +1628,20 @@ function formatPersonaShow(spec, source, tiers, tierNote) {
1454
1628
  lines.push(` deny: ${perms.deny.join(', ')}`);
1455
1629
  }
1456
1630
  lines.push('');
1631
+ lines.push('MOUNT');
1632
+ const mount = spec.mount;
1633
+ if (!mount || (!mount.ignoredPatterns?.length && !mount.readonlyPatterns?.length)) {
1634
+ lines.push(' (none)');
1635
+ }
1636
+ else {
1637
+ if (mount.ignoredPatterns?.length) {
1638
+ lines.push(` ignored: ${mount.ignoredPatterns.join(', ')}`);
1639
+ }
1640
+ if (mount.readonlyPatterns?.length) {
1641
+ lines.push(` readonly: ${mount.readonlyPatterns.join(', ')}`);
1642
+ }
1643
+ }
1644
+ lines.push('');
1457
1645
  lines.push('ENV');
1458
1646
  const envKeys = Object.keys(spec.env ?? {});
1459
1647
  if (envKeys.length === 0) {
@@ -1584,7 +1772,10 @@ async function runAgentSelector(selector, flags, inputValues) {
1584
1772
  ...(inputValues ? { inputValues } : {})
1585
1773
  };
1586
1774
  const code = await runInteractive(selection, {
1587
- installInRepo: flags.installInRepo
1775
+ installInRepo: flags.installInRepo,
1776
+ noLaunchMetadata: flags.noLaunchMetadata,
1777
+ personaSpec: target.spec,
1778
+ personaSource: target.source
1588
1779
  });
1589
1780
  process.exit(code);
1590
1781
  }
@@ -1641,7 +1832,7 @@ export async function main() {
1641
1832
  await runAgentSelector(selector, flags);
1642
1833
  }
1643
1834
  export function parseAgentArgs(args) {
1644
- const flags = { installInRepo: false };
1835
+ const flags = { installInRepo: false, noLaunchMetadata: false };
1645
1836
  const positional = [];
1646
1837
  let seenDoubleDash = false;
1647
1838
  for (const arg of args) {
@@ -1657,6 +1848,10 @@ export function parseAgentArgs(args) {
1657
1848
  flags.installInRepo = true;
1658
1849
  continue;
1659
1850
  }
1851
+ if (arg === '--no-launch-metadata') {
1852
+ flags.noLaunchMetadata = true;
1853
+ continue;
1854
+ }
1660
1855
  if (arg === '-h' || arg === '--help') {
1661
1856
  process.stdout.write(USAGE);
1662
1857
  process.exit(0);
@@ -1666,7 +1861,7 @@ export function parseAgentArgs(args) {
1666
1861
  return { flags, positional };
1667
1862
  }
1668
1863
  export function parseCreateArgs(args) {
1669
- const flags = { installInRepo: false, saveDefault: false };
1864
+ const flags = { installInRepo: false, noLaunchMetadata: false, saveDefault: false };
1670
1865
  let seenDoubleDash = false;
1671
1866
  const positional = [];
1672
1867
  const valueOf = (i, flag) => {
@@ -1690,6 +1885,10 @@ export function parseCreateArgs(args) {
1690
1885
  flags.installInRepo = true;
1691
1886
  continue;
1692
1887
  }
1888
+ if (arg === '--no-launch-metadata') {
1889
+ flags.noLaunchMetadata = true;
1890
+ continue;
1891
+ }
1693
1892
  if (arg === '--to') {
1694
1893
  flags.to = valueOf(i, arg);
1695
1894
  i += 1;
@@ -1700,7 +1899,7 @@ export function parseCreateArgs(args) {
1700
1899
  continue;
1701
1900
  }
1702
1901
  if (arg === '-h' || arg === '--help') {
1703
- process.stdout.write('Usage: agentworkforce create [--to <cwd|user|dir:n|library|path>] [--save-default] [--install-in-repo]\n');
1902
+ process.stdout.write('Usage: agentworkforce create [--to <cwd|user|dir:n|library|path>] [--save-default] [--install-in-repo] [--no-launch-metadata]\n');
1704
1903
  process.exit(0);
1705
1904
  }
1706
1905
  positional.push(arg);