@agent-loom/loom 1.0.2 → 1.0.3

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 (145) hide show
  1. package/README.md +69 -0
  2. package/dist/acp/client.d.ts +182 -0
  3. package/dist/acp/client.d.ts.map +1 -0
  4. package/dist/acp/client.js +432 -0
  5. package/dist/acp/client.js.map +1 -0
  6. package/dist/acp/index.d.ts +5 -0
  7. package/dist/acp/index.d.ts.map +1 -0
  8. package/dist/acp/index.js +3 -0
  9. package/dist/acp/index.js.map +1 -0
  10. package/dist/acp/run.d.ts +41 -0
  11. package/dist/acp/run.d.ts.map +1 -0
  12. package/dist/acp/run.js +32 -0
  13. package/dist/acp/run.js.map +1 -0
  14. package/dist/apply.d.ts +15 -6
  15. package/dist/apply.d.ts.map +1 -1
  16. package/dist/apply.js +78 -49
  17. package/dist/apply.js.map +1 -1
  18. package/dist/chat/chat.d.ts +108 -0
  19. package/dist/chat/chat.d.ts.map +1 -0
  20. package/dist/chat/chat.js +221 -0
  21. package/dist/chat/chat.js.map +1 -0
  22. package/dist/chat/discovery.d.ts +30 -0
  23. package/dist/chat/discovery.d.ts.map +1 -0
  24. package/dist/chat/discovery.js +68 -0
  25. package/dist/chat/discovery.js.map +1 -0
  26. package/dist/chat/frontmatter.d.ts +12 -0
  27. package/dist/chat/frontmatter.d.ts.map +1 -0
  28. package/dist/chat/frontmatter.js +11 -0
  29. package/dist/chat/frontmatter.js.map +1 -0
  30. package/dist/chat/index.d.ts +16 -0
  31. package/dist/chat/index.d.ts.map +1 -0
  32. package/dist/chat/index.js +11 -0
  33. package/dist/chat/index.js.map +1 -0
  34. package/dist/chat/registry.d.ts +73 -0
  35. package/dist/chat/registry.d.ts.map +1 -0
  36. package/dist/chat/registry.js +118 -0
  37. package/dist/chat/registry.js.map +1 -0
  38. package/dist/chat/resolve-agent.d.ts +39 -0
  39. package/dist/chat/resolve-agent.d.ts.map +1 -0
  40. package/dist/chat/resolve-agent.js +36 -0
  41. package/dist/chat/resolve-agent.js.map +1 -0
  42. package/dist/chat/suggest.d.ts +20 -0
  43. package/dist/chat/suggest.d.ts.map +1 -0
  44. package/dist/chat/suggest.js +55 -0
  45. package/dist/chat/suggest.js.map +1 -0
  46. package/dist/cli.js +627 -75
  47. package/dist/cli.js.map +1 -1
  48. package/dist/clone.d.ts +21 -3
  49. package/dist/clone.d.ts.map +1 -1
  50. package/dist/clone.js +240 -12
  51. package/dist/clone.js.map +1 -1
  52. package/dist/copilot/mcp.d.ts +48 -0
  53. package/dist/copilot/mcp.d.ts.map +1 -0
  54. package/dist/copilot/mcp.js +146 -0
  55. package/dist/copilot/mcp.js.map +1 -0
  56. package/dist/copilot/resolve.d.ts +33 -0
  57. package/dist/copilot/resolve.d.ts.map +1 -0
  58. package/dist/copilot/resolve.js +96 -0
  59. package/dist/copilot/resolve.js.map +1 -0
  60. package/dist/copilot/spawn.d.ts +51 -0
  61. package/dist/copilot/spawn.d.ts.map +1 -0
  62. package/dist/copilot/spawn.js +132 -0
  63. package/dist/copilot/spawn.js.map +1 -0
  64. package/dist/index.d.ts +19 -0
  65. package/dist/index.d.ts.map +1 -0
  66. package/dist/index.js +15 -0
  67. package/dist/index.js.map +1 -0
  68. package/dist/launch/index.d.ts +10 -0
  69. package/dist/launch/index.d.ts.map +1 -0
  70. package/dist/launch/index.js +9 -0
  71. package/dist/launch/index.js.map +1 -0
  72. package/dist/launch/stage.d.ts +62 -0
  73. package/dist/launch/stage.d.ts.map +1 -0
  74. package/dist/launch/stage.js +108 -0
  75. package/dist/launch/stage.js.map +1 -0
  76. package/dist/manifest.d.ts +165 -18
  77. package/dist/manifest.d.ts.map +1 -1
  78. package/dist/manifest.js +980 -225
  79. package/dist/manifest.js.map +1 -1
  80. package/dist/renderers/claude.d.ts +5 -0
  81. package/dist/renderers/claude.d.ts.map +1 -1
  82. package/dist/renderers/claude.js +17 -3
  83. package/dist/renderers/claude.js.map +1 -1
  84. package/dist/renderers/copilot.d.ts +1 -1
  85. package/dist/renderers/copilot.d.ts.map +1 -1
  86. package/dist/renderers/copilot.js +205 -22
  87. package/dist/renderers/copilot.js.map +1 -1
  88. package/dist/repo-clone.js +17 -11
  89. package/dist/repo-clone.js.map +1 -1
  90. package/dist/resolve-template.d.ts +12 -4
  91. package/dist/resolve-template.d.ts.map +1 -1
  92. package/dist/resolve-template.js +39 -8
  93. package/dist/resolve-template.js.map +1 -1
  94. package/dist/run/index.d.ts +4 -0
  95. package/dist/run/index.d.ts.map +1 -0
  96. package/dist/run/index.js +2 -0
  97. package/dist/run/index.js.map +1 -0
  98. package/dist/run/run.d.ts +143 -0
  99. package/dist/run/run.d.ts.map +1 -0
  100. package/dist/run/run.js +406 -0
  101. package/dist/run/run.js.map +1 -0
  102. package/dist/search-registry.d.ts +10 -3
  103. package/dist/search-registry.d.ts.map +1 -1
  104. package/dist/search-registry.js +16 -16
  105. package/dist/search-registry.js.map +1 -1
  106. package/dist/sessions/index.d.ts +16 -0
  107. package/dist/sessions/index.d.ts.map +1 -0
  108. package/dist/sessions/index.js +15 -0
  109. package/dist/sessions/index.js.map +1 -0
  110. package/dist/sessions/store.d.ts +56 -0
  111. package/dist/sessions/store.d.ts.map +1 -0
  112. package/dist/sessions/store.js +220 -0
  113. package/dist/sessions/store.js.map +1 -0
  114. package/dist/sessions/types.d.ts +62 -0
  115. package/dist/sessions/types.d.ts.map +1 -0
  116. package/dist/sessions/types.js +5 -0
  117. package/dist/sessions/types.js.map +1 -0
  118. package/dist/skill-fetcher.d.ts.map +1 -1
  119. package/dist/skill-fetcher.js +5 -6
  120. package/dist/skill-fetcher.js.map +1 -1
  121. package/dist/types.d.ts +123 -41
  122. package/dist/types.d.ts.map +1 -1
  123. package/dist/types.js +12 -0
  124. package/dist/types.js.map +1 -1
  125. package/dist/util/binary-cache.d.ts +53 -0
  126. package/dist/util/binary-cache.d.ts.map +1 -0
  127. package/dist/util/binary-cache.js +211 -0
  128. package/dist/util/binary-cache.js.map +1 -0
  129. package/dist/util/frontmatter.d.ts +53 -0
  130. package/dist/util/frontmatter.d.ts.map +1 -0
  131. package/dist/util/frontmatter.js +85 -0
  132. package/dist/util/frontmatter.js.map +1 -0
  133. package/dist/util/loom-home.d.ts +19 -0
  134. package/dist/util/loom-home.d.ts.map +1 -0
  135. package/dist/util/loom-home.js +37 -0
  136. package/dist/util/loom-home.js.map +1 -0
  137. package/dist/util/workspace-folder.d.ts +29 -0
  138. package/dist/util/workspace-folder.d.ts.map +1 -0
  139. package/dist/util/workspace-folder.js +43 -0
  140. package/dist/util/workspace-folder.js.map +1 -0
  141. package/dist/validate.d.ts +7 -1
  142. package/dist/validate.d.ts.map +1 -1
  143. package/dist/validate.js +90 -17
  144. package/dist/validate.js.map +1 -1
  145. package/package.json +31 -2
