@phnx-labs/agents-cli 1.20.13 → 1.20.15

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/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ **`agents inspect` summary: expanded detail for hooks, plugins, and MCP**
6
+
7
+ - The bare `agents inspect <agent>` / `agents inspect <repo>` summary no longer collapses everything to a count table. Simple kinds (commands, skills, rules, subagents, workflows) keep a count line but now preview a few names; the rich kinds get their own expanded sections: **hooks** show their events + `matches:` predicates + cache (`PreToolUse(Bash) · git_dirty · prompt~"deploy" (5m cache)`), **plugins** show version + bundle contents (`v2.1.0 skills:6 commands:5 hooks:2 mcp:1`), and **MCP** show transport + url/command. Drill-down flags (`--hooks`, `--plugins`, `--mcp`) and `--brief` are unchanged; `--json` gains the structured detail additively (existing keys retained).
8
+ - Hook detail joins installed hooks to the manifest by **script basename** (installed hooks are named after their script file while the manifest keys on the logical name), and the repo Hooks section uses the grouped hook reader so a script + its data file collapse to one clean entry.
9
+
10
+ **Plugin hooks were misreported — fixed**
11
+
12
+ - `discoverPluginHooks` read the **top-level** keys of a plugin's `hooks/hooks.json`, so the official `{ description, hooks: { SessionStart: [...] } }` format surfaced as `description, hooks` instead of the real events. It now reads the `hooks` wrapper when present (falling back to top-level keys for the flat format), so `agents inspect --plugin <name>` and the plugin row show the actual lifecycle events (e.g. `SessionStart, PreToolUse, …`).
13
+
14
+ **`agents doctor` / `agents prune`: precise orphan-hooks detection**
15
+
16
+ - Orphan-hook detection now flags hook scripts present in a version home that **no `agents.yaml`/`hooks.yaml` entry registers** — i.e. scripts that sync to disk but are never wired to a lifecycle event, so they never fire. This replaces the source-diff heuristic, which compared only against the user hooks dir and so **false-flagged valid system-sourced, registered hooks** (e.g. `03-linear-inject`, `04-capture`) as orphans — meaning `agents prune cleanup` could have deleted live hooks. Doctor's Orphans section and `prune cleanup hooks` now share this single manifest-based definition. `parseHookManifest` gained a silent (`{ warn: false }`) option so the diagnostic doesn't emit shadow/override warnings.
17
+
18
+ **Regression coverage: resource sync from extras repos**
19
+
20
+ - Added end-to-end regression tests (`src/lib/__tests__/extras-sync.test.ts`) locking in two behaviors for repos registered via `agents repo add` (`~/.agents-<alias>/`): a top-level `commands/<name>.md` is written into the agent's version home on `agents sync`, and plugins under `plugins/<name>/` are synthesized into a registered `agents-<alias>` marketplace on launch. Both already work in `main`; the tests exercise the real sync path (no mocking, isolated `$HOME`) so the extras-repo behavior can't silently regress (#313, #314).
21
+
5
22
  **Windows: `agents` is discoverable right after `npm i -g`**
6
23
 
7
24
  - On a global Windows install, postinstall now prepends npm's global-bin dir (where `agents.cmd`/`agents.ps1` live) to the **User PATH** via the .NET environment API. Node's installer normally adds it, but winget / portable / nvm-windows setups often don't — and then `npm i -g @phnx-labs/agents-cli` succeeds yet `agents` is "not recognized". The shims dir (claude/codex/…) is still left to `agents setup`, which the user can now run because `agents` resolves.
@@ -5,9 +5,10 @@ import { getGlobalDefault, getVersionHomePath, isVersionInstalled, listInstalled
5
5
  import { loadManifest, isStale } from '../lib/staleness/index.js';
6
6
  import { diffVersionCommands, iterCommandsCapableVersions } from '../lib/commands.js';
7
7
  import { diffVersionSkills, iterSkillsCapableVersions } from '../lib/skills.js';
8
- import { diffVersionHooks, iterHooksCapableVersions } from '../lib/hooks.js';
8
+ import { iterHooksCapableVersions, listUnmanagedHooksInVersionHome } from '../lib/hooks.js';
9
9
  import { diffVersionResources, DOCTOR_ALL_KINDS, } from '../lib/doctor-diff.js';
10
10
  import { unifiedDiff, colorizeUnifiedDiff } from '../lib/diff-text.js';
11
+ import { listCliStatus } from '../lib/cli-resources.js';
11
12
  import { setHelpSections } from '../lib/help.js';
12
13
  import * as fs from 'fs';
13
14
  const AGENT_NAMES = Object.fromEntries(ALL_AGENT_IDS.map((id) => [id, AGENTS[id].name]));
@@ -53,16 +54,20 @@ function countOrphans() {
53
54
  if (diff.orphans.length > 0)
54
55
  ensure(agent, version).skills = diff.orphans.length;
55
56
  }
57
+ // Orphan hooks are scripts in the version home that no agents.yaml/hooks.yaml
58
+ // entry registers — so the registrar never wires them to an event and they
59
+ // never fire. (Distinct from the source-diff `diffVersionHooks().orphans`,
60
+ // which false-flags valid system-sourced registered hooks.)
56
61
  for (const { agent, version } of iterHooksCapableVersions()) {
57
62
  if (version !== getGlobalDefault(agent))
58
63
  continue;
59
- const diff = diffVersionHooks(agent, version);
60
- if (diff.orphans.length > 0)
61
- ensure(agent, version).hooks = diff.orphans.length;
64
+ const dead = listUnmanagedHooksInVersionHome(agent, version);
65
+ if (dead.length > 0)
66
+ ensure(agent, version).hooks = dead.length;
62
67
  }
63
68
  return Array.from(byKey.values()).filter((r) => r.commands + r.skills + r.hooks > 0);
64
69
  }
65
- function renderOverviewText(clis, syncRows, orphanRows) {
70
+ function renderOverviewText(clis, syncRows, orphanRows, hostClis) {
66
71
  console.log(chalk.bold('Agent CLIs'));
67
72
  if (Object.keys(clis).length === 0) {
68
73
  console.log(chalk.gray(' (no agents reported)'));
@@ -116,6 +121,31 @@ function renderOverviewText(clis, syncRows, orphanRows) {
116
121
  }
117
122
  console.log(chalk.gray(' Run `agents prune cleanup` to remove.'));
118
123
  }
124
+ console.log();
125
+ // Host CLIs are host-global (declared in any DotAgents repo's cli/, installed
126
+ // to PATH — not synced into version homes), so they live in the overview, not
127
+ // the per-version resource diff. Source tag shows which repo layer declared
128
+ // each, including user-level and extra repos.
129
+ console.log(chalk.bold('Host CLIs'));
130
+ if (hostClis.statuses.length === 0) {
131
+ console.log(chalk.gray(' (none declared — add one with `agents cli add <name>`)'));
132
+ }
133
+ else {
134
+ const nameWidth = Math.max(...hostClis.statuses.map((s) => s.manifest.name.length));
135
+ for (const { manifest, installed } of hostClis.statuses) {
136
+ const label = manifest.name.padEnd(nameWidth);
137
+ const src = chalk.gray(`[${manifest.source}]`);
138
+ if (installed) {
139
+ console.log(` ${chalk.green('ready')} ${label} ${src} ${chalk.gray(manifest.description || '')}`);
140
+ }
141
+ else {
142
+ console.log(` ${chalk.red('miss ')} ${label} ${src} ${chalk.gray(`not installed — run \`agents cli install ${manifest.name}\``)}`);
143
+ }
144
+ }
145
+ }
146
+ for (const err of hostClis.errors) {
147
+ console.log(` ${chalk.red('err ')} ${chalk.gray(err.file)}: ${chalk.gray(err.reason)}`);
148
+ }
119
149
  }
120
150
  function parseTargetArg(arg) {
121
151
  const at = arg.indexOf('@');
@@ -346,11 +376,25 @@ export function registerDoctorCommand(program) {
346
376
  const clis = checkAllClis();
347
377
  const syncRows = checkSyncStatus(cwd);
348
378
  const orphanRows = countOrphans();
379
+ const hostClis = listCliStatus(cwd);
349
380
  if (opts.json) {
350
- console.log(JSON.stringify({ clis, sync: syncRows, orphans: orphanRows }, null, 2));
381
+ console.log(JSON.stringify({
382
+ clis,
383
+ sync: syncRows,
384
+ orphans: orphanRows,
385
+ hostClis: {
386
+ statuses: hostClis.statuses.map((s) => ({
387
+ name: s.manifest.name,
388
+ source: s.manifest.source,
389
+ description: s.manifest.description ?? null,
390
+ installed: s.installed,
391
+ })),
392
+ errors: hostClis.errors,
393
+ },
394
+ }, null, 2));
351
395
  return;
352
396
  }
353
- renderOverviewText(clis, syncRows, orphanRows);
397
+ renderOverviewText(clis, syncRows, orphanRows, hostClis);
354
398
  return;
355
399
  }
356
400
  const parsed = parseTargetArg(target);
@@ -12,6 +12,8 @@
12
12
  * AGENT.md / the file itself) so users can click straight to the source.
13
13
  */
14
14
  import { Command } from 'commander';
15
+ import type { ManifestHook } from '../lib/types.js';
16
+ import { type McpYamlConfig } from '../lib/mcp.js';
15
17
  import { type PluginResourceGroup } from '../lib/plugins.js';
16
18
  /** Resource kinds the inspect command can drill into. */
17
19
  declare const DRILLABLE_KINDS: readonly ["commands", "skills", "hooks", "mcp", "rules", "plugins", "workflows", "subagents"];
@@ -101,4 +103,19 @@ export interface RepoGitInfo {
101
103
  behind: number | null;
102
104
  }
103
105
  export declare function repoGitInfo(root: string): RepoGitInfo | null;
106
+ /**
107
+ * Compact one-liner for a hook from its manifest entry: the firing events (with
108
+ * the matcher/tool-name in parens), then a `·`-separated predicate summary, then
109
+ * an optional cache tail. Plain text — the caller applies color.
110
+ */
111
+ export declare function summarizeHook(hook: ManifestHook): string;
112
+ /** Compact one-liner for an MCP server: padded transport + the url (http) or command line (stdio). */
113
+ export declare function summarizeMcp(cfg: McpYamlConfig): string;
114
+ /**
115
+ * Index a hook manifest by script basename (no extension). Installed hooks are
116
+ * named after their script file (`04-capture-…`), while the manifest is keyed by
117
+ * logical name (`capture-…`) with the filename in `script:` — so we join on the
118
+ * script basename, not the manifest key.
119
+ */
120
+ export declare function hookManifestByScript(manifest: Record<string, ManifestHook>): Map<string, ManifestHook>;
104
121
  export {};
@@ -23,6 +23,8 @@ import { readMeta, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, ge
23
23
  import { getVersionHomePath } from '../lib/versions.js';
24
24
  import { getShimsDir, getVersionedAliasPath } from '../lib/shims.js';
25
25
  import { getAgentResources, listResources, } from '../lib/resources.js';
26
+ import { listHookEntriesFromDir } from '../lib/hooks.js';
27
+ import { listMcpServerConfigs, discoverMcpConfigsFromRepo } from '../lib/mcp.js';
26
28
  import { discoverPlugins, discoverPluginsInDir, pluginResourceGroups } from '../lib/plugins.js';
27
29
  import { PLUGIN_GROUP_COLORS } from './plugins.js';
28
30
  import { countSessionsInScope } from '../lib/session/discover.js';
@@ -38,6 +40,14 @@ const DRILLABLE_KINDS = [
38
40
  'workflows',
39
41
  'subagents',
40
42
  ];
43
+ /**
44
+ * Summary-view partition. SIMPLE kinds render as a one-line count + name preview;
45
+ * RICH kinds (hooks/plugins/mcp) get their own expanded section showing each
46
+ * item's key detail (events/predicates, bundle contents, transport/url). Together
47
+ * they cover every DrillableKind.
48
+ */
49
+ const SIMPLE_KINDS = ['commands', 'skills', 'rules', 'subagents', 'workflows'];
50
+ const RICH_KINDS = ['hooks', 'plugins', 'mcp'];
41
51
  /**
42
52
  * Singular aliases for the plural drill-down flags. `--plugin code` reads as
43
53
  * "show the one plugin named code" — a required-value flag that always lands in
@@ -355,6 +365,9 @@ function renderRepoSummary(repo, options) {
355
365
  const manifest = repoManifestSummary(repo.root);
356
366
  const kindData = {};
357
367
  let totalBytes = 0, totalFiles = 0;
368
+ let repoHookByScript = new Map();
369
+ let repoHookItemList = [];
370
+ let repoMcpConfigs = new Map();
358
371
  if (!options.brief) {
359
372
  for (const kind of DRILLABLE_KINDS) {
360
373
  const items = collectRepoKind(repo, kind);
@@ -363,6 +376,9 @@ function renderRepoSummary(repo, options) {
363
376
  totalBytes += size.bytes;
364
377
  totalFiles += size.files;
365
378
  }
379
+ repoHookByScript = hookManifestByScript(hookManifestFromFile(path.join(repo.root, 'agents.yaml')));
380
+ repoHookItemList = repoHookItems(repo);
381
+ repoMcpConfigs = new Map(discoverMcpConfigsFromRepo(repo.root).map(s => [s.name, s.config]));
366
382
  }
367
383
  if (options.json) {
368
384
  console.log(JSON.stringify({
@@ -372,12 +388,34 @@ function renderRepoSummary(repo, options) {
372
388
  manifests,
373
389
  manifest,
374
390
  size: options.brief ? null : { bytes: totalBytes, files: totalFiles },
375
- resources: options.brief ? null : Object.fromEntries(DRILLABLE_KINDS.map(kind => [kind, {
376
- count: kindData[kind].items.length,
377
- bytes: kindData[kind].size.bytes,
378
- files: kindData[kind].size.files,
379
- names: kindData[kind].items.map(i => i.name),
380
- }])),
391
+ resources: options.brief ? null : Object.fromEntries(DRILLABLE_KINDS.map(kind => {
392
+ const size = kindData[kind].size;
393
+ // Hooks use the grouped reader (clean names) instead of the raw readdir.
394
+ const items = kind === 'hooks' ? repoHookItemList : kindData[kind].items;
395
+ const base = {
396
+ count: items.length,
397
+ bytes: size.bytes,
398
+ files: size.files,
399
+ names: items.map(i => i.name),
400
+ };
401
+ if (kind === 'hooks')
402
+ return [kind, { ...base, items: items.map(i => {
403
+ const h = repoHookByScript.get(i.name);
404
+ return { name: i.name, events: h?.events ?? [], matcher: h?.matcher, matches: h?.matches, cache: h?.cache };
405
+ }) }];
406
+ if (kind === 'mcp')
407
+ return [kind, { ...base, items: items.map(i => {
408
+ const c = repoMcpConfigs.get(i.name);
409
+ return { name: i.name, transport: c?.transport, url: c?.url, command: c?.command, args: c?.args };
410
+ }) }];
411
+ if (kind === 'plugins')
412
+ return [kind, { ...base, items: items.map(i => ({
413
+ name: i.name,
414
+ version: i.extra?.find(([k]) => k === 'version')?.[1],
415
+ groups: Object.fromEntries((i.groups ?? []).map(g => [g.label, g.items.length])),
416
+ })) }];
417
+ return [kind, base];
418
+ })),
381
419
  }, null, 2));
382
420
  return;
383
421
  }
@@ -416,13 +454,16 @@ function renderRepoSummary(repo, options) {
416
454
  if (!options.brief) {
417
455
  console.log(` ${'size'.padEnd(10)} ${formatBytes(totalBytes)} ${chalk.gray('·')} ${totalFiles} files`);
418
456
  console.log('\n' + chalk.bold('Resources'));
419
- for (const kind of DRILLABLE_KINDS) {
457
+ for (const kind of SIMPLE_KINDS) {
420
458
  const { items, size } = kindData[kind];
421
459
  const count = String(items.length).padStart(4);
422
460
  const sz = items.length > 0 ? formatBytes(size.bytes).padStart(8) : ''.padEnd(8);
423
461
  const preview = items.length > 0 ? chalk.gray(truncate(previewNames(items, 4), 60)) : '';
424
462
  console.log(` ${kind.padEnd(10)} ${count} ${sz} ${preview}`.trimEnd());
425
463
  }
464
+ printExpandedSection('Hooks', hookRows(repoHookItemList, repoHookByScript));
465
+ printExpandedSection('Plugins', pluginRows(kindData.plugins.items));
466
+ printExpandedSection('MCP', mcpRows(kindData.mcp.items, repoMcpConfigs));
426
467
  }
427
468
  console.log('');
428
469
  console.log(chalk.gray(`Drill in: agents inspect ${repo.label} --skills <query>`));
@@ -480,7 +521,9 @@ async function renderSummary(agent, version, versionHome, options) {
480
521
  const shimPath = path.join(getShimsDir(), AGENTS[agent].cliCommand);
481
522
  const aliasPath = getVersionedAliasPath(agent, version);
482
523
  const capabilities = collectCapabilities(agent, version);
483
- const counts = options.brief ? null : collectCounts(agent, versionHome);
524
+ const itemsByKind = options.brief ? null : collectItemsByKind(agent, versionHome);
525
+ const hookByScript = options.brief ? null : hookManifestByScript(loadCentralHookManifest());
526
+ const mcpConfigs = options.brief ? null : new Map(listMcpServerConfigs().map(s => [s.name, s.config]));
484
527
  const sessions = options.brief ? null : {
485
528
  total: safeCountSessions(agent),
486
529
  };
@@ -496,7 +539,7 @@ async function renderSummary(agent, version, versionHome, options) {
496
539
  strategy,
497
540
  installedShim: cliState?.installed === true ? cliState.path : null,
498
541
  capabilities,
499
- resources: counts,
542
+ resources: itemsByKind ? summaryResourcesJson(itemsByKind, hookByScript, mcpConfigs) : null,
500
543
  sessions,
501
544
  };
502
545
  console.log(JSON.stringify(json, null, 2));
@@ -521,15 +564,14 @@ async function renderSummary(agent, version, versionHome, options) {
521
564
  const reason = res.ok ? '' : chalk.gray(`(${res.reason}${res.need ? ' ' + res.need : ''})`);
522
565
  console.log(` ${cap.padEnd(10)} ${mark} ${reason}`);
523
566
  }
524
- if (counts) {
567
+ if (itemsByKind) {
525
568
  console.log('\n' + chalk.bold('Resources'));
526
- for (const kind of DRILLABLE_KINDS) {
527
- const c = counts[kind];
528
- if (!c)
529
- continue;
530
- const breakdown = formatScopeBreakdown(c.bySource);
531
- console.log(` ${kind.padEnd(10)} ${String(c.total).padStart(4)} ${chalk.gray(breakdown)}`);
569
+ for (const kind of SIMPLE_KINDS) {
570
+ printSimpleResourceRow(kind, itemsByKind[kind]);
532
571
  }
572
+ printExpandedSection('Hooks', hookRows(itemsByKind.hooks, hookByScript));
573
+ printExpandedSection('Plugins', pluginRows(itemsByKind.plugins));
574
+ printExpandedSection('MCP', mcpRows(itemsByKind.mcp, mcpConfigs));
533
575
  }
534
576
  if (sessions) {
535
577
  console.log('\n' + chalk.bold('Sessions'));
@@ -649,14 +691,52 @@ function collectCapabilities(agent, version) {
649
691
  }
650
692
  return out;
651
693
  }
652
- function collectCounts(agent, versionHome) {
694
+ function collectItemsByKind(agent, versionHome) {
695
+ const out = {};
696
+ for (const kind of DRILLABLE_KINDS)
697
+ out[kind] = collectKind(agent, versionHome, kind);
698
+ return out;
699
+ }
700
+ /** A simple-kind count row: `kind N user:30 system:12 name, name …(+K)`. */
701
+ function printSimpleResourceRow(kind, items) {
702
+ const count = String(items.length).padStart(4);
703
+ const breakdown = chalk.gray(scopeBreakdownPlain(countBySource(items.map(i => i.source))).padEnd(18));
704
+ const preview = items.length > 0 ? chalk.gray(truncate(previewNames(items, 3), 48)) : '';
705
+ console.log(` ${kind.padEnd(10)} ${count} ${breakdown} ${preview}`.trimEnd());
706
+ }
707
+ /**
708
+ * Build the `resources` JSON: every kind keeps `total` + `bySource` (back-compat),
709
+ * simple kinds add `names`, and the rich kinds add structured `items` (hook
710
+ * events/predicates, mcp transport/url/command, plugin version + group counts).
711
+ */
712
+ function summaryResourcesJson(itemsByKind, hookByScript, mcpConfigs) {
653
713
  const out = {};
654
714
  for (const kind of DRILLABLE_KINDS) {
655
- const items = collectKind(agent, versionHome, kind);
656
- const bySource = {};
657
- for (const item of items)
658
- bySource[item.source] = (bySource[item.source] || 0) + 1;
659
- out[kind] = { total: items.length, bySource };
715
+ const items = itemsByKind[kind];
716
+ const base = { total: items.length, bySource: countBySource(items.map(i => i.source)) };
717
+ if (kind === 'hooks') {
718
+ out[kind] = { ...base, items: items.map(i => {
719
+ const h = hookByScript.get(i.name);
720
+ return { name: i.name, source: i.source, events: h?.events ?? [], matcher: h?.matcher, matches: h?.matches, cache: h?.cache };
721
+ }) };
722
+ }
723
+ else if (kind === 'mcp') {
724
+ out[kind] = { ...base, items: items.map(i => {
725
+ const c = mcpConfigs.get(i.name);
726
+ return { name: i.name, source: i.source, transport: c?.transport, url: c?.url, command: c?.command, args: c?.args };
727
+ }) };
728
+ }
729
+ else if (kind === 'plugins') {
730
+ out[kind] = { ...base, items: items.map(i => ({
731
+ name: i.name,
732
+ source: i.source,
733
+ version: i.extra?.find(([k]) => k === 'version')?.[1],
734
+ groups: Object.fromEntries((i.groups ?? []).map(g => [g.label, g.items.length])),
735
+ })) };
736
+ }
737
+ else {
738
+ out[kind] = { ...base, names: items.map(i => i.name) };
739
+ }
660
740
  }
661
741
  return out;
662
742
  }
@@ -782,6 +862,181 @@ function buildDetailRows(item, kind) {
782
862
  }
783
863
  return rows;
784
864
  }
865
+ /** `system` → `sys`; everything else unchanged. Keeps the tag column narrow. */
866
+ function abbrevSource(s) {
867
+ return s === 'system' ? 'sys' : s;
868
+ }
869
+ /**
870
+ * Compact one-liner for a hook from its manifest entry: the firing events (with
871
+ * the matcher/tool-name in parens), then a `·`-separated predicate summary, then
872
+ * an optional cache tail. Plain text — the caller applies color.
873
+ */
874
+ export function summarizeHook(hook) {
875
+ const events = (hook.events ?? []).join('/') || '(no event)';
876
+ let matcher = hook.matcher;
877
+ if (!matcher && hook.matches?.tool_name) {
878
+ const tn = hook.matches.tool_name;
879
+ matcher = Array.isArray(tn) ? tn.join('|') : tn;
880
+ }
881
+ const head = matcher ? `${events}(${matcher})` : events;
882
+ const parts = [head];
883
+ const preds = summarizeMatches(hook.matches);
884
+ if (preds)
885
+ parts.push(preds);
886
+ let line = parts.join(' · ');
887
+ const ttl = hookCacheTtl(hook.cache);
888
+ if (ttl)
889
+ line += ` (${ttl} cache)`;
890
+ return line;
891
+ }
892
+ /** `·`-separated predicate summary from a hook's `matches:` block (tool_name omitted — shown in the matcher parens). */
893
+ function summarizeMatches(m) {
894
+ if (!m)
895
+ return '';
896
+ const bits = [];
897
+ if (m.git_dirty)
898
+ bits.push('git_dirty');
899
+ if (m.prompt_contains)
900
+ bits.push(`prompt~"${truncate(m.prompt_contains, 24)}"`);
901
+ if (m.prompt_matches)
902
+ bits.push(`prompt=/${truncate(m.prompt_matches, 24)}/`);
903
+ if (m.tool_args_match)
904
+ bits.push(`args=/${truncate(m.tool_args_match, 20)}/`);
905
+ if (m.cwd_includes) {
906
+ const c = Array.isArray(m.cwd_includes) ? m.cwd_includes.join('|') : m.cwd_includes;
907
+ bits.push(`cwd~${truncate(c, 24)}`);
908
+ }
909
+ if (m.project_has)
910
+ bits.push(`has ${m.project_has}`);
911
+ return bits.join(' · ');
912
+ }
913
+ /** Normalize a hook cache shorthand/object to a display ttl ("5m", "1h"); null when uncached. */
914
+ function hookCacheTtl(cache) {
915
+ if (cache === undefined || cache === null)
916
+ return null;
917
+ if (typeof cache === 'string')
918
+ return cache.replace(/-bg$/, '');
919
+ return String(cache.ttl);
920
+ }
921
+ /** Compact one-liner for an MCP server: padded transport + the url (http) or command line (stdio). */
922
+ export function summarizeMcp(cfg) {
923
+ const target = cfg.transport === 'http'
924
+ ? (cfg.url ?? '')
925
+ : [cfg.command, ...(cfg.args ?? [])].filter(Boolean).join(' ');
926
+ return `${cfg.transport.padEnd(5)} ${truncate(target, 60)}`.trimEnd();
927
+ }
928
+ /** Print `Title (N)` then up to `max` aligned `[source] name detail` rows with a `…(+K)` tail. */
929
+ function printExpandedSection(title, rows, max = 6) {
930
+ console.log('\n' + chalk.bold(title) + chalk.gray(` (${rows.length})`));
931
+ if (rows.length === 0) {
932
+ console.log(chalk.gray(' (none)'));
933
+ return;
934
+ }
935
+ const shown = rows.slice(0, max);
936
+ const nameW = Math.max(...shown.map(r => r.name.length));
937
+ for (const r of shown) {
938
+ const tag = chalk.gray(`[${abbrevSource(r.source)}]`.padEnd(8));
939
+ const padded = r.name.padEnd(nameW);
940
+ const name = r.linkTarget ? termLink(chalk.cyan(padded), r.linkTarget) : chalk.cyan(padded);
941
+ const detail = r.detail ? ' ' + chalk.gray(r.detail) : '';
942
+ console.log(` ${tag} ${name}${detail}`);
943
+ }
944
+ if (rows.length > max)
945
+ console.log(chalk.gray(` …(+${rows.length - max})`));
946
+ }
947
+ /** Tally a source list into `{user: n, system: m}`. */
948
+ function countBySource(sources) {
949
+ const out = {};
950
+ for (const s of sources)
951
+ out[s] = (out[s] || 0) + 1;
952
+ return out;
953
+ }
954
+ /** Unbracketed scope breakdown for the simple count rows: `user:30 system:12`. */
955
+ function scopeBreakdownPlain(bySource) {
956
+ return Object.entries(bySource).map(([k, v]) => `${k}:${v}`).join(' ');
957
+ }
958
+ /**
959
+ * Index a hook manifest by script basename (no extension). Installed hooks are
960
+ * named after their script file (`04-capture-…`), while the manifest is keyed by
961
+ * logical name (`capture-…`) with the filename in `script:` — so we join on the
962
+ * script basename, not the manifest key.
963
+ */
964
+ export function hookManifestByScript(manifest) {
965
+ const out = new Map();
966
+ for (const hook of Object.values(manifest)) {
967
+ if (hook && typeof hook.script === 'string') {
968
+ out.set(path.basename(hook.script).replace(/\.[^.]+$/, ''), hook);
969
+ }
970
+ }
971
+ return out;
972
+ }
973
+ /** Build hook rows by enriching the installed hook items with manifest events/predicates. */
974
+ function hookRows(items, byScript) {
975
+ return items.map(item => {
976
+ const hook = byScript.get(item.name);
977
+ return {
978
+ source: item.source,
979
+ name: item.name,
980
+ linkTarget: item.linkTarget,
981
+ // Hooks are shell scripts with no human description — show events/predicates
982
+ // from the manifest, or nothing rather than a meaningless shebang line.
983
+ detail: hook ? summarizeHook(hook) : '',
984
+ };
985
+ });
986
+ }
987
+ /** Build plugin rows: `vVERSION skills:6 commands:5 …` from the bundle's groups. */
988
+ function pluginRows(items) {
989
+ return items.map(item => {
990
+ const version = item.extra?.find(([k]) => k === 'version')?.[1];
991
+ const counts = (item.groups ?? []).map(g => `${g.label}:${g.items.length}`).join(' ');
992
+ const detail = [version ? `v${version}` : '', counts].filter(Boolean).join(' ');
993
+ return { source: item.source, name: item.name, detail, linkTarget: item.linkTarget };
994
+ });
995
+ }
996
+ /** Build MCP rows by joining the installed mcp items with their full configs (transport/url/command). */
997
+ function mcpRows(items, configs) {
998
+ return items.map(item => {
999
+ const cfg = configs.get(item.name);
1000
+ return { source: item.source, name: item.name, detail: cfg ? summarizeMcp(cfg) : item.description };
1001
+ });
1002
+ }
1003
+ /** Read a repo/agents.yaml `hooks:` section into a name→ManifestHook map (best-effort). */
1004
+ function hookManifestFromFile(agentsYamlPath) {
1005
+ try {
1006
+ const meta = yaml.parse(fs.readFileSync(agentsYamlPath, 'utf-8'));
1007
+ return meta?.hooks ?? {};
1008
+ }
1009
+ catch {
1010
+ return {};
1011
+ }
1012
+ }
1013
+ /**
1014
+ * Merge the system + user `agents.yaml` hook manifests (user wins on key
1015
+ * collision). Built directly from the two layer files rather than via
1016
+ * `parseHookManifest()` so inspecting never emits the shadow/override warnings
1017
+ * that the registrar path prints.
1018
+ */
1019
+ function loadCentralHookManifest() {
1020
+ return {
1021
+ ...hookManifestFromFile(path.join(getSystemAgentsDir(), 'agents.yaml')),
1022
+ ...hookManifestFromFile(path.join(getUserAgentsDir(), 'agents.yaml')),
1023
+ };
1024
+ }
1025
+ /**
1026
+ * Hook items for a repo's Hooks section. Uses the grouped hook reader (script +
1027
+ * data file collapsed into one entry, non-hook files like promptcuts.yaml or
1028
+ * README.md filtered out) rather than a naive readdir, so names are clean and
1029
+ * join cleanly against the manifest by script basename.
1030
+ */
1031
+ function repoHookItems(repo) {
1032
+ return listHookEntriesFromDir(path.join(repo.root, 'hooks')).map(h => ({
1033
+ name: h.name,
1034
+ source: repo.label,
1035
+ path: h.scriptPath,
1036
+ linkTarget: h.scriptPath,
1037
+ description: '',
1038
+ }));
1039
+ }
785
1040
  function findMatches(items, query) {
786
1041
  const q = query.toLowerCase();
787
1042
  const out = [];
@@ -979,9 +1234,3 @@ function truncate(s, n) {
979
1234
  return s;
980
1235
  return s.slice(0, n - 1) + '…';
981
1236
  }
982
- function formatScopeBreakdown(bySource) {
983
- const entries = Object.entries(bySource);
984
- if (entries.length === 0)
985
- return '';
986
- return '[' + entries.map(([k, v]) => `${k}:${v}`).join(' ') + ']';
987
- }
@@ -26,7 +26,7 @@ import chalk from 'chalk';
26
26
  import { confirm } from '@inquirer/prompts';
27
27
  import { diffVersionCommands, iterCommandsCapableVersions, removeCommandFromVersion, } from '../lib/commands.js';
28
28
  import { diffVersionSkills, iterSkillsCapableVersions, removeSkillFromVersion, } from '../lib/skills.js';
29
- import { diffVersionHooks, iterHooksCapableVersions, removeHookFromVersion, } from '../lib/hooks.js';
29
+ import { listUnmanagedHooksInVersionHome, iterHooksCapableVersions, removeHookFromVersion, } from '../lib/hooks.js';
30
30
  import { diffVersionPlugins, iterPluginsCapableVersions, removePluginSkillFromVersion, } from '../lib/plugins.js';
31
31
  import { diffVersionSubagents, iterSubagentsCapableVersions, removeSubagentFromVersion, } from '../lib/subagents.js';
32
32
  import { getGlobalDefault } from '../lib/versions.js';
@@ -64,9 +64,12 @@ function collectOrphans(types, all) {
64
64
  }
65
65
  if (types.includes('hooks')) {
66
66
  for (const { agent, version } of scopePairs(iterHooksCapableVersions(), all)) {
67
- const diff = diffVersionHooks(agent, version);
68
- if (diff.orphans.length > 0) {
69
- groups.push({ type: 'hooks', agent, version, orphans: diff.orphans });
67
+ // Orphan hooks = scripts present in the version home that no
68
+ // agents.yaml/hooks.yaml entry registers, so they never fire. Same
69
+ // definition the doctor overview reports.
70
+ const orphans = listUnmanagedHooksInVersionHome(agent, version);
71
+ if (orphans.length > 0) {
72
+ groups.push({ type: 'hooks', agent, version, orphans });
70
73
  }
71
74
  }
72
75
  }
@@ -210,7 +213,7 @@ async function runOrphanPrune(resourceTypes, options) {
210
213
  return;
211
214
  }
212
215
  const total = groups.reduce((n, g) => n + g.orphans.length, 0);
213
- console.log(chalk.bold('Orphans (in version home, not in any source)\n'));
216
+ console.log(chalk.bold('Orphans (in version home, unmanaged by any source)\n'));
214
217
  for (const g of groups) {
215
218
  const label = `${g.type} · ${g.agent}@${g.version}`;
216
219
  console.log(` ${chalk.cyan(label)} ${g.orphans.join(', ')}`);
@@ -23,7 +23,7 @@ function resolveRepoPath(target) {
23
23
  }
24
24
  return path.join(HOME, `.agents-${trimmed}`);
25
25
  }
26
- import { ensureAgentsDir, getExtraRepoDir, getSystemAgentsDir, getUserAgentsDir, readMeta, resolveExtraRepoDir, updateMeta, } from '../lib/state.js';
26
+ import { applyExtraAliasToVersions, ensureAgentsDir, getExtraRepoDir, getSystemAgentsDir, getUserAgentsDir, readMeta, resolveExtraRepoDir, updateMeta, } from '../lib/state.js';
27
27
  import { parseSource, pullRepo, commitAndPush, isGitRepo, isSystemRepoOrigin } from '../lib/git.js';
28
28
  import { DEFAULT_SYSTEM_REPO } from '../lib/types.js';
29
29
  import { ALL_AGENT_IDS, isAgentName, resolveAgentName } from '../lib/agents.js';
@@ -259,6 +259,7 @@ export function registerRepoCommands(program) {
259
259
  const commit = log.latest?.hash.slice(0, 8) || 'unknown';
260
260
  extras[alias] = { url: targetDir, path: targetDir, enabled: true };
261
261
  updateMeta({ extraRepos: extras });
262
+ syncExtraAliasAcrossVersions(alias, true);
262
263
  spinner.succeed(`Created ${targetDir} (${commit})`);
263
264
  console.log(chalk.gray(`\nRegistered as "${alias}". Edit files there, then add your own git remote when ready.`));
264
265
  }
@@ -306,6 +307,7 @@ export function registerRepoCommands(program) {
306
307
  if (parsed.type === 'local') {
307
308
  extras[alias] = { url: parsed.url, path: parsed.url, enabled: true };
308
309
  updateMeta({ extraRepos: extras });
310
+ syncExtraAliasAcrossVersions(alias, true);
309
311
  syncMarketplacesForDefaults();
310
312
  console.log(chalk.green(`Registered local repo "${alias}" -> ${parsed.url}`));
311
313
  return;
@@ -342,6 +344,7 @@ export function registerRepoCommands(program) {
342
344
  }
343
345
  extras[alias] = { url: parsed.url, path: targetDir, enabled: true };
344
346
  updateMeta({ extraRepos: extras });
347
+ syncExtraAliasAcrossVersions(alias, true);
345
348
  syncMarketplacesForDefaults();
346
349
  console.log(chalk.gray(`\nRegistered as "${alias}". Skills and commands from this repo will be`));
347
350
  console.log(chalk.gray(`picked up automatically the next time you launch any agent.`));
@@ -379,6 +382,7 @@ export function registerRepoCommands(program) {
379
382
  }
380
383
  delete extras[alias];
381
384
  updateMeta({ extraRepos: extras });
385
+ syncExtraAliasAcrossVersions(alias, false);
382
386
  syncMarketplacesForDefaults();
383
387
  console.log(chalk.green(`Removed "${alias}"`));
384
388
  });
@@ -592,6 +596,19 @@ function collectRepoTargets(alias) {
592
596
  }
593
597
  return [found];
594
598
  }
599
+ /**
600
+ * Keep already-installed versions' selectors in sync with an extra-repo change:
601
+ * add `<alias>:*` when the repo is registered/enabled, strip it when removed.
602
+ * Newly-installed versions inherit it from `defaultPatterns()` at scaffold time,
603
+ * so without this a repo added after install is invisible to existing versions.
604
+ */
605
+ function syncExtraAliasAcrossVersions(alias, add) {
606
+ const n = applyExtraAliasToVersions(alias, add);
607
+ if (n > 0) {
608
+ const verb = add ? 'Added to' : 'Removed from';
609
+ console.log(chalk.gray(`${verb} ${n} existing version selector${n === 1 ? '' : 's'}.`));
610
+ }
611
+ }
595
612
  async function toggle(alias, enabled) {
596
613
  const meta = readMeta();
597
614
  const extras = { ...(meta.extraRepos || {}) };
@@ -606,6 +623,10 @@ async function toggle(alias, enabled) {
606
623
  }
607
624
  extras[alias] = { ...extras[alias], enabled };
608
625
  updateMeta({ extraRepos: extras });
626
+ // Re-enabling backfills the alias into existing versions; disabling leaves the
627
+ // selectors (resolution skips disabled extras) so a later enable is a no-op.
628
+ if (enabled)
629
+ syncExtraAliasAcrossVersions(alias, true);
609
630
  syncMarketplacesForDefaults();
610
631
  console.log(chalk.green(`${enabled ? 'Enabled' : 'Disabled'} "${alias}"`));
611
632
  }
@@ -19,6 +19,7 @@ export interface ViewSectionFilter {
19
19
  rules?: boolean;
20
20
  hooks?: boolean;
21
21
  promptcuts?: boolean;
22
+ cli?: boolean;
22
23
  }
23
24
  /** Machine-readable entry for a single installed version. */
24
25
  export interface ViewJsonVersion {
@@ -8,6 +8,7 @@ import { readManifest } from '../lib/manifest.js';
8
8
  import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, resolveVersionAlias, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, promptNewResourceSelection, syncResourcesToVersion, removeVersion, printTrashFooter, } from '../lib/versions.js';
9
9
  import { ensureVersionedAliasCurrent, removeShim, } from '../lib/shims.js';
10
10
  import { getAgentResources } from '../lib/resources.js';
11
+ import { listCliStatus } from '../lib/cli-resources.js';
11
12
  import { isCapable } from '../lib/capabilities.js';
12
13
  import { discoverPlugins, pluginSupportsAgent } from '../lib/plugins.js';
13
14
  import { getAgentsDir, getUserAgentsDir, getEffectivePromptcutsPath, readMergedPromptcuts } from '../lib/state.js';
@@ -114,7 +115,7 @@ function getProjectVersionFromCwd(agent) {
114
115
  return null;
115
116
  }
116
117
  }
117
- const SECTION_KEYS = ['commands', 'skills', 'mcp', 'workflows', 'plugins', 'rules', 'hooks', 'promptcuts'];
118
+ const SECTION_KEYS = ['commands', 'skills', 'mcp', 'workflows', 'plugins', 'rules', 'hooks', 'promptcuts', 'cli'];
118
119
  /**
119
120
  * Decide whether a section should render given the filter. If no flags are set,
120
121
  * everything renders (current behavior). If any flag is set, only those sections
@@ -167,6 +168,51 @@ function renderProfilesSection(profiles) {
167
168
  * Show installed versions for one or all agents.
168
169
  * Called when: `agents view` or `agents view claude`
169
170
  */
171
+ /** Color the source-layer tag for a host CLI, matching the rules-section convention. */
172
+ function hostCliSourceTag(source) {
173
+ if (source === 'project')
174
+ return chalk.blue('[project]');
175
+ if (source === 'user')
176
+ return chalk.cyan('[user]');
177
+ if (source === 'system')
178
+ return chalk.gray('[system]');
179
+ // Anything else is an extra repo, tagged by its alias.
180
+ return chalk.magenta(`[${source}]`);
181
+ }
182
+ /**
183
+ * Render the host-CLI section. Host CLIs are host-global: declared in any
184
+ * DotAgents repo's `cli/` (project > user > system > extras), installed to PATH
185
+ * rather than copied into a version home. They render identically in the overview
186
+ * and in a per-agent detail view because every agent on the host shares them.
187
+ * The source tag shows which repo layer declared each — so user-level and
188
+ * extra-repo manifests are visibly supported.
189
+ */
190
+ function renderHostClisSection(cwd) {
191
+ const { statuses, errors } = listCliStatus(cwd);
192
+ console.log(chalk.bold('\nHost CLIs\n'));
193
+ if (statuses.length === 0) {
194
+ console.log(` ${chalk.gray('none declared')} ${chalk.gray('— add one with `agents cli add <name>`')}`);
195
+ }
196
+ else {
197
+ const nameWidth = Math.max(...statuses.map((s) => s.manifest.name.length));
198
+ let anyMissing = false;
199
+ for (const { manifest, installed } of statuses) {
200
+ if (!installed)
201
+ anyMissing = true;
202
+ const status = installed ? chalk.green('installed') : chalk.red('missing ');
203
+ const linkedName = termLink(manifest.name.padEnd(nameWidth), linkTarget(manifest.path));
204
+ const tag = hostCliSourceTag(manifest.source);
205
+ const desc = manifest.description ? chalk.gray(` ${summarizeDescription(manifest.description, 60)}`) : '';
206
+ console.log(` ${status} ${chalk.cyan(linkedName)} ${tag}${desc}`);
207
+ }
208
+ if (anyMissing) {
209
+ console.log(chalk.gray(' Install missing with `agents cli install`'));
210
+ }
211
+ }
212
+ for (const err of errors) {
213
+ console.log(` ${chalk.red('error')} ${chalk.gray(err.file)}: ${chalk.gray(err.reason)}`);
214
+ }
215
+ }
170
216
  async function showInstalledVersions(filterAgentId) {
171
217
  const spinnerText = filterAgentId
172
218
  ? `Checking ${agentLabel(filterAgentId)} agents...`
@@ -537,6 +583,10 @@ async function showInstalledVersions(filterAgentId) {
537
583
  console.log(chalk.gray(' Run: agents add claude@latest'));
538
584
  console.log();
539
585
  }
586
+ // Host CLIs are host-global, not per-agent — show them once in the overview.
587
+ if (!filterAgentId) {
588
+ renderHostClisSection(process.cwd());
589
+ }
540
590
  // Check for new resources when viewing a specific agent
541
591
  if (filterAgentId && versionManaged.length > 0) {
542
592
  const defaultVersion = getGlobalDefault(filterAgentId);
@@ -892,6 +942,9 @@ async function showAgentResources(agentId, requestedVersion, filter) {
892
942
  if (shouldRenderSection('promptcuts', filter)) {
893
943
  renderPromptcuts();
894
944
  }
945
+ if (shouldRenderSection('cli', filter)) {
946
+ renderHostClisSection(cwd);
947
+ }
895
948
  // Show legend at the end if git repo exists and we showed all sections.
896
949
  // Filtered single-section views skip it — noise for promptcuts or plugins.
897
950
  if (hasGitRepo && !anyFilterSet) {
@@ -1183,6 +1236,7 @@ export async function viewAction(agentArg, options) {
1183
1236
  rules: options?.rules,
1184
1237
  hooks: options?.hooks,
1185
1238
  promptcuts: options?.promptcuts,
1239
+ cli: options?.cli,
1186
1240
  };
1187
1241
  const filterIsSet = SECTION_KEYS.some((k) => filter[k]);
1188
1242
  if (!agentArg) {
@@ -1263,6 +1317,7 @@ export function registerViewCommand(program) {
1263
1317
  .option('--rules', 'Show only rules in the detail view.')
1264
1318
  .option('--hooks', 'Show only hooks in the detail view.')
1265
1319
  .option('--promptcuts', 'Show only promptcuts in the detail view.')
1320
+ .option('--cli', 'Show only host CLIs (declared in cli/, installed to PATH).')
1266
1321
  .addHelpText('after', `
1267
1322
  Examples:
1268
1323
  # Show all installed agents with versions, accounts, and usage
@@ -1701,24 +1701,34 @@ export class BrowserService {
1701
1701
  targetId: tabId,
1702
1702
  flatten: true,
1703
1703
  }));
1704
- // Inject a one-shot stealth shim before any page script runs. Chromium
1705
- // unconditionally exposes navigator.webdriver = true when a remote-debug
1706
- // transport is attached; Cloudflare Turnstile, hCaptcha, and similar bot
1707
- // checks read that property first. For browsers agents-cli spawns the
1708
- // --disable-blink-features=AutomationControlled launch flag already
1709
- // covers this, but for attach-to-running profiles (the Comet / Arc /
1710
- // Brave case where the user launched the browser themselves) the flag
1711
- // is unavailable Page.addScriptToEvaluateOnNewDocument is the only
1712
- // lever. Non-page targets (workers, service workers) will reject these
1713
- // calls; we swallow the error and keep going.
1714
- try {
1715
- await conn.cdp.send('Page.enable', {}, sessionId);
1716
- await conn.cdp.send('Page.addScriptToEvaluateOnNewDocument', {
1717
- source: "Object.defineProperty(navigator,'webdriver',{get:()=>undefined});",
1718
- }, sessionId);
1719
- }
1720
- catch {
1721
- // Target doesn't support Page domain nothing to inject.
1704
+ // Inject a stealth shim before any page script runs. Chromium exposes
1705
+ // navigator.webdriver = true whenever a remote-debug transport is attached;
1706
+ // Cloudflare Turnstile, hCaptcha, and similar bot checks read it first.
1707
+ //
1708
+ // Only attach-to-running profiles (conn.pid === 0 — Comet / Arc / Brave the
1709
+ // user launched themselves) need this. Browsers agents-cli spawns already
1710
+ // carry the --disable-blink-features=AutomationControlled launch flag, which
1711
+ // makes navigator.webdriver a native Navigator.prototype getter returning
1712
+ // false indistinguishable from an untouched browser. Injecting on top of
1713
+ // that is actively harmful: it defines an OWN getter on the instance, and an
1714
+ // own `webdriver` descriptor (native lives on the prototype) returning
1715
+ // `undefined` (native returns `false`) is itself a tampering signal that
1716
+ // bot.sannysoft.com and similar tests flag as "WebDriver present".
1717
+ //
1718
+ // When we do inject (attach mode), mirror native semantics exactly: define
1719
+ // on Navigator.prototype and return false, so no own descriptor leaks and
1720
+ // the value matches a real browser. Non-page targets (workers, service
1721
+ // workers) reject these calls; swallow the error and keep going.
1722
+ if (conn.pid === 0) {
1723
+ try {
1724
+ await conn.cdp.send('Page.enable', {}, sessionId);
1725
+ await conn.cdp.send('Page.addScriptToEvaluateOnNewDocument', {
1726
+ source: "Object.defineProperty(Navigator.prototype,'webdriver',{get:()=>false,configurable:true});",
1727
+ }, sessionId);
1728
+ }
1729
+ catch {
1730
+ // Target doesn't support Page domain — nothing to inject.
1731
+ }
1722
1732
  }
1723
1733
  conn.sessionCache.set(tabId, sessionId);
1724
1734
  return sessionId;
@@ -123,7 +123,27 @@ export declare function listCentralHooks(): HookEntry[];
123
123
  *
124
124
  * Hooks marked `enabled: false` are dropped from the returned map.
125
125
  */
126
- export declare function parseHookManifest(): Record<string, ManifestHook>;
126
+ export declare function parseHookManifest(opts?: {
127
+ warn?: boolean;
128
+ }): Record<string, ManifestHook>;
129
+ /**
130
+ * Hook script files present on disk that no manifest entry declares — "dead"
131
+ * hooks. The registrar only wires manifest-declared hooks into an agent's
132
+ * native config (settings.json / config.toml), matching the installed file to a
133
+ * manifest entry by script basename. So a file whose basename matches no
134
+ * manifest `script:` is never registered: it occupies the hooks dir and shows
135
+ * up in listings, but no lifecycle event ever fires it.
136
+ *
137
+ * Pure on purpose (no disk reads) so it is trivially testable; callers pass the
138
+ * installed hook names and the manifest's script paths.
139
+ */
140
+ export declare function unmanagedHookNames(installedHookNames: string[], manifestScripts: string[]): string[];
141
+ /**
142
+ * The dead hooks (see {@link unmanagedHookNames}) sitting in one version home.
143
+ * Reads the merged hook manifest silently — a diagnostic must not emit the
144
+ * shadow/override warnings the registrar path prints.
145
+ */
146
+ export declare function listUnmanagedHooksInVersionHome(agent: AgentId, version: string): string[];
127
147
  export declare function registerHooksToSettings(agentId: AgentId, versionHome: string, hookManifest?: Record<string, ManifestHook>, agentsDirOverride?: string): {
128
148
  registered: string[];
129
149
  errors: string[];
package/dist/lib/hooks.js CHANGED
@@ -650,7 +650,8 @@ export function listCentralHooks() {
650
650
  *
651
651
  * Hooks marked `enabled: false` are dropped from the returned map.
652
652
  */
653
- export function parseHookManifest() {
653
+ export function parseHookManifest(opts = {}) {
654
+ const warn = opts.warn !== false;
654
655
  const merged = {};
655
656
  const systemHooks = {};
656
657
  // System layer: hooks: section of agents.yaml (npm-shipped, separate repo).
@@ -673,7 +674,7 @@ export function parseHookManifest() {
673
674
  const meta = yaml.parse(fs.readFileSync(userMetaPath, 'utf-8'));
674
675
  if (meta?.hooks)
675
676
  for (const [name, def] of Object.entries(meta.hooks)) {
676
- if (systemHooks[name] && def.override !== true) {
677
+ if (warn && systemHooks[name] && def.override !== true) {
677
678
  const action = def.enabled === false ? 'disables' : 'shadows';
678
679
  console.warn(`[agents hooks] User-layer hook '${name}' ${action} system-shipped hook. Set 'override: true' to silence this warning.`);
679
680
  }
@@ -689,6 +690,35 @@ export function parseHookManifest() {
689
690
  }
690
691
  return merged;
691
692
  }
693
+ /**
694
+ * Hook script files present on disk that no manifest entry declares — "dead"
695
+ * hooks. The registrar only wires manifest-declared hooks into an agent's
696
+ * native config (settings.json / config.toml), matching the installed file to a
697
+ * manifest entry by script basename. So a file whose basename matches no
698
+ * manifest `script:` is never registered: it occupies the hooks dir and shows
699
+ * up in listings, but no lifecycle event ever fires it.
700
+ *
701
+ * Pure on purpose (no disk reads) so it is trivially testable; callers pass the
702
+ * installed hook names and the manifest's script paths.
703
+ */
704
+ export function unmanagedHookNames(installedHookNames, manifestScripts) {
705
+ const managed = new Set(manifestScripts.map((s) => path.basename(s).replace(/\.[^.]+$/, '')));
706
+ return installedHookNames.filter((name) => !managed.has(name)).sort();
707
+ }
708
+ /**
709
+ * The dead hooks (see {@link unmanagedHookNames}) sitting in one version home.
710
+ * Reads the merged hook manifest silently — a diagnostic must not emit the
711
+ * shadow/override warnings the registrar path prints.
712
+ */
713
+ export function listUnmanagedHooksInVersionHome(agent, version) {
714
+ if (!AGENTS[agent].supportsHooks)
715
+ return [];
716
+ const scripts = Object.values(parseHookManifest({ warn: false }))
717
+ .map((h) => h.script)
718
+ .filter((s) => typeof s === 'string');
719
+ const installed = listHooksInVersionHome(agent, version).map((e) => e.name);
720
+ return unmanagedHookNames(installed, scripts);
721
+ }
692
722
  // Codex events that support a matcher field (matches tool name or session type).
693
723
  // UserPromptSubmit and Stop never include a matcher.
694
724
  const CODEX_MATCHER_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'SessionStart']);
@@ -74,6 +74,16 @@ export declare function getPlugin(name: string): DiscoveredPlugin | null;
74
74
  * Otherwise defaults to all plugin-capable agents.
75
75
  */
76
76
  export declare function pluginSupportsAgent(plugin: DiscoveredPlugin, agent: AgentId): boolean;
77
+ /**
78
+ * The lifecycle events a plugin hooks into, read from hooks/hooks.json.
79
+ *
80
+ * The official plugin format wraps the event map under a `hooks` key
81
+ * (`{ description, hooks: { SessionStart: [...], PreToolUse: [...] } }`), so the
82
+ * meaningful keys are the events — NOT the top-level keys (`description`,
83
+ * `hooks`). Older/flat files put the event names at the top level directly; we
84
+ * read whichever object actually holds the event map.
85
+ */
86
+ export declare function discoverPluginHooks(pluginRoot: string): string[];
77
87
  /** Discover command .md files inside a plugin's commands/ directory. */
78
88
  export declare function discoverPluginCommands(pluginRoot: string): string[];
79
89
  /** Discover agent definition .md files inside a plugin's agents/ directory. */
@@ -213,13 +213,25 @@ function discoverPluginSkills(pluginRoot) {
213
213
  .filter(d => d.isDirectory() && !d.name.startsWith('.'))
214
214
  .map(d => d.name);
215
215
  }
216
- function discoverPluginHooks(pluginRoot) {
216
+ /**
217
+ * The lifecycle events a plugin hooks into, read from hooks/hooks.json.
218
+ *
219
+ * The official plugin format wraps the event map under a `hooks` key
220
+ * (`{ description, hooks: { SessionStart: [...], PreToolUse: [...] } }`), so the
221
+ * meaningful keys are the events — NOT the top-level keys (`description`,
222
+ * `hooks`). Older/flat files put the event names at the top level directly; we
223
+ * read whichever object actually holds the event map.
224
+ */
225
+ export function discoverPluginHooks(pluginRoot) {
217
226
  const hooksFile = path.join(pluginRoot, 'hooks', 'hooks.json');
218
227
  if (!fs.existsSync(hooksFile))
219
228
  return [];
220
229
  try {
221
230
  const content = JSON.parse(fs.readFileSync(hooksFile, 'utf-8'));
222
- return Object.keys(content);
231
+ const eventMap = content.hooks && typeof content.hooks === 'object' && !Array.isArray(content.hooks)
232
+ ? content.hooks
233
+ : content;
234
+ return Object.keys(eventMap);
223
235
  }
224
236
  catch {
225
237
  return [];
@@ -11,6 +11,13 @@ export interface RotateCandidate {
11
11
  agent: AgentId;
12
12
  version: string;
13
13
  email: string | null;
14
+ /**
15
+ * Per-org usage/quota key (e.g. `claude:org=<orgUuid>`) — the unit rate
16
+ * limits are actually measured in. Distinct orgs signed in under the same
17
+ * email have distinct keys, so this is the correct dedup boundary; null when
18
+ * no usage identity is available (then we fall back to email).
19
+ */
20
+ usageKey: string | null;
14
21
  usageStatus: AccountInfo['usageStatus'];
15
22
  usageSnapshot: UsageSnapshot | null;
16
23
  authValid: boolean;
@@ -105,19 +105,29 @@ function compareCandidates(a, b) {
105
105
  return ta - tb;
106
106
  return Math.random() - 0.5;
107
107
  }
108
+ /**
109
+ * Identity a candidate dedups on. Quota is tracked per-org, so two versions
110
+ * that share an org are the same rate-limit bucket and must collapse — but two
111
+ * orgs under the same email (e.g. Enterprise + Personal on one Google identity)
112
+ * are genuinely separate buckets and must stay distinct. Prefer the org usage
113
+ * key; fall back to email only when no usage identity is available.
114
+ */
115
+ function candidateIdentity(c) {
116
+ return c.usageKey ?? c.email;
117
+ }
108
118
  function dedupeAndSortCandidates(candidates) {
109
- const byEmail = new Map();
119
+ const byIdentity = new Map();
110
120
  for (const c of candidates) {
111
- const email = c.email;
112
- const existing = byEmail.get(email);
121
+ const id = candidateIdentity(c);
122
+ const existing = byIdentity.get(id);
113
123
  if (!existing) {
114
- byEmail.set(email, c);
124
+ byIdentity.set(id, c);
115
125
  continue;
116
126
  }
117
127
  if (compareCandidates(c, existing) < 0)
118
- byEmail.set(email, c);
128
+ byIdentity.set(id, c);
119
129
  }
120
- return [...byEmail.values()].sort(compareCandidates);
130
+ return [...byIdentity.values()].sort(compareCandidates);
121
131
  }
122
132
  /**
123
133
  * Pick a healthy candidate using weighted random by remaining capacity.
@@ -252,7 +262,7 @@ async function collectRunCandidates(agent) {
252
262
  const usageSnapshot = usageKey
253
263
  ? usageByKey.get(usageKey)?.snapshot ?? null
254
264
  : null;
255
- return { ...candidate, usageSnapshot };
265
+ return { ...candidate, usageKey, usageSnapshot };
256
266
  });
257
267
  }
258
268
  /**
package/dist/lib/shims.js CHANGED
@@ -685,7 +685,10 @@ export function ensureVersionedAliasCurrent(agent, version) {
685
685
  createVersionedAlias(agent, version);
686
686
  return 'created';
687
687
  }
688
- if (!isVersionedAliasCurrent(agent, version)) {
688
+ // Upgrade-only (newest-wins), same rationale as ensureShimCurrent: never
689
+ // downgrade an alias stamped by a newer install sharing the shims dir.
690
+ const onDisk = readVersionedAliasSchemaVersion(agent, version);
691
+ if (onDisk === null || onDisk < VERSIONED_ALIAS_SCHEMA_VERSION) {
689
692
  createVersionedAlias(agent, version);
690
693
  return 'updated';
691
694
  }
@@ -1278,7 +1281,14 @@ export function ensureShimCurrent(agent) {
1278
1281
  createShim(agent);
1279
1282
  return 'created';
1280
1283
  }
1281
- if (!isShimCurrent(agent)) {
1284
+ // Upgrade-only (newest-wins): regenerate only when the on-disk shim is
1285
+ // unversioned/unreadable (null) or OLDER than this binary. Never downgrade a
1286
+ // shim stamped by a NEWER agents-cli install. Two installs at different
1287
+ // SHIM_SCHEMA_VERSION sharing ~/.agents/.cache/shims/ (e.g. a dev build on
1288
+ // PATH alongside a Hermes-bundled published copy) otherwise ping-pong —
1289
+ // rewriting every shim on each alternating launch and adding boot latency.
1290
+ const onDisk = readShimSchemaVersion(agent);
1291
+ if (onDisk === null || onDisk < SHIM_SCHEMA_VERSION) {
1282
1292
  createShim(agent);
1283
1293
  return 'updated';
1284
1294
  }
@@ -247,6 +247,24 @@ export declare function recordVersionResources(_agent: AgentId, _version: string
247
247
  * Pass all resource types you want to initialize in one call to batch the write.
248
248
  */
249
249
  export declare function ensureVersionResourcePatterns(agent: AgentId, version: string, updates: Partial<Record<Exclude<keyof VersionResources, 'rulesPreset'>, ResourcePattern[]>>): void;
250
+ /**
251
+ * Insert `<alias>:*` at the canonical position (after the system/user/other-extra
252
+ * includes, before `project:*`), unless the alias is already referenced — as an
253
+ * include (`alias:...`) or an exclude (`!alias:...`). Returns a new array when it
254
+ * changes, otherwise the same reference (so callers can detect no-ops cheaply).
255
+ */
256
+ export declare function withAlias(list: ResourcePattern[], alias: string): ResourcePattern[];
257
+ /** Strip every reference to `<alias>:...` / `!<alias>:...` from a selector list. */
258
+ export declare function withoutAlias(list: ResourcePattern[], alias: string): ResourcePattern[];
259
+ /**
260
+ * Backfill (add=true) or strip (add=false) an extra-repo alias across every
261
+ * already-installed version's selectors. New versions get the alias via
262
+ * `defaultPatterns()` at scaffold time; this keeps existing versions in sync
263
+ * when an extra repo is registered/enabled or removed. Only touches selector
264
+ * lists that are already set — an unset list is left for `defaultPatterns()`.
265
+ * Returns the number of (agent, version) pairs changed.
266
+ */
267
+ export declare function applyExtraAliasToVersions(alias: string, add: boolean): number;
250
268
  export declare function getVersionResources(agent: AgentId, version: string): VersionResources | null;
251
269
  /** Active rules preset for an agent@version. Defaults to "default" when unset. */
252
270
  export declare function getActiveRulesPreset(agent: AgentId, version: string): string;
package/dist/lib/state.js CHANGED
@@ -711,6 +711,79 @@ export function ensureVersionResourcePatterns(agent, version, updates) {
711
711
  if (changed)
712
712
  writeMeta(meta);
713
713
  }
714
+ /**
715
+ * Resource types that resolve across the extra-repo layer. Mirrors
716
+ * `defaultPatterns()`: extras feed commands/skills/hooks/subagents/plugins/
717
+ * workflows, but never permissions (`system:*`) or mcp (`user:*`).
718
+ */
719
+ const EXTRA_ELIGIBLE_TYPES = [
720
+ 'commands', 'skills', 'hooks', 'subagents', 'plugins', 'workflows',
721
+ ];
722
+ /**
723
+ * Insert `<alias>:*` at the canonical position (after the system/user/other-extra
724
+ * includes, before `project:*`), unless the alias is already referenced — as an
725
+ * include (`alias:...`) or an exclude (`!alias:...`). Returns a new array when it
726
+ * changes, otherwise the same reference (so callers can detect no-ops cheaply).
727
+ */
728
+ export function withAlias(list, alias) {
729
+ const prefix = `${alias}:`;
730
+ if (list.some(p => p === `${alias}:*` || p.startsWith(prefix) || p.startsWith(`!${prefix}`))) {
731
+ return list;
732
+ }
733
+ const next = [...list];
734
+ const projIdx = next.findIndex(p => p === 'project:*' || p.startsWith('project:'));
735
+ if (projIdx >= 0)
736
+ next.splice(projIdx, 0, `${alias}:*`);
737
+ else
738
+ next.push(`${alias}:*`);
739
+ return next;
740
+ }
741
+ /** Strip every reference to `<alias>:...` / `!<alias>:...` from a selector list. */
742
+ export function withoutAlias(list, alias) {
743
+ const prefix = `${alias}:`;
744
+ const next = list.filter(p => !(p.startsWith(prefix) || p.startsWith(`!${prefix}`)));
745
+ return next.length === list.length ? list : next;
746
+ }
747
+ /**
748
+ * Backfill (add=true) or strip (add=false) an extra-repo alias across every
749
+ * already-installed version's selectors. New versions get the alias via
750
+ * `defaultPatterns()` at scaffold time; this keeps existing versions in sync
751
+ * when an extra repo is registered/enabled or removed. Only touches selector
752
+ * lists that are already set — an unset list is left for `defaultPatterns()`.
753
+ * Returns the number of (agent, version) pairs changed.
754
+ */
755
+ export function applyExtraAliasToVersions(alias, add) {
756
+ const meta = readMeta();
757
+ if (!meta.versions)
758
+ return 0;
759
+ let changed = false;
760
+ let count = 0;
761
+ for (const versions of Object.values(meta.versions)) {
762
+ if (!versions)
763
+ continue;
764
+ for (const vr of Object.values(versions)) {
765
+ if (!vr)
766
+ continue;
767
+ let touched = false;
768
+ for (const type of EXTRA_ELIGIBLE_TYPES) {
769
+ const cur = vr[type];
770
+ if (!Array.isArray(cur) || cur.length === 0)
771
+ continue;
772
+ const next = add ? withAlias(cur, alias) : withoutAlias(cur, alias);
773
+ if (next !== cur) {
774
+ vr[type] = next;
775
+ touched = true;
776
+ changed = true;
777
+ }
778
+ }
779
+ if (touched)
780
+ count++;
781
+ }
782
+ }
783
+ if (changed)
784
+ writeMeta(meta);
785
+ return count;
786
+ }
714
787
  export function getVersionResources(agent, version) {
715
788
  const meta = readMeta();
716
789
  return meta.versions?.[agent]?.[version] || null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.13",
3
+ "version": "1.20.15",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",