@phnx-labs/agents-cli 1.20.0 → 1.20.4

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 (111) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +4 -4
  3. package/dist/commands/cli.js +3 -3
  4. package/dist/commands/cloud.js +1 -1
  5. package/dist/commands/commands.js +24 -7
  6. package/dist/commands/exec.js +36 -16
  7. package/dist/commands/feedback.d.ts +7 -0
  8. package/dist/commands/feedback.js +89 -0
  9. package/dist/commands/helper.d.ts +12 -0
  10. package/dist/commands/helper.js +87 -0
  11. package/dist/commands/hooks.js +86 -7
  12. package/dist/commands/import.js +90 -37
  13. package/dist/commands/mcp.js +166 -10
  14. package/dist/commands/packages.js +196 -27
  15. package/dist/commands/permissions.js +21 -6
  16. package/dist/commands/profiles.d.ts +8 -0
  17. package/dist/commands/profiles.js +117 -4
  18. package/dist/commands/pull.js +4 -4
  19. package/dist/commands/routines.js +6 -6
  20. package/dist/commands/rules.js +8 -4
  21. package/dist/commands/secrets-migrate.d.ts +24 -0
  22. package/dist/commands/secrets-migrate.js +198 -0
  23. package/dist/commands/secrets-sync.d.ts +11 -0
  24. package/dist/commands/secrets-sync.js +155 -0
  25. package/dist/commands/secrets.js +74 -39
  26. package/dist/commands/skills.js +22 -5
  27. package/dist/commands/subagents.js +69 -49
  28. package/dist/commands/teams.js +48 -10
  29. package/dist/commands/utils.d.ts +33 -0
  30. package/dist/commands/utils.js +139 -0
  31. package/dist/commands/versions.js +4 -4
  32. package/dist/commands/view.d.ts +6 -0
  33. package/dist/commands/view.js +169 -8
  34. package/dist/commands/workflows.js +29 -6
  35. package/dist/index.js +4 -0
  36. package/dist/lib/acp/client.js +6 -1
  37. package/dist/lib/agents.d.ts +4 -0
  38. package/dist/lib/agents.js +41 -17
  39. package/dist/lib/auto-pull-worker.js +18 -1
  40. package/dist/lib/browser/chrome.js +4 -0
  41. package/dist/lib/browser/drivers/ssh.js +1 -1
  42. package/dist/lib/browser/profiles.d.ts +3 -3
  43. package/dist/lib/browser/profiles.js +3 -3
  44. package/dist/lib/browser/service.js +19 -0
  45. package/dist/lib/browser/types.d.ts +4 -4
  46. package/dist/lib/cli-resources.d.ts +36 -8
  47. package/dist/lib/cli-resources.js +268 -46
  48. package/dist/lib/cloud/factory.d.ts +1 -1
  49. package/dist/lib/cloud/factory.js +1 -1
  50. package/dist/lib/events.d.ts +16 -2
  51. package/dist/lib/events.js +33 -2
  52. package/dist/lib/exec.d.ts +39 -11
  53. package/dist/lib/exec.js +90 -31
  54. package/dist/lib/help.js +11 -5
  55. package/dist/lib/hooks/cache.d.ts +38 -0
  56. package/dist/lib/hooks/cache.js +242 -0
  57. package/dist/lib/hooks/profile.d.ts +33 -0
  58. package/dist/lib/hooks/profile.js +129 -0
  59. package/dist/lib/hooks.d.ts +0 -10
  60. package/dist/lib/hooks.js +68 -15
  61. package/dist/lib/import.d.ts +21 -0
  62. package/dist/lib/import.js +55 -2
  63. package/dist/lib/mcp.d.ts +15 -0
  64. package/dist/lib/mcp.js +40 -0
  65. package/dist/lib/permissions.d.ts +13 -0
  66. package/dist/lib/permissions.js +51 -1
  67. package/dist/lib/plugin-marketplace.d.ts +10 -0
  68. package/dist/lib/plugin-marketplace.js +47 -1
  69. package/dist/lib/plugins.js +15 -1
  70. package/dist/lib/profiles-presets.d.ts +26 -0
  71. package/dist/lib/profiles-presets.js +187 -8
  72. package/dist/lib/profiles.d.ts +34 -0
  73. package/dist/lib/profiles.js +112 -1
  74. package/dist/lib/pty-server.js +27 -3
  75. package/dist/lib/routines-format.d.ts +17 -5
  76. package/dist/lib/routines-format.js +37 -16
  77. package/dist/lib/routines.d.ts +1 -1
  78. package/dist/lib/routines.js +2 -2
  79. package/dist/lib/runner.js +64 -10
  80. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  81. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  82. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -9
  83. package/dist/lib/secrets/bundles.d.ts +18 -22
  84. package/dist/lib/secrets/bundles.js +75 -99
  85. package/dist/lib/secrets/index.d.ts +51 -27
  86. package/dist/lib/secrets/index.js +147 -156
  87. package/dist/lib/secrets/install-helper.d.ts +45 -0
  88. package/dist/lib/secrets/install-helper.js +165 -0
  89. package/dist/lib/secrets/linux.js +4 -4
  90. package/dist/lib/secrets/sync.d.ts +56 -0
  91. package/dist/lib/secrets/sync.js +180 -0
  92. package/dist/lib/session/render.js +4 -4
  93. package/dist/lib/session/types.d.ts +1 -1
  94. package/dist/lib/shims.d.ts +4 -1
  95. package/dist/lib/shims.js +5 -35
  96. package/dist/lib/state.d.ts +14 -1
  97. package/dist/lib/state.js +49 -5
  98. package/dist/lib/teams/agents.d.ts +5 -4
  99. package/dist/lib/teams/agents.js +47 -21
  100. package/dist/lib/teams/api.d.ts +2 -1
  101. package/dist/lib/teams/api.js +4 -3
  102. package/dist/lib/types.d.ts +57 -1
  103. package/dist/lib/types.js +2 -0
  104. package/dist/lib/usage.d.ts +27 -2
  105. package/dist/lib/usage.js +100 -17
  106. package/dist/lib/versions.d.ts +35 -1
  107. package/dist/lib/versions.js +288 -64
  108. package/package.json +13 -12
  109. package/scripts/install-helper.js +97 -0
  110. package/scripts/postinstall.js +16 -0
  111. package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Hook profiling — reads `hook.fire` events from the daily JSONL logs that