package/dist/cli.js CHANGED
@@ -18,15 +18,23 @@ import { applyTemplate } from './apply.js';
18
18
  import { cloneRegistry, resolveTemplatePath } from './clone.js';
19
19
  import { resolveTemplateSource } from './resolve-template.js';
20
20
  import { createCraftFetcher } from './skill-fetcher.js';
21
- import { validateTemplate, validateRegistry } from './validate.js';
22
21
  import { loadWorkspaces, filterWorkspaces, recordWorkspace, removeWorkspace, addTag, setNote, saveWorkspaces, } from './workspaces.js';
22
+ import { chat, listWorkspaceAgents, findWorkspaceRoot, suggestClosest, resolveAgentInWorkspace, resolveAgentFromRegistry, listRegistryAgentIds, listRegistryAgents, } from './chat/index.js';
23
+ import { stageRegistryAgent } from './launch/index.js';
24
+ import { listSessions, readSession, updateSessionMeta, } from './sessions/index.js';
23
25
  const require = createRequire(import.meta.url);
24
26
  const { version } = require('../package.json');
25
27
  const program = new Command();
26
28
  program
27
29
  .name('loom')
28
30
  .description('Template engine for coding agent workspaces')
29
- .version(version);
31
+ .version(version)
32
+ // Route options to the subcommand that owns them. Without this, options
33
+ // declared on both the verbless `loom <agent>` form and on a subcommand
34
+ // (e.g. `--clone-repos`, `--registry`) get consumed by the program-level
35
+ // verbless declaration before subcommand parsing runs, leaving the
36
+ // subcommand's option at its default. Bug repro and rationale: #186.
37
+ .enablePositionalOptions();
30
38
  // =============================================================================
31
39
  // registry
32
40
  // =============================================================================
@@ -376,7 +384,7 @@ ws
376
384
  }
377
385
  addTag(entry, tag);
378
386
  await saveWorkspaces(data);
379
- console.log(chalk.green(`✓ Tag "${tag}" added.`));
387
+ console.log(chalk.green(`Tag "${tag}" added.`));
380
388
  });
381
389
  ws
382
390
  .command('note <path> <note>')
@@ -390,7 +398,7 @@ ws
390
398
  }
391
399
  setNote(entry, note);
392
400
  await saveWorkspaces(data);
393
- console.log(chalk.green(`✓ Note updated.`));
401
+ console.log(chalk.green('Note updated.'));
394
402
  });
395
403
  ws
396
404
  .command('remove <path>')
@@ -398,98 +406,642 @@ ws
398
406
  .action(async (path) => {
399
407
  const removed = await removeWorkspace(path);
400
408
  if (removed) {
401
- console.log(chalk.green(`✓ Workspace entry removed.`));
409
+ console.log(chalk.green('Workspace entry removed.'));
402
410
  }
403
411
  else {
404
412
  console.error(chalk.red(`Workspace "${path}" not found.`));
405
413
  process.exit(1);
406
414
  }
407
415
  });