3
+ * generated shims (see `cache.ts`) emit on every invocation, and aggregates
4
+ * per-hook timing + cache stats.
5
+ *
6
+ * Only hooks declared with `cache:` get instrumented today, because only those
7
+ * are wrapped by a generated shim. Hooks without `cache:` are not in the
8
+ * profile output — that's deliberate: opting into the primitive is what
9
+ * surfaces the data.
10
+ */
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import { getLogsDir } from '../state.js';
14
+ /**
15
+ * Load every `hook.fire` event from the last `days` daily log files.
16
+ * Lines that aren't JSON or aren't `hook.fire` events are silently skipped —
17
+ * the events log is multiplexed (version.switch, secrets.get, …).
18
+ */
19
+ export function loadHookFireEvents(days = 7, logsDir = getLogsDir()) {
20
+ if (!fs.existsSync(logsDir))
21
+ return [];
22
+ const today = new Date();
23
+ const events = [];
24
+ for (let i = 0; i < days; i++) {
25
+ const d = new Date(today);
26
+ d.setUTCDate(d.getUTCDate() - i);
27
+ const yyyy = d.getUTCFullYear();
28
+ const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
29
+ const dd = String(d.getUTCDate()).padStart(2, '0');
30
+ const file = path.join(logsDir, `events-${yyyy}-${mm}-${dd}.jsonl`);
31
+ if (!fs.existsSync(file))
32
+ continue;
33
+ const raw = fs.readFileSync(file, 'utf-8');
34
+ for (const line of raw.split('\n')) {
35
+ if (!line)
36
+ continue;
37
+ let parsed;
38
+ try {
39
+ parsed = JSON.parse(line);
40
+ }
41
+ catch {
42
+ continue;
43
+ }
44
+ if (parsed.event !== 'hook.fire')
45
+ continue;
46
+ if (typeof parsed.hook !== 'string')
47
+ continue;
48
+ if (typeof parsed.ms !== 'number')
49
+ continue;
50
+ events.push(parsed);
51
+ }
52
+ }
53
+ return events;
54
+ }
55
+ /** Percentile of a sorted-ascending array. p in [0,100]. Linear interpolation. */
56
+ function percentile(sorted, p) {
57
+ if (sorted.length === 0)
58
+ return 0;
59
+ if (sorted.length === 1)
60
+ return sorted[0];
61
+ const rank = (p / 100) * (sorted.length - 1);
62
+ const lo = Math.floor(rank);
63
+ const hi = Math.ceil(rank);
64
+ if (lo === hi)
65
+ return sorted[lo];
66
+ const frac = rank - lo;
67
+ return sorted[lo] * (1 - frac) + sorted[hi] * frac;
68
+ }
69
+ /** Aggregate fire events into a per-hook profile, sorted by p99 desc. */
70
+ export function aggregateHookProfile(events) {
71
+ const byHook = new Map();
72
+ for (const e of events) {
73
+ if (!e.hook)
74
+ continue;
75
+ if (!byHook.has(e.hook))
76
+ byHook.set(e.hook, []);
77
+ byHook.get(e.hook).push(e);
78
+ }
79
+ const rows = [];
80
+ for (const [hook, evs] of byHook) {
81
+ const sortedMs = evs.map(e => e.ms).sort((a, b) => a - b);
82
+ const n = evs.length;
83
+ const sum = sortedMs.reduce((a, b) => a + b, 0);
84
+ const hits = evs.filter(e => e.cache === 'hit').length;
85
+ const stale = evs.filter(e => e.cache === 'stale-prefetch').length;
86
+ const misses = evs.filter(e => e.cache === 'miss').length;
87
+ const errors = evs.filter(e => typeof e.exit === 'number' && e.exit !== 0).length;
88
+ rows.push({
89
+ hook,
90
+ n,
91
+ p50Ms: Math.round(percentile(sortedMs, 50)),
92
+ p99Ms: Math.round(percentile(sortedMs, 99)),
93
+ meanMs: Math.round(sum / n),
94
+ maxMs: sortedMs[sortedMs.length - 1],
95
+ cacheHitPct: Math.round((hits / n) * 100),
96
+ cacheStalePct: Math.round((stale / n) * 100),
97
+ cacheMissPct: Math.round((misses / n) * 100),
98
+ errorCount: errors,
99
+ });
100
+ }
101
+ rows.sort((a, b) => b.p99Ms - a.p99Ms);
102
+ return rows;
103
+ }
104
+ /** Human-friendly duration: "42ms" / "1.2s" / "12s" / "2m". */
105
+ export function formatMs(ms) {
106
+ if (ms < 1000)
107
+ return `${ms}ms`;
108
+ if (ms < 10_000)
109
+ return `${(ms / 1000).toFixed(1)}s`;
110
+ if (ms < 60_000)
111
+ return `${Math.round(ms / 1000)}s`;
112
+ const mins = Math.floor(ms / 60_000);
113
+ const secs = Math.round((ms % 60_000) / 1000);
114
+ return secs > 0 ? `${mins}m${secs}s` : `${mins}m`;
115
+ }
116
+ /** Format a row's cache column: `hit:97% miss:3%` or `n/a` when nothing cached. */
117
+ export function formatCacheColumn(row) {
118
+ if (row.cacheHitPct + row.cacheStalePct + row.cacheMissPct === 0)
119
+ return 'n/a';
120
+ const parts = [];
121
+ if (row.cacheHitPct > 0)
122
+ parts.push(`hit:${row.cacheHitPct}%`);
123
+ if (row.cacheStalePct > 0)
124
+ parts.push(`stale:${row.cacheStalePct}%`);
125
+ if (row.cacheMissPct > 0)
126
+ parts.push(`miss:${row.cacheMissPct}%`);
127
+ return parts.join(' ');
128
+ }
129
+ export const DEFAULT_SLOW_HOOK_WARN_MS = 2000;
@@ -124,16 +124,6 @@ export declare function listCentralHooks(): HookEntry[];
124
124
  * Hooks marked `enabled: false` are dropped from the returned map.
125
125
  */
126
126
  export declare function parseHookManifest(): Record<string, ManifestHook>;
127
- /**
128
- * Register hooks as lifecycle events in an agent's config.
129
- * Reads hooks.yaml manifest, merges into the agent's config file(s).
130
- * Only manages hooks whose command paths are under ~/.agents/hooks/ or
131
- * ~/.agents-system/hooks/. Does not remove user-added hooks.
132
- *
133
- * @param agentsDirOverride - When provided, treats this single dir as the
134
- * only managed hook root. Used by tests to inject a temp path. In normal
135
- * operation, both user and system roots are consulted with user precedence.
136
- */
137
127
  export declare function registerHooksToSettings(agentId: AgentId, versionHome: string, hookManifest?: Record<string, ManifestHook>, agentsDirOverride?: string): {
138
128
  registered: string[];
139
129
  errors: string[];
package/dist/lib/hooks.js CHANGED
@@ -61,6 +61,29 @@ function isManagedHookCommand(command, prefixes) {
61
61
  return false;
62
62
  }
63
63
  import { getEffectiveHome, getVersionHomePath, listInstalledVersions } from './versions.js';
64
+ import { generateHookShim, parseCacheConfig, removeHookShim } from './hooks/cache.js';
65
+ import { getHookShimsDir } from './state.js';
66
+ /**
67
+ * Resolve the command path to register for a hook.
68
+ *
69
+ * Returns either the raw script path (no `cache:` set, legacy behavior) or
70
+ * the path to a generated caching/timing shim. The shim is written as a
71
+ * side effect when `cache:` is configured. The agent-native settings file
72
+ * gets the same shape either way — just a different command path.
73
+ */
74
+ function resolveHookCommand(name, hookDef, resolveScript) {
75
+ const scriptPath = resolveScript(hookDef.script);
76
+ if (!scriptPath)
77
+ return null;
78
+ const cache = parseCacheConfig(hookDef.cache);
79
+ if (!cache) {
80
+ // No caching opted in — make sure a previously generated shim from an
81
+ // earlier `cache:` config is gone so the JSONL doesn't keep claiming hits.
82
+ removeHookShim(name);
83
+ return scriptPath;
84
+ }
85
+ return generateHookShim({ name, scriptPath, cache });
86
+ }
64
87
  /**
65
88
  * Extensions that are NEVER hooks — docs, configuration, plain data. A file
66
89
  * in hooks/ with one of these extensions is auxiliary content (e.g., the
@@ -646,11 +669,35 @@ const CODEX_MATCHER_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'SessionStart
646
669
  * only managed hook root. Used by tests to inject a temp path. In normal
647
670
  * operation, both user and system roots are consulted with user precedence.
648
671
  */
672
+ /**
673
+ * Delete shim files for hooks that no longer exist in the manifest.
674
+ * managedPrefixes already GCs the settings.json entries pointing at orphaned
675
+ * shims, but the .sh files on disk would otherwise persist forever. Called
676
+ * once per registerHooksToSettings invocation — cheap (a single readdir).
677
+ */
678
+ function sweepOrphanShims(manifest) {
679
+ const shimsDir = getHookShimsDir();
680
+ if (!fs.existsSync(shimsDir))
681
+ return;
682
+ const activeNames = new Set(Object.keys(manifest));
683
+ for (const file of fs.readdirSync(shimsDir)) {
684
+ if (!file.endsWith('.sh'))
685
+ continue;
686
+ const name = file.slice(0, -3);
687
+ if (activeNames.has(name))
688
+ continue;
689
+ try {
690
+ fs.unlinkSync(path.join(shimsDir, file));
691
+ }
692
+ catch { /* best effort */ }
693
+ }
694
+ }
649
695
  export function registerHooksToSettings(agentId, versionHome, hookManifest, agentsDirOverride) {
650
696
  const manifest = hookManifest || parseHookManifest();
651
697
  if (Object.keys(manifest).length === 0) {
652
698
  return { registered: [], errors: [] };
653
699
  }
700
+ sweepOrphanShims(manifest);
654
701
  const overrideRoots = agentsDirOverride ? [agentsDirOverride] : null;
655
702
  // Scripts are copied into the version home during sync — prefer that stable
656
703
  // local path so registered commands don't break when source dirs change.
@@ -673,6 +720,10 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
673
720
  : [
674
721
  ...getManagedHookPrefixes(),
675
722
  ...(localHooksDir ? [localHooksDir + path.sep] : []),
723
+ // Generated cache/timing shims live here; needs GC coverage so that a
724
+ // hook whose `cache:` field is removed gets its stale shim path purged
725
+ // from the agent's settings file (see resolveHookCommand).
726
+ getHookShimsDir() + path.sep,
676
727
  ];
677
728
  if (agentId === 'claude') {
678
729
  return registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes);
@@ -737,12 +788,13 @@ function registerHooksForClaude(versionHome, manifest, resolveScript, managedPre
737
788
  }
738
789
  const hooks = config.hooks;
739
790
  // Build set of all command paths the current manifest will register.
740
- // Used to garbage-collect stale entries left behind after hook renames.
791
+ // Used to garbage-collect stale entries left behind after hook renames
792
+ // or after a `cache:` field is added/removed (raw script vs shim path).
741
793
  const currentManifestPaths = new Set();
742
- for (const hookDef of Object.values(manifest)) {
794
+ for (const [hookName, hookDef] of Object.entries(manifest)) {
743
795
  if (!hookDef.events || hookDef.events.length === 0)
744
796
  continue;
745
- const resolved = resolveScript(hookDef.script);
797
+ const resolved = resolveHookCommand(hookName, hookDef, resolveScript);
746
798
  if (resolved)
747
799
  currentManifestPaths.add(resolved);
748
800
  }
@@ -766,7 +818,7 @@ function registerHooksForClaude(versionHome, manifest, resolveScript, managedPre
766
818
  for (const [name, hookDef] of Object.entries(manifest)) {
767
819
  if (!hookDef.events || hookDef.events.length === 0)
768
820
  continue;
769
- const commandPath = resolveScript(hookDef.script);
821
+ const commandPath = resolveHookCommand(name, hookDef, resolveScript);
770
822
  if (!commandPath) {
771
823
  errors.push(`${name}: script not found in user or system hooks dir`);
772
824
  continue;
@@ -830,12 +882,13 @@ function registerHooksForCodex(versionHome, manifest, resolveScript, managedPref
830
882
  return { registered, errors };
831
883
  }
832
884
  }
833
- // Build set of current manifest command paths for codex to GC stale entries
885
+ // Build set of current manifest command paths for codex to GC stale entries.
886
+ // Uses resolveHookCommand so cached hooks resolve to their shim path.
834
887
  const currentManifestPaths = new Set();
835
- for (const hookDef of Object.values(manifest)) {
888
+ for (const [hookName, hookDef] of Object.entries(manifest)) {
836
889
  if (!hookDef.events || hookDef.events.length === 0)
837
890
  continue;
838
- const resolved = resolveScript(hookDef.script);
891
+ const resolved = resolveHookCommand(hookName, hookDef, resolveScript);
839
892
  if (resolved)
840
893
  currentManifestPaths.add(resolved);
841
894
  }
@@ -853,7 +906,7 @@ function registerHooksForCodex(versionHome, manifest, resolveScript, managedPref
853
906
  for (const [name, hookDef] of Object.entries(manifest)) {
854
907
  if (!hookDef.events || hookDef.events.length === 0)
855
908
  continue;
856
- const commandPath = resolveScript(hookDef.script);
909
+ const commandPath = resolveHookCommand(name, hookDef, resolveScript);
857
910
  if (!commandPath) {
858
911
  errors.push(`${name}: script not found in user or system hooks dir`);
859
912
  continue;
@@ -941,10 +994,10 @@ function registerHooksForGemini(versionHome, manifest, resolveScript, managedPre
941
994
  }
942
995
  const hooks = config.hooks;
943
996
  const currentManifestPaths = new Set();
944
- for (const hookDef of Object.values(manifest)) {
997
+ for (const [hookName, hookDef] of Object.entries(manifest)) {
945
998
  if (!hookDef.events || hookDef.events.length === 0)
946
999
  continue;
947
- const resolved = resolveScript(hookDef.script);
1000
+ const resolved = resolveHookCommand(hookName, hookDef, resolveScript);
948
1001
  if (resolved)
949
1002
  currentManifestPaths.add(resolved);
950
1003
  }
@@ -965,7 +1018,7 @@ function registerHooksForGemini(versionHome, manifest, resolveScript, managedPre
965
1018
  for (const [name, hookDef] of Object.entries(manifest)) {
966
1019
  if (!hookDef.events || hookDef.events.length === 0)
967
1020
  continue;
968
- const commandPath = resolveScript(hookDef.script);
1021
+ const commandPath = resolveHookCommand(name, hookDef, resolveScript);
969
1022
  if (!commandPath) {
970
1023
  errors.push(`${name}: script not found in user or system hooks dir`);
971
1024
  continue;
@@ -1039,7 +1092,7 @@ function registerHooksForAntigravity(versionHome, manifest, resolveScript, manag
1039
1092
  // hooks. Only managed paths are considered for removal — user-added entries
1040
1093
  // outside managedPrefixes are preserved.
1041
1094
  const currentManifestPaths = new Set();
1042
- for (const hookDef of Object.values(manifest)) {
1095
+ for (const [hookName, hookDef] of Object.entries(manifest)) {
1043
1096
  if (!hookDef.events || hookDef.events.length === 0)
1044
1097
  continue;
1045
1098
  // Only paths whose events map to a known agy event would actually be
@@ -1047,7 +1100,7 @@ function registerHooksForAntigravity(versionHome, manifest, resolveScript, manag
1047
1100
  const anyMapped = hookDef.events.some((e) => ANTIGRAVITY_EVENT_MAP[e]);
1048
1101
  if (!anyMapped)
1049
1102
  continue;
1050
- const resolved = resolveScript(hookDef.script);
1103
+ const resolved = resolveHookCommand(hookName, hookDef, resolveScript);
1051
1104
  if (resolved)
1052
1105
  currentManifestPaths.add(resolved);
1053
1106
  }
@@ -1072,7 +1125,7 @@ function registerHooksForAntigravity(versionHome, manifest, resolveScript, manag
1072
1125
  for (const [name, hookDef] of Object.entries(manifest)) {
1073
1126
  if (!hookDef.events || hookDef.events.length === 0)
1074
1127
  continue;
1075
- const commandPath = resolveScript(hookDef.script);
1128
+ const commandPath = resolveHookCommand(name, hookDef, resolveScript);
1076
1129
  if (!commandPath) {
1077
1130
  errors.push(`${name}: script not found in user or system hooks dir`);
1078
1131
  continue;
@@ -1128,7 +1181,7 @@ function registerHooksForGrok(versionHome, manifest, resolveScript, managedPrefi
1128
1181
  for (const [name, hookDef] of Object.entries(manifest)) {
1129
1182
  if (!hookDef.events || hookDef.events.length === 0)
1130
1183
  continue;
1131
- const commandPath = resolveScript(hookDef.script);
1184
+ const commandPath = resolveHookCommand(name, hookDef, resolveScript);
1132
1185
  if (!commandPath) {
1133
1186
  errors.push(`${name}: script not found`);
1134
1187
  continue;
@@ -81,6 +81,27 @@ export interface AgentBinarySpec {
81
81
  * node_modules/.bin/{cliCommand} -> {binaryEntry}
82
82
  */
83
83
  export declare function importAgentBinary(spec: AgentBinarySpec, version: string, globalPath: string, versionDir: string): ImportBinaryResult;
84
+ /**
85
+ * Register an existing installScript-based binary (Grok, Antigravity, Cursor,
86
+ * etc. — anything with `npmPackage: ''` and a curl/brew installer) under the
87
+ * managed version path. Unlike `importAgentBinary` this skips the npm
88
+ * package.json walk and just symlinks the resolved PATH binary directly into
89
+ * `{versionDir}/node_modules/.bin/{cliCommand}`. The symlink is what makes
90
+ * `listInstalledVersions` consider the version Managed.
91
+ *
92
+ * Layout produced:
93
+ *
94
+ * {versionDir}/
95
+ * package.json # marker (private, imported, from)
96
+ * home/ # empty isolated $HOME
97
+ * node_modules/.bin/{cliCommand} -> {binaryPath}
98
+ *
99
+ * For agents whose binary lookup is special-cased elsewhere (e.g. Grok's
100
+ * `~/.grok/downloads/`), the symlink is still created — `getBinaryPath` won't
101
+ * read it for those agents, but it documents provenance and lets a future
102
+ * refactor consolidate the binary-resolution registry.
103
+ */
104
+ export declare function importInstallScriptBinary(spec: AgentBinarySpec, version: string, binaryPath: string, versionDir: string): ImportBinaryResult;
84
105
  /**
85
106
  * Resolve the on-disk npm package directory for an agent's CLI binary by
86
107
  * walking up from the binary, following any symlinks. Returns null if the
@@ -17,6 +17,7 @@
17
17
  * true.
18
18
  */
19
19
  import * as fs from 'fs';
20
+ import * as os from 'os';
20
21
  import * as path from 'path';
21
22
  import { AGENTS } from './agents.js';
22
23
  import { getVersionsDir } from './state.js';
@@ -42,12 +43,17 @@ export async function importAgentConfig(agentId, version) {
42
43
  const configDir = agent.configDir;
43
44
  const versionsDir = getVersionsDir();
44
45
  const versionHome = path.join(versionsDir, agentId, version, 'home');
45
- const versionConfigDir = path.join(versionHome, `.${agentId}`);
46
+ // Match the shim's derivation in generateShimScript: the per-version config
47
+ // path mirrors the original configDir's path relative to $HOME. Hardcoding
48
+ // `.${agentId}` broke for nested configDirs like Antigravity
49
+ // (`~/.gemini/antigravity-cli`) — the destination would be `.antigravity`,
50
+ // mismatching the shim's expectation of `.gemini/antigravity-cli`.
51
+ const versionConfigDir = path.join(versionHome, path.relative(os.homedir(), configDir));
46
52
  if (fs.existsSync(versionConfigDir)) {
47
53
  return { success: false, skipped: true, error: `${version} already installed` };
48
54
  }
49
55
  try {
50
- fs.mkdirSync(versionHome, { recursive: true });
56
+ fs.mkdirSync(path.dirname(versionConfigDir), { recursive: true });
51
57
  fs.renameSync(configDir, versionConfigDir);
52
58
  fs.symlinkSync(versionConfigDir, configDir);
53
59
  setGlobalDefault(agentId, version);
@@ -155,6 +161,53 @@ export function importAgentBinary(spec, version, globalPath, versionDir) {
155
161
  return { success: false, error: err.message };
156
162
  }
157
163
  }
164
+ /**
165
+ * Register an existing installScript-based binary (Grok, Antigravity, Cursor,
166
+ * etc. — anything with `npmPackage: ''` and a curl/brew installer) under the
167
+ * managed version path. Unlike `importAgentBinary` this skips the npm
168
+ * package.json walk and just symlinks the resolved PATH binary directly into
169
+ * `{versionDir}/node_modules/.bin/{cliCommand}`. The symlink is what makes
170
+ * `listInstalledVersions` consider the version Managed.
171
+ *
172
+ * Layout produced:
173
+ *
174
+ * {versionDir}/
175
+ * package.json # marker (private, imported, from)
176
+ * home/ # empty isolated $HOME
177
+ * node_modules/.bin/{cliCommand} -> {binaryPath}
178
+ *
179
+ * For agents whose binary lookup is special-cased elsewhere (e.g. Grok's
180
+ * `~/.grok/downloads/`), the symlink is still created — `getBinaryPath` won't
181
+ * read it for those agents, but it documents provenance and lets a future
182
+ * refactor consolidate the binary-resolution registry.
183
+ */
184
+ export function importInstallScriptBinary(spec, version, binaryPath, versionDir) {
185
+ const binaryLink = path.join(versionDir, 'node_modules', '.bin', spec.cliCommand);
186
+ let alreadyExists = false;
187
+ try {
188
+ fs.lstatSync(binaryLink);
189
+ alreadyExists = true;
190
+ }
191
+ catch {
192
+ /* not present */
193
+ }
194
+ if (alreadyExists) {
195
+ return { success: false, skipped: true, error: `${version} already installed`, resolvedFromPath: binaryPath };
196
+ }
197
+ if (!fs.existsSync(binaryPath)) {
198
+ return { success: false, error: `Binary does not exist: ${binaryPath}` };
199
+ }
200
+ try {
201
+ fs.mkdirSync(path.join(versionDir, 'home'), { recursive: true });
202
+ fs.mkdirSync(path.join(versionDir, 'node_modules', '.bin'), { recursive: true });
203
+ fs.writeFileSync(path.join(versionDir, 'package.json'), JSON.stringify({ name: `agents-${spec.agentId}-${version}`, version: '1.0.0', private: true, imported: true, from: binaryPath, installScriptBased: true }, null, 2));
204
+ fs.symlinkSync(binaryPath, binaryLink);
205
+ return { success: true, resolvedFromPath: binaryPath };
206
+ }
207
+ catch (err) {
208
+ return { success: false, error: err.message };
209
+ }
210
+ }
158
211
  /**
159
212
  * Resolve the on-disk npm package directory for an agent's CLI binary by
160
213
  * walking up from the binary, following any symlinks. Returns null if the
package/dist/lib/mcp.d.ts CHANGED
@@ -43,6 +43,21 @@ export declare function parseMcpServerConfig(filePath: string): McpYamlConfig |
43
43
  * List all MCP server configs from ~/.agents/mcp/.
44
44
  */
45
45
  export declare function listMcpServerConfigs(cwd?: string): InstalledMcpServer[];
46
+ /**
47
+ * Scan a repository for MCP server YAML configs.
48
+ * Looks under <repoPath>/mcp/*.yaml — same on-disk layout as ~/.agents/mcp/.
49
+ */
50
+ export declare function discoverMcpConfigsFromRepo(repoPath: string): InstalledMcpServer[];
51
+ /**
52
+ * Install an MCP YAML config from a source file into ~/.agents/mcp/.
53
+ * Re-serializes via writeMcpServerConfig so the on-disk filename is
54
+ * deterministic (sanitized from the server name).
55
+ */
56
+ export declare function installMcpConfigCentrally(sourcePath: string): {
57
+ success: boolean;
58
+ error?: string;
59
+ path?: string;
60
+ };
46
61
  /**
47
62
  * Get MCP servers by name.
48
63
  * If names is provided, returns only those servers.
package/dist/lib/mcp.js CHANGED
@@ -113,6 +113,46 @@ export function listMcpServerConfigs(cwd = process.cwd()) {
113
113
  }
114
114
  return Array.from(results.values());
115
115
  }
116
+ /**
117
+ * Scan a repository for MCP server YAML configs.
118
+ * Looks under <repoPath>/mcp/*.yaml — same on-disk layout as ~/.agents/mcp/.
119
+ */
120
+ export function discoverMcpConfigsFromRepo(repoPath) {
121
+ const dir = path.join(repoPath, 'mcp');
122
+ if (!fs.existsSync(dir))
123
+ return [];
124
+ const results = [];
125
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
126
+ if (!entry.isFile())
127
+ continue;
128
+ if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.yml'))
129
+ continue;
130
+ const filePath = path.join(dir, entry.name);
131
+ const config = parseMcpServerConfig(filePath);
132
+ if (config) {
133
+ results.push({ name: config.name, path: filePath, config, scope: 'user' });
134
+ }
135
+ }
136
+ return results;
137
+ }
138
+ /**
139
+ * Install an MCP YAML config from a source file into ~/.agents/mcp/.
140
+ * Re-serializes via writeMcpServerConfig so the on-disk filename is
141
+ * deterministic (sanitized from the server name).
142
+ */
143
+ export function installMcpConfigCentrally(sourcePath) {
144
+ try {
145
+ const config = parseMcpServerConfig(sourcePath);
146
+ if (!config) {
147
+ return { success: false, error: `Invalid MCP config at ${sourcePath}` };
148
+ }
149
+ const written = writeMcpServerConfig(config);
150
+ return { success: true, path: written };
151
+ }
152
+ catch (err) {
153
+ return { success: false, error: err.message };
154
+ }
155
+ }
116
156
  /**
117
157
  * Get MCP servers by name.
118
158
  * If names is provided, returns only those servers.
@@ -97,6 +97,19 @@ export declare function removePermissionSet(name: string): {
97
97
  * Claude uses: { permissions: { allow: ["Bash(*)", "Read(**)"], deny: [] } }
98
98
  */
99
99
  export declare function convertToClaudeFormat(set: PermissionSet): ClaudePermissions;
100
+ /**
101
+ * Convert canonical permission set to Gemini format.
102
+ * Gemini reads tool allow-lists from settings.json under `tools.allowed`.
103
+ * Bash permissions map to run_shell_command(prefix) — the prefix is extracted
104
+ * from the canonical "Bash(cmd:*)" pattern by stripping the trailing ":*".
105
+ * Blanket Bash grants map to bare "run_shell_command" (no prefix filter).
106
+ * Non-Bash tool patterns are skipped; Gemini uses different tool names.
107
+ */
108
+ export declare function convertToGeminiFormat(set: PermissionSet): {
109
+ tools: {
110
+ allowed: string[];
111
+ };
112
+ };
100
113
  /**
101
114
  * Convert canonical permission set to OpenCode format.
102
115
  * OpenCode uses: { permission: { bash: { "git *": "allow", "rm *": "deny" } } }
@@ -14,13 +14,14 @@ import * as yaml from 'yaml';
14
14
  import * as TOML from 'smol-toml';
15
15
  import { getPermissionsDir, getUserPermissionsDir, ensureAgentsDir } from './state.js';
16
16
  import { safeJoin } from './paths.js';
17
+ import { updateGeminiSettings } from './gemini-settings.js';
17
18
  const HOME = os.homedir();
18
19
  /** Agents that support the permissions subsystem. */
19
20
  // antigravity: permissions in ~/.gemini/antigravity-cli/settings.json under
20
21
  // `permissions: { allow: [...], deny: [...] }`. Serializer is a follow-up.
21
22
  // grok: permissions via --allow/--deny CLI flags or [permission] block in
22
23
  // ~/.grok/config.toml. Serializer is a follow-up.
23
- export const PERMISSIONS_CAPABLE_AGENTS = ['claude', 'codex', 'opencode', 'antigravity', 'grok'];
24
+ export const PERMISSIONS_CAPABLE_AGENTS = ['claude', 'codex', 'opencode', 'antigravity', 'grok', 'gemini'];
24
25
  /** Filename used for Codex Starlark deny-rules generated from permission groups. */
25
26
  export const CODEX_RULES_FILENAME = 'agents-deny.rules';
26
27
  export function containsBroadGrants(rules) {
@@ -416,6 +417,35 @@ function parseCanonicalPattern(permission) {
416
417
  }
417
418
  /** Blanket-Bash canonical forms that mean "allow any bash command". */
418
419
  const BLANKET_BASH_FORMS = new Set(['Bash', 'Bash(*)', 'Bash(**)']);
420
+ /**
421
+ * Convert canonical permission set to Gemini format.
422
+ * Gemini reads tool allow-lists from settings.json under `tools.allowed`.
423
+ * Bash permissions map to run_shell_command(prefix) — the prefix is extracted
424
+ * from the canonical "Bash(cmd:*)" pattern by stripping the trailing ":*".
425
+ * Blanket Bash grants map to bare "run_shell_command" (no prefix filter).
426
+ * Non-Bash tool patterns are skipped; Gemini uses different tool names.
427
+ */
428
+ export function convertToGeminiFormat(set) {
429
+ const allowed = new Set();
430
+ for (const perm of set.allow) {
431
+ if (BLANKET_BASH_FORMS.has(perm)) {
432
+ allowed.add('run_shell_command');
433
+ continue;
434
+ }
435
+ const parsed = parseCanonicalPattern(perm);
436
+ if (!parsed || parsed.tool !== 'bash')
437
+ continue;
438
+ const colonIdx = parsed.pattern.lastIndexOf(':');
439
+ const prefix = colonIdx > 0 ? parsed.pattern.slice(0, colonIdx) : parsed.pattern;
440
+ if (prefix === '*' || prefix === '**') {
441
+ allowed.add('run_shell_command');
442
+ }
443
+ else {
444
+ allowed.add(`run_shell_command(${prefix})`);
445
+ }
446
+ }
447
+ return { tools: { allowed: Array.from(allowed) } };
448
+ }
419
449
  /**
420
450
  * Convert canonical permission set to OpenCode format.
421
451
  * OpenCode uses: { permission: { bash: { "git *": "allow", "rm *": "deny" } } }
@@ -881,6 +911,26 @@ export function applyPermissionsToVersion(agentId, set, versionHome, merge = tru
881
911
  }
882
912
  return { success: true };
883
913
  }
914
+ if (agentId === 'gemini') {
915
+ const geminiPerms = convertToGeminiFormat(set);
916
+ const settingsPath = path.join(versionHome, '.gemini', 'settings.json');
917
+ updateGeminiSettings(settingsPath, (settings) => {
918
+ // Remove stale permissions key written by earlier versions of this serializer.
919
+ delete settings.permissions;
920
+ const tools = (typeof settings.tools === 'object' && settings.tools !== null && !Array.isArray(settings.tools))
921
+ ? settings.tools
922
+ : {};
923
+ if (merge) {
924
+ const existing = Array.isArray(tools.allowed) ? tools.allowed : [];
925
+ tools.allowed = Array.from(new Set([...existing, ...geminiPerms.tools.allowed]));
926
+ }
927
+ else {
928
+ tools.allowed = geminiPerms.tools.allowed;
929
+ }
930
+ settings.tools = tools;
931
+ });
932
+ return { success: true };
933
+ }
884
934
  return { success: false, error: `Agent '${agentId}' does not support permissions` };
885
935
  }
886
936
  catch (err) {
@@ -45,6 +45,16 @@ export declare function knownMarketplacesPath(agent: AgentId, versionHome: strin
45
45
  /**
46
46
  * Copy plugin source into marketplace install dir.
47
47
  * Source of truth remains ~/.agents/plugins/<name>/ — this is a per-version snapshot.
48
+ *
49
+ * Symlinks pointing OUTSIDE the plugin source root are dropped. They show up
50
+ * when plugin authors (legitimately) link prompt-side references to sibling
51
+ * codebases — e.g. the rush plugin's `app -> ../../../rush/app` for @app/...
52
+ * autocomplete in user prompts. Faithfully copying those symlinks pollutes
53
+ * the marketplace with gigabytes of node_modules / .next / brand-asset video
54
+ * that the consumer (Claude Code, OpenClaw) then walks during plugin
55
+ * discovery — which is the documented cause of multi-minute startup hangs.
56
+ *
57
+ * Internal symlinks (target stays inside the plugin root) are preserved.
48
58
  */
49
59
  export declare function copyPluginToMarketplace(plugin: DiscoveredPlugin, agent: AgentId, versionHome: string): string;
50
60
  /**