408
- // =============================================================================
409
- // validate
410
- // =============================================================================
411
- program
412
- .command('validate [template-dir]')
413
- .description('Validate manifest.yaml schema and references')
414
- .option('-r, --registry-root <dir>', 'Registry root directory (for resolving shared/ refs)')
415
- .option('--all', 'Validate all templates in registry (requires --registry-root)')
416
- .action(async (templateDir, opts) => {
416
+ async function runChatCli(agentId, passthrough, opts) {
417
417
  try {
418
- if (opts.all) {
419
- const registryRoot = opts.registryRoot ?? '.';
420
- const results = await validateRegistry(registryRoot);
421
- let hasError = false;
422
- const templateResults = results.filter((r) => r.templateId !== '__registry__');
423
- const registryResults = results.filter((r) => r.templateId === '__registry__');
424
- // Print registry-level errors first
425
- for (const r of registryResults) {
426
- for (const e of r.errors) {
427
- console.log(chalk.red(` ✗ ${e.message}`));
428
- hasError = true;
429
- }
430
- }
431
- // Print per-template results
432
- const validCount = templateResults.filter((r) => r.errors.length === 0).length;
433
- console.log(chalk.blue(`\nValidating registry (${templateResults.length} templates)`));
434
- for (const r of templateResults) {
435
- if (r.errors.length === 0) {
436
- console.log(chalk.green(` ✓ ${r.templateId} ......... OK`));
418
+ const cwd = opts.cwd ?? process.cwd();
419
+ const runtime = (opts.runtime ?? 'copilot');
420
+ // Prefer `-- <args...>` passthrough. Fall back to deprecated
421
+ // --runtime-args <csv> for anyone who has the old form scripted.
422
+ let extraArgs;
423
+ if (passthrough && passthrough.length > 0) {
424
+ extraArgs = passthrough;
425
+ }
426
+ else if (opts.runtimeArgs) {
427
+ extraArgs = opts.runtimeArgs.split(',');
428
+ }
429
+ // Bolt-on flags from the new verb-less form. These get appended AFTER
430
+ // any explicit passthrough so users can still override them with `-- ...`.
431
+ const boltOn = [];
432
+ if (opts.addDir)
433
+ boltOn.push('--add-dir', opts.addDir);
434
+ if (opts.model)
435
+ boltOn.push('--model', opts.model);
436
+ if (boltOn.length > 0) {
437
+ extraArgs = [...(extraArgs ?? []), ...boltOn];
438
+ }
439
+ // No agent given -> show what's available in the resolved workspace.
440
+ if (!agentId) {
441
+ printAgentList(opts.workspace ?? cwd);
442
+ console.error();
443
+ console.error(chalk.dim('Usage: loom <agent>'));
444
+ return;
445
+ }
446
+ if (runtime !== 'copilot' && runtime !== 'claude') {
447
+ console.error(chalk.red(`Unknown runtime: ${runtime}. Use copilot or claude.`));
448
+ process.exit(2);
449
+ }
450
+ const launchCwd = opts.workspace ?? cwd;
451
+ const interactive = opts.interactive !== false;
452
+ // Step 1: try in-workspace resolution (walk-up unless --workspace is set).
453
+ const local = resolveAgentInWorkspace({
454
+ agentId,
455
+ cwd: launchCwd,
456
+ strictCwd: Boolean(opts.workspace),
457
+ });
458
+ if (local) {
459
+ const result = await chat({
460
+ agentId,
461
+ cwd: launchCwd,
462
+ resolved: local,
463
+ runtime,
464
+ prompt: opts.prompt,
465
+ interactive,
466
+ extraArgs,
467
+ sessionName: opts.sessionName,
468
+ recordSession: opts.session !== false,
469
+ });
470
+ process.exit(result.exitCode ?? 0);
471
+ }
472
+ // Step 2: fall back to configured registries (unless --no-registry-fallback).
473
+ if (opts.registryFallback !== false) {
474
+ const hit = await resolveAgentFromRegistry({
475
+ agentId,
476
+ registryName: typeof opts.registry === 'string' ? opts.registry : undefined,
477
+ workspaceDir: launchCwd,
478
+ });
479
+ if (hit) {
480
+ console.error(chalk.blue(` Staging ${chalk.bold(hit.agentId)} from ${hit.registryName}/${hit.templateId} ...`));
481
+ // Resolve --clone-repos. Commander parses `--clone-repos [mode]`
482
+ // as one of: undefined (flag absent), true (flag present with no
483
+ // value), or a string. Default is 'auto' since staged launches
484
+ // need repos to do real work.
485
+ let cloneReposMode = 'auto';
486
+ if (typeof opts.cloneRepos === 'string') {
487
+ if (!['auto', 'all', 'none'].includes(opts.cloneRepos)) {
488
+ throw new Error(`Invalid --clone-repos "${opts.cloneRepos}". Must be "auto", "all", or "none".`);
489
+ }
490
+ cloneReposMode = opts.cloneRepos;
437
491
  }
438
- else {
439
- hasError = true;
440
- console.log(chalk.red(` ✗ ${r.templateId} ......... ${r.errors.length} error(s)`));
441
- for (const e of r.errors) {
442
- console.log(chalk.red(` → ${e.message}`));
492
+ let cloneModeOpt;
493
+ if (typeof opts.cloneMode === 'string') {
494
+ if (!['full', 'partial'].includes(opts.cloneMode)) {
495
+ throw new Error(`Invalid --clone-mode "${opts.cloneMode}". Must be "full" or "partial".`);
443
496
  }
497
+ cloneModeOpt = opts.cloneMode;
444
498
  }
499
+ // Interactive prompt for large/ADO repos (mirrors `loom apply`).
500
+ const stagingPromptFn = async (message, _choices) => {
501
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
502
+ return new Promise((resolveP) => {
503
+ rl.question(chalk.yellow(` ${message}\n [clone / skip / enter path to existing clone]: `), (answer) => {
504
+ rl.close();
505
+ resolveP(answer.trim() || 'skip');
506
+ });
507
+ });
508
+ };
509
+ const stagingLinkFn = async (target, linkPath) => {
510
+ if (platform() === 'win32') {
511
+ execSync(`cmd /c mklink /J "${linkPath}" "${target}"`, { stdio: 'pipe' });
512
+ }
513
+ else {
514
+ const { symlink } = await import('node:fs/promises');
515
+ await symlink(target, linkPath, 'junction');
516
+ }
517
+ };
518
+ const staged = await stageRegistryAgent({
519
+ hit,
520
+ sessionName: opts.sessionName,
521
+ runtime,
522
+ onProgress: (m) => console.error(chalk.dim(` ${m}`)),
523
+ cloneRepos: cloneReposMode,
524
+ cloneMode: cloneModeOpt,
525
+ promptFn: cloneReposMode !== 'none' ? stagingPromptFn : undefined,
526
+ linkFn: cloneReposMode !== 'none' ? stagingLinkFn : undefined,
527
+ });
528
+ console.error(chalk.green(` Staged at ${staged.session.dir}`));
529
+ const result = await chat({
530
+ agentId,
531
+ cwd: staged.agent.workspaceDir,
532
+ resolved: staged.agent,
533
+ session: staged.session,
534
+ runtime,
535
+ prompt: opts.prompt,
536
+ interactive,
537
+ extraArgs,
538
+ });
539
+ process.exit(result.exitCode ?? 0);
445
540
  }
446
- console.log(`\nSummary: ${validCount}/${templateResults.length} templates valid`);
447
- if (hasError)
448
- process.exit(1);
449
541
  }
450
- else {
451
- if (!templateDir) {
452
- console.error(chalk.red('Please provide a template directory or use --all'));
453
- process.exit(1);
542
+ // Step 3: neither found -- friendly error with suggestions from both layers.
543
+ await printNotFoundHelp(agentId, launchCwd, opts.registryFallback !== false);
544
+ process.exit(1);
545
+ }
546
+ catch (err) {
547
+ const msg = err.message ?? String(err);
548
+ console.error(chalk.red(msg));
549
+ process.exit(1);
550
+ }
551
+ }
552
+ /**
553
+ * Attach the chat option set to a Commander command.
554
+ */
555
+ function applyChatOptions(cmd) {
556
+ return cmd
557
+ .option('-p, --prompt <text>', 'Prefilled / one-shot prompt (use --no-interactive for headless single-shot)')
558
+ .option('--no-interactive', 'Run non-interactively (single-shot, exits after the prompt)')
559
+ .option('--add-dir <path>', 'Additional directory passed to the runtime via --add-dir')
560
+ .option('--model <name>', 'Model override (forwarded to the runtime via --model)')
561
+ .option('-r, --runtime <runtime>', 'Runtime: copilot or claude', 'copilot')
562
+ .option('-w, --workspace <dir>', 'Override workspace (skips walk-up discovery)')
563
+ .option('--cwd <dir>', 'Working directory (defaults to current)')
564
+ .option('--runtime-args <args>', '(deprecated) comma-separated runtime args; prefer `-- <args...>`')
565
+ .option('--session-name <name>', 'Friendly label stored on the session record')
566
+ .option('--no-session', 'Do not record a session under ~/.loom/sessions/')
567
+ .option('--no-registry-fallback', 'Disable the registry fallback (fail fast if agent not in workspace)')
568
+ .option('--registry <name>', 'Only search the named registry when falling back')
569
+ .option('--clone-repos [mode]', 'When staging from a registry, clone manifest repos: auto (default), all, or none')
570
+ .option('--clone-mode <strategy>', 'Clone strategy: full (default) or partial (blobless)');
571
+ }
572
+ // Verb-less default: `loom <agent> [-- ...]`. When no agent is given (and
573
+ // no subcommand matched), this falls through to the agent-list helper.
574
+ applyChatOptions(program
575
+ .argument('[agent]', 'Agent name to launch interactively (verb-less form)')
576
+ .argument('[passthrough...]', 'Args forwarded to the runtime (after --)')
577
+ .description('Launch a single agent interactively (verb-less form). Falls back to configured registries if the agent is not in the current workspace.')
578
+ .addHelpText('after', `
579
+ Examples:
580
+ $ loom triage Launch 'triage' interactively
581
+ $ loom triage -p "investigate X" Launch with a prefilled prompt
582
+ $ loom triage --model opus Use a specific model
583
+ $ loom triage -- --some-runtime-flag Pass --some-runtime-flag through
584
+ $ loom -w C:\\path\\to\\ws triage Use explicit workspace (no walk-up)
585
+ $ loom List available agents (no agent given)
586
+
587
+ For headless / one-shot execution see \`loom run\` (or import from
588
+ '@agent-loom/loom/run' as an SDK).
589
+ `)).action(async (agentId, passthrough, opts) => {
590
+ await runChatCli(agentId, passthrough, opts);
591
+ });
592
+ // =============================================================================
593
+ // run -- headless single-agent execution (SDK-equivalent CLI surface)
594
+ // =============================================================================
595
+ program
596
+ .command('run <template> <agent>')
597
+ .description('Run an agent headlessly and collect artifacts (SESSION, REPORT, PROGRESS)')
598
+ .requiredOption('--prompt <text>', 'Prompt text or @filepath')
599
+ .requiredOption('--work-dir <path>', 'Working directory for the run (required, no temp default)')
600
+ .option('--model <name>', 'Model override')
601
+ .option('--timeout-ms <n>', 'Timeout in milliseconds', (v) => parseInt(v, 10))
602
+ .action(async (template, agentId, opts) => {
603
+ const { run: runFn } = await import('./run/index.js');
604
+ try {
605
+ const result = await runFn({
606
+ template,
607
+ agent: agentId,
608
+ workDir: opts.workDir,
609
+ prompt: opts.prompt,
610
+ model: opts.model,
611
+ // Library handles the default (DEFAULT_RUN_TIMEOUT_MS); only override
612
+ // when the user supplied --timeout-ms explicitly.
613
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
614
+ });
615
+ if (result.artifactPaths.session)
616
+ console.log(`SESSION: ${result.artifactPaths.session}`);
617
+ if (result.artifactPaths.report)
618
+ console.log(`REPORT: ${result.artifactPaths.report}`);
619
+ if (result.artifactPaths.progress)
620
+ console.log(`PROGRESS: ${result.artifactPaths.progress}`);
621
+ if (result.timedOut)
622
+ console.error(chalk.red('Run timed out.'));
623
+ process.exit(result.exitCode);
624
+ }
625
+ catch (err) {
626
+ console.error(chalk.red(err.message));
627
+ process.exit(1);
628
+ }
629
+ });
630
+ /** Helper: aggregate workspace + registry agent ids for the not-found message. */
631
+ async function printNotFoundHelp(agentId, cwd, registryEnabled) {
632
+ console.error(chalk.red(`Agent "${agentId}" not found.`));
633
+ const workspace = findWorkspaceRoot(cwd);
634
+ const localIds = workspace ? listWorkspaceAgents(workspace).map((a) => a.agentId) : [];
635
+ const registryIds = registryEnabled
636
+ ? await listRegistryAgentIds({ workspaceDir: cwd }).catch(() => [])
637
+ : [];
638
+ const pool = Array.from(new Set([...localIds, ...registryIds]));
639
+ const suggestions = suggestClosest(agentId, pool);
640
+ if (suggestions.length > 0) {
641
+ console.error();
642
+ console.error(chalk.yellow(`Did you mean: ${suggestions.join(', ')}?`));
643
+ }
644
+ if (workspace && localIds.length > 0) {
645
+ console.error();
646
+ console.error(chalk.dim(`In workspace ${workspace}:`));
647
+ for (const a of localIds)
648
+ console.error(chalk.dim(` ${a}`));
649
+ }
650
+ if (registryEnabled && registryIds.length > 0) {
651
+ console.error();
652
+ console.error(chalk.dim(`In configured registries:`));
653
+ for (const a of registryIds)
654
+ console.error(chalk.dim(` ${a}`));
655
+ }
656
+ if (pool.length === 0) {
657
+ console.error();
658
+ console.error(chalk.dim('No agents found locally and no registries configured.'));
659
+ console.error(chalk.dim('Add a registry with: loom registry add <name> <url>'));
660
+ }
661
+ }
662
+ // =============================================================================
663
+ // agents — list agents in the current workspace
664
+ // =============================================================================
665
+ program
666
+ .command('agents [scope]')
667
+ .description('List agents in the current workspace (use --all for every registry-installable agent). Scope drills: <registry> or <registry>/<template>')
668
+ .option('--cwd <dir>', 'Working directory (defaults to current)')
669
+ .option('--names-only', 'Output just agent ids, one per line (for scripts / tab completion)')
670
+ .option('--all', 'List every agent available across configured registries')
671
+ .action(async (scope, opts) => {
672
+ const cwd = opts.cwd ?? process.cwd();
673
+ if (opts.all) {
674
+ const parts = (scope ?? '').split('/').map((s) => s.trim()).filter(Boolean);
675
+ const registryFilter = parts[0];
676
+ const templateFilter = parts[1];
677
+ const listings = await listRegistryAgents({
678
+ workspaceDir: cwd,
679
+ registryName: registryFilter,
680
+ });
681
+ if (opts.namesOnly) {
682
+ const filtered = templateFilter
683
+ ? listings.filter((l) => l.templateId === templateFilter)
684
+ : listings;
685
+ for (const l of filtered)
686
+ process.stdout.write(l.agentId + '\n');
687
+ return;
688
+ }
689
+ if (listings.length === 0) {
690
+ console.error(chalk.yellow(registryFilter
691
+ ? `No agents found in registry "${registryFilter}".`
692
+ : 'No agents found in configured registries.'));
693
+ console.error(chalk.dim(' Add one: loom registry add <name> <url>'));
694
+ return;
695
+ }
696
+ // Level 1: no scope → registry summary (skipped when only one registry)
697
+ const allRegistryNames = Array.from(new Set(listings.map((l) => l.registryName)));
698
+ if (!registryFilter && allRegistryNames.length > 1) {
699
+ const byReg = new Map();
700
+ for (const l of listings) {
701
+ const e = byReg.get(l.registryName) ?? { teams: new Set(), agents: 0 };
702
+ e.teams.add(l.templateId);
703
+ e.agents++;
704
+ byReg.set(l.registryName, e);
454
705
  }
455
- const registryRoot = opts.registryRoot ?? templateDir.replace(/[/\\]templates[/\\].*$/, '');
456
- const result = await validateTemplate(templateDir, registryRoot);
457
- console.log(chalk.blue(`\nValidating template: ${result.templateId}`));
458
- if (result.errors.length === 0) {
459
- console.log(chalk.green(' ✓ manifest.yaml valid'));
460
- const s = result.summary;
461
- if (s.instructions > 0)
462
- console.log(chalk.green(` ✓ ${s.instructions} instructions resolved`));
463
- if (s.skills > 0)
464
- console.log(chalk.green(` ✓ ${s.skills} skills`));
465
- if (s.agents > 0)
466
- console.log(chalk.green(` ✓ ${s.agents} agents resolved`));
467
- if (s.mcp > 0)
468
- console.log(chalk.green(` ✓ ${s.mcp} MCP servers resolved`));
469
- if (s.repos > 0)
470
- console.log(chalk.green(` ✓ ${s.repos} repos resolved`));
471
- if (s.prompts > 0)
472
- console.log(chalk.green(` ✓ ${s.prompts} prompts resolved`));
473
- if (s.prerequisites > 0)
474
- console.log(chalk.green(` ✓ ${s.prerequisites} prerequisites resolved`));
475
- console.log(chalk.green('\n0 errors'));
706
+ const rows = Array.from(byReg.entries()).sort((a, b) => a[0].localeCompare(b[0]));
707
+ const regCol = Math.max(8, ...rows.map((r) => r[0].length));
708
+ console.error(chalk.bold.dim(` ${'REGISTRY'.padEnd(regCol)} TEAMS AGENTS`));
709
+ console.error(chalk.dim(' ' + '-'.repeat(regCol + 18)));
710
+ for (const [name, e] of rows) {
711
+ console.error(` ${chalk.bold(name.padEnd(regCol))} ` +
712
+ `${String(e.teams.size).padStart(5)} ${String(e.agents).padStart(6)}`);
476
713
  }
477
- else {
478
- for (const e of result.errors) {
479
- console.log(chalk.red(` ✗ [${e.level}] ${e.field}: ${e.message}`));
480
- }
481
- console.log(chalk.red(`\n${result.errors.length} error(s)`));
482
- process.exit(1);
714
+ console.error(chalk.dim(`\n Drill down: loom agents --all <registry>\n` +
715
+ ` e.g. loom agents --all ${rows[0][0]}`));
716
+ return;
717
+ }
718
+ // Level 2: registry scope (or single-registry auto-collapse) → team summary
719
+ const effectiveRegistry = registryFilter ?? allRegistryNames[0];
720
+ if (!templateFilter) {
721
+ const byTeam = new Map();
722
+ for (const l of listings) {
723
+ if (l.registryName !== effectiveRegistry)
724
+ continue;
725
+ const e = byTeam.get(l.templateId) ?? { agents: 0, sample: [] };
726
+ e.agents++;
727
+ if (e.sample.length < 3)
728
+ e.sample.push(l.agentId);
729
+ byTeam.set(l.templateId, e);
730
+ }
731
+ const rows = Array.from(byTeam.entries()).sort((a, b) => a[0].localeCompare(b[0]));
732
+ const teamCol = Math.max(4, ...rows.map((r) => r[0].length));
733
+ console.error(chalk.bold(effectiveRegistry) + chalk.dim(allRegistryNames.length > 1 ? '' : ' (only registry)'));
734
+ console.error();
735
+ console.error(chalk.bold.dim(` ${'TEAM'.padEnd(teamCol)} AGENTS SAMPLE`));
736
+ console.error(chalk.dim(' ' + '-'.repeat(teamCol + 40)));
737
+ for (const [name, e] of rows) {
738
+ console.error(` ${chalk.bold(name.padEnd(teamCol))} ` +
739
+ `${String(e.agents).padStart(6)} ` +
740
+ `${chalk.dim(e.sample.join(', ') + (e.agents > e.sample.length ? ', ...' : ''))}`);
483
741
  }
742
+ console.error(chalk.dim(`\n Drill down: loom agents --all ${effectiveRegistry}/<team>\n` +
743
+ ` e.g. loom agents --all ${effectiveRegistry}/${rows[0][0]}`));
744
+ return;
745
+ }
746
+ // Level 3: registry/team scope → agent list
747
+ const filtered = listings.filter((l) => l.registryName === effectiveRegistry && l.templateId === templateFilter);
748
+ if (filtered.length === 0) {
749
+ console.error(chalk.yellow(`No team "${templateFilter}" in registry "${effectiveRegistry}".`));
750
+ console.error(chalk.dim(` List teams: loom agents --all ${effectiveRegistry}`));
751
+ return;
484
752
  }
753
+ const idCol = Math.max(5, ...filtered.map((l) => l.agentId.length));
754
+ console.error(chalk.bold(`${effectiveRegistry} / ${templateFilter}`));
755
+ console.error();
756
+ console.error(chalk.bold.dim(` ${'AGENT'.padEnd(idCol)} DESCRIPTION`));
757
+ console.error(chalk.dim(' ' + '-'.repeat(idCol + 60)));
758
+ for (const l of filtered) {
759
+ const desc = l.description ?? chalk.dim('(no description)');
760
+ console.error(` ${chalk.bold(l.agentId.padEnd(idCol))} ${chalk.dim(desc)}`);
761
+ }
762
+ console.error(chalk.dim(`\n ${filtered.length} ${filtered.length === 1 ? 'agent' : 'agents'}. ` +
763
+ `Launch one with: loom <agent>`));
764
+ return;
485
765
  }
486
- catch (err) {
487
- console.error(chalk.red(`Error: ${err.message}`));
766
+ if (opts.namesOnly) {
767
+ const workspace = findWorkspaceRoot(cwd);
768
+ if (!workspace)
769
+ return;
770
+ for (const a of listWorkspaceAgents(workspace)) {
771
+ process.stdout.write(a.agentId + '\n');
772
+ }
773
+ return;
774
+ }
775
+ printAgentList(cwd);
776
+ });
777
+ function printAgentList(cwd) {
778
+ const workspace = findWorkspaceRoot(cwd);
779
+ if (!workspace) {
780
+ console.error(chalk.yellow('No workspace found.'));
781
+ console.error(chalk.dim(` Walked up from: ${cwd}\n` +
782
+ ` Looking for an ancestor containing .github/agents/`));
783
+ return;
784
+ }
785
+ const agents = listWorkspaceAgents(workspace);
786
+ console.error(chalk.dim(`workspace: ${workspace}`));
787
+ console.error();
788
+ if (agents.length === 0) {
789
+ console.error(chalk.yellow('No agents found in .github/agents/'));
790
+ console.error(chalk.dim(' See what you could install: loom agents --all'));
791
+ return;
792
+ }
793
+ const idCol = Math.max(5, ...agents.map((a) => a.agentId.length));
794
+ console.error(chalk.bold.dim(` ${'AGENT'.padEnd(idCol)} DESCRIPTION`));
795
+ console.error(chalk.dim(' ' + '-'.repeat(idCol + 2 + 60)));
796
+ for (const a of agents) {
797
+ const name = a.metadata.name ?? '';
798
+ const desc = a.metadata.description ?? chalk.dim('(no description)');
799
+ const nameCell = name && name !== a.agentId ? chalk.dim(` ${name}`) : '';
800
+ console.error(` ${chalk.bold(a.agentId.padEnd(idCol))}${nameCell}`);
801
+ if (a.metadata.description)
802
+ console.error(` ${chalk.dim(desc)}`);
803
+ }
804
+ console.error(chalk.dim(`\n ${agents.length} ${agents.length === 1 ? 'agent' : 'agents'}. ` +
805
+ `Launch one with: loom <agent>`));
806
+ }
807
+ // =============================================================================
808
+ // sessions — inspect & manage session records
809
+ // =============================================================================
810
+ const sessions = program
811
+ .command('sessions')
812
+ .description('Inspect launch sessions (~/.loom/sessions/)');
813
+ sessions
814
+ .command('list')
815
+ .description('List recent sessions, newest first')
816
+ .option('-n, --limit <n>', 'Max sessions to show', '20')
817
+ .option('--status <s>', 'Filter by status (running|exited|errored)')
818
+ .option('--agent <id>', 'Filter by agent id')
819
+ .option('--ids-only', 'Output just session ids, one per line (for scripts / tab completion)')
820
+ .action((opts) => {
821
+ const limit = Math.max(1, parseInt(opts.limit, 10) || 20);
822
+ let all = listSessions();
823
+ if (opts.status)
824
+ all = all.filter((s) => s.meta.status === opts.status);
825
+ if (opts.agent)
826
+ all = all.filter((s) => s.meta.agentId === opts.agent);
827
+ if (opts.idsOnly) {
828
+ for (const s of all.slice(0, limit))
829
+ process.stdout.write(s.meta.id + '\n');
830
+ return;
831
+ }
832
+ if (all.length === 0) {
833
+ console.error(chalk.dim('No sessions found.'));
834
+ return;
835
+ }
836
+ const shown = all.slice(0, limit);
837
+ // Column widths sized to content; keep status at a fixed width for color padding.
838
+ const idW = Math.max(2, ...shown.map((s) => s.meta.id.length));
839
+ const agentW = Math.max(5, ...shown.map((s) => s.meta.agentId.length));
840
+ const whenW = 19; // YYYY-MM-DD HH:MM:SS
841
+ const statusW = 7; // 'running' is longest
842
+ const header = ` ${'ID'.padEnd(idW)} ${'STATUS'.padEnd(statusW)} ${'STARTED'.padEnd(whenW)} ${'AGENT'.padEnd(agentW)} EXIT NAME`;
843
+ console.log(chalk.bold.dim(header));
844
+ console.log(chalk.dim(' ' + '-'.repeat(header.length - 2)));
845
+ for (const s of shown) {
846
+ const status = s.meta.status.padEnd(statusW);
847
+ const colored = s.meta.status === 'running' ? chalk.cyan(status) :
848
+ s.meta.status === 'exited' && s.meta.exitCode === 0 ? chalk.green(status) :
849
+ chalk.red(status);
850
+ const when = s.meta.startedAt.slice(0, 19).replace('T', ' ');
851
+ const exit = s.meta.exitCode == null ? ' - ' : String(s.meta.exitCode).padEnd(4);
852
+ const name = s.meta.name ? chalk.dim(`"${s.meta.name}"`) : '';
853
+ console.log(` ${s.meta.id.padEnd(idW)} ${colored} ${chalk.dim(when)} ${chalk.bold(s.meta.agentId.padEnd(agentW))} ${exit} ${name}`);
854
+ }
855
+ const total = all.length;
856
+ const hidden = total - shown.length;
857
+ const footer = hidden > 0
858
+ ? chalk.dim(`\n Showing ${shown.length} of ${total}. Use -n <N> to show more.`)
859
+ : chalk.dim(`\n ${total} ${total === 1 ? 'session' : 'sessions'}.`);
860
+ console.error(footer);
861
+ });
862
+ sessions
863
+ .command('show <id>')
864
+ .description('Show full metadata for a session')
865
+ .action((id) => {
866
+ const s = readSession(id);
867
+ if (!s) {
868
+ console.error(chalk.red(`Session not found: ${id}`));
869
+ process.exit(1);
870
+ }
871
+ const m = s.meta;
872
+ const statusColored = m.status === 'running' ? chalk.cyan(m.status) :
873
+ m.status === 'exited' && m.exitCode === 0 ? chalk.green(m.status) :
874
+ chalk.red(m.status);
875
+ console.log(chalk.bold(m.id));
876
+ console.log(chalk.dim(s.dir));
877
+ console.log();
878
+ const row = (label, value) => console.log(` ${chalk.dim(label.padEnd(10))} ${value}`);
879
+ if (m.name)
880
+ row('name', m.name);
881
+ row('agent', m.agentName && m.agentName !== m.agentId
882
+ ? `${m.agentId} ${chalk.dim(`(${m.agentName})`)}` : m.agentId);
883
+ row('runtime', m.runtime);
884
+ row('origin', m.origin);
885
+ row('workspace', m.workspaceDir);
886
+ row('status', m.exitCode != null ? `${statusColored} ${chalk.dim(`exit=${m.exitCode}`)}` : statusColored);
887
+ row('started', m.startedAt);
888
+ if (m.endedAt)
889
+ row('ended', m.endedAt);
890
+ if (m.durationMs != null)
891
+ row('duration', humanizeDuration(m.durationMs));
892
+ if (m.tags && m.tags.length > 0)
893
+ row('tags', m.tags.join(', '));
894
+ if (m.note)
895
+ row('note', m.note);
896
+ console.log();
897
+ console.log(chalk.dim(` Tag: loom sessions tag ${m.id} <tag>`));
898
+ console.log(chalk.dim(` Note: loom sessions note ${m.id} "<text>"`));
899
+ });
900
+ function humanizeDuration(ms) {
901
+ if (ms < 1000)
902
+ return `${ms}ms`;
903
+ const s = Math.floor(ms / 1000);
904
+ if (s < 60)
905
+ return `${s}s`;
906
+ const m = Math.floor(s / 60);
907
+ const rs = s % 60;
908
+ if (m < 60)
909
+ return rs > 0 ? `${m}m ${rs}s` : `${m}m`;
910
+ const h = Math.floor(m / 60);
911
+ const rm = m % 60;
912
+ return rm > 0 ? `${h}h ${rm}m` : `${h}h`;
913
+ }
914
+ sessions
915
+ .command('tag <id> <tag>')
916
+ .description('Add a tag to a session')
917
+ .action((id, tag) => {
918
+ const s = readSession(id);
919
+ if (!s) {
920
+ console.error(chalk.red(`Session not found: ${id}`));
921
+ process.exit(1);
922
+ }
923
+ const tags = Array.from(new Set([...(s.meta.tags ?? []), tag]));
924
+ updateSessionMeta(id, { tags });
925
+ console.error(chalk.green(`Tagged ${id} with "${tag}"`));
926
+ });
927
+ sessions
928
+ .command('note <id> <note>')
929
+ .description('Set a note on a session')
930
+ .action((id, note) => {
931
+ const s = readSession(id);
932
+ if (!s) {
933
+ console.error(chalk.red(`Session not found: ${id}`));
488
934
  process.exit(1);
489
935
  }
936
+ updateSessionMeta(id, { note });
937
+ console.error(chalk.green(`Note set on ${id}`));
490
938
  });
491
939
  // =============================================================================
492
- // Run
940
+ // completion — shell tab-completion scripts
493
941
  // =============================================================================
942
+ const POWERSHELL_COMPLETER = [
943
+ '# loom PowerShell tab completion',
944
+ '# Install: Add to your $PROFILE, or: loom completion powershell | Out-String | Invoke-Expression',
945
+ 'Register-ArgumentCompleter -Native -CommandName loom -ScriptBlock {',
946
+ ' param($wordToComplete, $commandAst, $cursorPosition)',
947
+ ' $tokens = $commandAst.CommandElements | ForEach-Object { $_.ToString() }',
948
+ ' $sub = if ($tokens.Count -ge 2) { $tokens[1] } else { \'\' }',
949
+ '',
950
+ ' $subcommands = @(',
951
+ ' \'run\',\'agents\',\'sessions\',\'apply\',\'search\',\'registry\',\'workspaces\',',
952
+ ' \'completion\',\'validate\',\'scaffold\'',
953
+ ' )',
954
+ '',
955
+ ' if ($tokens.Count -le 1 -or ($tokens.Count -eq 2 -and $wordToComplete)) {',
956
+ ' $subcommands |',
957
+ ' Where-Object { $_ -like "$wordToComplete*" } |',
958
+ ' ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, \'ParameterValue\', $_) }',
959
+ ' return',
960
+ ' }',
961
+ '',
962
+ ' switch ($sub) {',
963
+ ' \'run\' {',
964
+ ' if (-not $wordToComplete.StartsWith(\'-\')) {',
965
+ ' try {',
966
+ ' (& loom agents --names-only 2>$null) -split [Environment]::NewLine |',
967
+ ' Where-Object { $_ -and $_ -like "$wordToComplete*" } |',
968
+ ' ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, \'ParameterValue\', $_) }',
969
+ ' } catch {}',
970
+ ' }',
971
+ ' }',
972
+ ' \'sessions\' {',
973
+ ' $sessionSub = if ($tokens.Count -ge 3) { $tokens[2] } else { \'\' }',
974
+ ' if (@(\'show\',\'tag\',\'note\') -contains $sessionSub -and -not $wordToComplete.StartsWith(\'-\')) {',
975
+ ' try {',
976
+ ' (& loom sessions list --ids-only -n 50 2>$null) -split [Environment]::NewLine |',
977
+ ' Where-Object { $_ -and $_ -like "$wordToComplete*" } |',
978
+ ' ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, \'ParameterValue\', $_) }',
979
+ ' } catch {}',
980
+ ' } elseif ($tokens.Count -le 2 -or ($tokens.Count -eq 3 -and $wordToComplete)) {',
981
+ ' @(\'list\',\'show\',\'tag\',\'note\') |',
982
+ ' Where-Object { $_ -like "$wordToComplete*" } |',
983
+ ' ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, \'ParameterValue\', $_) }',
984
+ ' }',
985
+ ' }',
986
+ ' }',
987
+ '}',
988
+ '',
989
+ ].join('\n');
990
+ const BASH_COMPLETER = [
991
+ '# loom bash tab completion',
992
+ '# Install: loom completion bash > /etc/bash_completion.d/loom (or source in .bashrc)',
993
+ '_loom_completion() {',
994
+ ' local cur prev words cword',
995
+ ' COMPREPLY=()',
996
+ ' cur="${COMP_WORDS[COMP_CWORD]}"',
997
+ ' prev="${COMP_WORDS[COMP_CWORD-1]}"',
998
+ ' local sub="${COMP_WORDS[1]:-}"',
999
+ ' local subcommands="run agents sessions apply search registry workspaces completion validate scaffold"',
1000
+ '',
1001
+ ' if [ "$COMP_CWORD" -eq 1 ]; then',
1002
+ ' COMPREPLY=( $(compgen -W "$subcommands" -- "$cur") )',
1003
+ ' return 0',
1004
+ ' fi',
1005
+ '',
1006
+ ' case "$sub" in',
1007
+ ' run)',
1008
+ ' if [[ "$cur" != -* && "$prev" != -* ]]; then',
1009
+ ' local agents',
1010
+ ' agents=$(loom agents --names-only 2>/dev/null)',
1011
+ ' COMPREPLY=( $(compgen -W "$agents" -- "$cur") )',
1012
+ ' fi',
1013
+ ' ;;',
1014
+ ' sessions)',
1015
+ ' local ssub="${COMP_WORDS[2]:-}"',
1016
+ ' if [[ "$ssub" =~ ^(show|tag|note)$ && "$cur" != -* ]]; then',
1017
+ ' local ids',
1018
+ ' ids=$(loom sessions list --ids-only -n 50 2>/dev/null)',
1019
+ ' COMPREPLY=( $(compgen -W "$ids" -- "$cur") )',
1020
+ ' elif [ "$COMP_CWORD" -eq 2 ]; then',
1021
+ ' COMPREPLY=( $(compgen -W "list show tag note" -- "$cur") )',
1022
+ ' fi',
1023
+ ' ;;',
1024
+ ' esac',
1025
+ '}',
1026
+ 'complete -F _loom_completion loom',
1027
+ '',
1028
+ ].join('\n');
1029
+ program
1030
+ .command('completion <shell>')
1031
+ .description('Emit tab-completion script for powershell or bash')
1032
+ .action((shell) => {
1033
+ const s = shell.toLowerCase();
1034
+ if (s === 'powershell' || s === 'pwsh') {
1035
+ process.stdout.write(POWERSHELL_COMPLETER);
1036
+ }
1037
+ else if (s === 'bash') {
1038
+ process.stdout.write(BASH_COMPLETER);
1039
+ }
1040
+ else {
1041
+ console.error(chalk.red(`Unsupported shell: ${shell}`));
1042
+ console.error(chalk.dim('Supported: powershell, bash'));
1043
+ process.exit(1);
1044
+ }
1045
+ });
494
1046
  program.parse();
495
1047
  //# sourceMappingURL=cli.js.map