@phnx-labs/agents-cli 1.20.25 → 1.20.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/commands/doctor.d.ts +5 -2
  3. package/dist/commands/doctor.js +126 -27
  4. package/dist/commands/inspect.d.ts +2 -1
  5. package/dist/commands/inspect.js +1 -1
  6. package/dist/commands/menubar.js +6 -1
  7. package/dist/commands/repo.js +40 -0
  8. package/dist/commands/secrets.js +16 -12
  9. package/dist/commands/sessions.js +20 -1
  10. package/dist/index.js +2 -12
  11. package/dist/lib/agent-spec.d.ts +36 -0
  12. package/dist/lib/agent-spec.js +157 -0
  13. package/dist/lib/agents.js +1 -0
  14. package/dist/lib/daemon.js +32 -0
  15. package/dist/lib/doctor-diff.d.ts +7 -0
  16. package/dist/lib/doctor-diff.js +18 -13
  17. package/dist/lib/fs-atomic.d.ts +3 -2
  18. package/dist/lib/fs-atomic.js +22 -7
  19. package/dist/lib/heal.d.ts +107 -0
  20. package/dist/lib/heal.js +279 -0
  21. package/dist/lib/hooks.js +36 -1
  22. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  23. package/dist/lib/menubar/install-menubar.d.ts +27 -3
  24. package/dist/lib/menubar/install-menubar.js +74 -9
  25. package/dist/lib/plugin-marketplace.d.ts +18 -0
  26. package/dist/lib/plugin-marketplace.js +67 -1
  27. package/dist/lib/plugins.d.ts +23 -1
  28. package/dist/lib/plugins.js +55 -10
  29. package/dist/lib/resources/rules.d.ts +5 -2
  30. package/dist/lib/resources/rules.js +39 -10
  31. package/dist/lib/rules/compose.d.ts +22 -0
  32. package/dist/lib/rules/compose.js +114 -9
  33. package/dist/lib/secrets/agent.d.ts +4 -2
  34. package/dist/lib/secrets/agent.js +6 -4
  35. package/dist/lib/secrets/bundles.d.ts +20 -14
  36. package/dist/lib/secrets/bundles.js +31 -10
  37. package/dist/lib/session/remote.d.ts +33 -0
  38. package/dist/lib/session/remote.js +114 -0
  39. package/dist/lib/staleness/checkers/rules.js +13 -1
  40. package/dist/lib/staleness/detectors/commands.js +7 -6
  41. package/dist/lib/staleness/writers/commands.js +7 -12
  42. package/dist/lib/startup/dev-build.d.ts +22 -0
  43. package/dist/lib/startup/dev-build.js +41 -0
  44. package/dist/lib/types.d.ts +13 -2
  45. package/dist/lib/versions.d.ts +3 -1
  46. package/dist/lib/versions.js +35 -3
  47. package/package.json +3 -3
@@ -65,7 +65,12 @@ export declare function loadPluginManifest(pluginRoot: string): PluginManifest |
65
65
  export declare function validatePluginName(name: string): boolean;
66
66
  export declare function assertPluginTargetContained(targetRoot: string, pluginsDir: string): void;
67
67
  /**
68
- * Get a specific plugin by name.
68
+ * Get a specific plugin by name. On a cross-marketplace name collision the
69
+ * highest-precedence scope wins (project > extra > user > system) — the same
70
+ * resolution the sync writer's Map(last-wins) dedupe and collectPluginScopes()
71
+ * use. discoverPlugins() yields low→high precedence order, so the LAST match is
72
+ * the winner; returning the first match would resolve to the lowest scope (e.g.
73
+ * a system plugin over the user's same-named one), which is exactly backwards.
69
74
  */
70
75
  export declare function getPlugin(name: string): DiscoveredPlugin | null;
71
76
  /**
@@ -212,6 +217,23 @@ export declare function installPlugin(spec: string): Promise<{
212
217
  isNew: boolean;
213
218
  capabilities: PluginCapabilities;
214
219
  }>;
220
+ /** Parsed `.source` provenance written by install/update. `version` is the
221
+ * upstream manifest version captured at the last pull (absent on pre-existing
222
+ * installs from before baseline tracking). */
223
+ export interface PluginSourceInfo {
224
+ source: string;
225
+ isGit: boolean;
226
+ version?: string;
227
+ }
228
+ /** Read a plugin's `.source` provenance, or null when absent/unreadable. */
229
+ export declare function readPluginSourceInfo(root: string): PluginSourceInfo | null;
230
+ /**
231
+ * Resolve the CURRENT upstream manifest version for a local-sourced plugin
232
+ * (the `.system`/local-path case). Returns null for git sources — reading their
233
+ * upstream version would need a network fetch, so git plugins are refreshed only
234
+ * via the explicit `agents plugins update`.
235
+ */
236
+ export declare function getUpstreamManifestVersion(info: PluginSourceInfo): string | null;
215
237
  /**
216
238
  * Update an installed plugin by re-pulling from its original source.
217
239
  * Returns true if the update succeeded.
@@ -11,14 +11,14 @@
11
11
  import * as fs from 'fs';
12
12
  import * as path from 'path';
13
13
  import { execFileSync } from 'child_process';
14
- import { getPluginsDir, getTrashPluginsDir, getExtraPluginsDir, getProjectPluginsDir } from './state.js';
14
+ import { getPluginsDir, getTrashPluginsDir, getExtraPluginsDir, getProjectPluginsDir, getSystemPluginsDir } from './state.js';
15
15
  import { IS_WINDOWS, isWindowsAbsolutePath, homeDir } from './platform/index.js';
16
16
  import { assertSafeGitTransport } from './git.js';
17
17
  import { listInstalledVersions, getVersionHomePath } from './versions.js';
18
18
  import { AGENTS, agentConfigDirName } from './agents.js';
19
19
  import { capableAgents, isCapable } from './capabilities.js';
20
20
  import { shouldInstallCommandAsSkill, installCommandSkillToVersion } from './command-skills.js';
21
- import { copyPluginToMarketplace, syncMarketplaceManifest, registerMarketplace, unregisterMarketplace, addPluginToSettings, removePluginFromSettings, removePluginFromMarketplace, marketplaceIsEmpty, removeEmptyMarketplaceDir, isInstalledInMarketplace, marketplaceRoot, discoverMarketplaces, marketplaceNameFor, MARKETPLACE_NAME, PROJECT_MARKETPLACE_NAME, } from './plugin-marketplace.js';
21
+ import { copyPluginToMarketplace, syncMarketplaceManifest, registerMarketplace, unregisterMarketplace, addPluginToSettings, removePluginFromSettings, removePluginFromMarketplace, marketplaceIsEmpty, removeEmptyMarketplaceDir, isInstalledInMarketplace, marketplaceRoot, discoverMarketplaces, marketplaceNameFor, MARKETPLACE_NAME, PROJECT_MARKETPLACE_NAME, SYSTEM_MARKETPLACE_NAME, } from './plugin-marketplace.js';
22
22
  const PLUGIN_MANIFEST_DIR = '.claude-plugin';
23
23
  const PLUGIN_MANIFEST_FILE = 'plugin.json';
24
24
  const USER_CONFIG_FILE = '.user-config.json';
@@ -186,11 +186,20 @@ export function assertPluginTargetContained(targetRoot, pluginsDir) {
186
186
  }
187
187
  }
188
188
  /**
189
- * Get a specific plugin by name.
189
+ * Get a specific plugin by name. On a cross-marketplace name collision the
190
+ * highest-precedence scope wins (project > extra > user > system) — the same
191
+ * resolution the sync writer's Map(last-wins) dedupe and collectPluginScopes()
192
+ * use. discoverPlugins() yields low→high precedence order, so the LAST match is
193
+ * the winner; returning the first match would resolve to the lowest scope (e.g.
194
+ * a system plugin over the user's same-named one), which is exactly backwards.
190
195
  */
191
196
  export function getPlugin(name) {
192
197
  const plugins = discoverPlugins();
193
- return plugins.find(p => p.name === name) || null;
198
+ for (let i = plugins.length - 1; i >= 0; i--) {
199
+ if (plugins[i].name === name)
200
+ return plugins[i];
201
+ }
202
+ return null;
194
203
  }
195
204
  /**
196
205
  * Check if an agent supports a specific plugin.
@@ -382,13 +391,18 @@ export function checkPluginDependencies(manifest) {
382
391
  /**
383
392
  * Reconstruct a MarketplaceSpec from a marketplace name. The inverse of
384
393
  * marketplaceNameFor(): "agents-cli" → user, "agents-project" → project,
385
- * "agents-<alias>" → extra. The per-version marketplace operations only key off
386
- * the name (never spec.root), but we resolve the real source root anyway so the
387
- * spec is honest for any caller that inspects it.
394
+ * "agents-system" → system, "agents-<alias>" → extra. The per-version
395
+ * marketplace operations only key off the name (never spec.root), but we
396
+ * resolve the real source root anyway so the spec is honest for any caller that
397
+ * inspects it (e.g. descriptionFor, which would otherwise label the system
398
+ * marketplace as an extra repo named "system").
388
399
  */
389
400
  function marketplaceSpecForName(name, cwd = process.cwd()) {
390
401
  if (!name || name === MARKETPLACE_NAME)
391
402
  return { kind: 'user' };
403
+ if (name === SYSTEM_MARKETPLACE_NAME) {
404
+ return { kind: 'system', root: getSystemPluginsDir() };
405
+ }
392
406
  if (name === PROJECT_MARKETPLACE_NAME) {
393
407
  return { kind: 'project', root: getProjectPluginsDir(cwd) ?? '' };
394
408
  }
@@ -1096,10 +1110,38 @@ export async function installPlugin(spec) {
1096
1110
  throw new Error(`Installed source has no valid .claude-plugin/plugin.json`);
1097
1111
  }
1098
1112
  const capabilities = inspectPluginCapabilities(targetRoot);
1099
- // Persist source for future updates
1100
- fs.writeFileSync(path.join(targetRoot, SOURCE_FILE), JSON.stringify({ source, isGit: !isLocalPath }), 'utf-8');
1113
+ // Persist source for future updates. `version` records the manifest version
1114
+ // at pull time a baseline that lets the heal path tell "central is an
1115
+ // untouched copy of upstream" (safe to fast-forward) from "the user edited
1116
+ // it" (leave alone) without hashing the whole tree.
1117
+ fs.writeFileSync(path.join(targetRoot, SOURCE_FILE), JSON.stringify({ source, isGit: !isLocalPath, version: manifest.version }), 'utf-8');
1101
1118
  return { name: manifest.name, root: targetRoot, isNew, capabilities };
1102
1119
  }
1120
+ /** Read a plugin's `.source` provenance, or null when absent/unreadable. */
1121
+ export function readPluginSourceInfo(root) {
1122
+ const f = path.join(root, SOURCE_FILE);
1123
+ if (!fs.existsSync(f))
1124
+ return null;
1125
+ try {
1126
+ return JSON.parse(fs.readFileSync(f, 'utf-8'));
1127
+ }
1128
+ catch {
1129
+ return null;
1130
+ }
1131
+ }
1132
+ /**
1133
+ * Resolve the CURRENT upstream manifest version for a local-sourced plugin
1134
+ * (the `.system`/local-path case). Returns null for git sources — reading their
1135
+ * upstream version would need a network fetch, so git plugins are refreshed only
1136
+ * via the explicit `agents plugins update`.
1137
+ */
1138
+ export function getUpstreamManifestVersion(info) {
1139
+ if (info.isGit)
1140
+ return null;
1141
+ const resolved = info.source.replace(/^~/, homeDir());
1142
+ const m = loadPluginManifest(resolved);
1143
+ return m?.version ?? null;
1144
+ }
1103
1145
  /**
1104
1146
  * Update an installed plugin by re-pulling from its original source.
1105
1147
  * Returns true if the update succeeded.
@@ -1136,11 +1178,14 @@ export async function updatePlugin(name) {
1136
1178
  : null;
1137
1179
  fs.rmSync(plugin.root, { recursive: true, force: true });
1138
1180
  fs.cpSync(resolvedSource, plugin.root, { recursive: true });
1139
- fs.writeFileSync(path.join(plugin.root, SOURCE_FILE), JSON.stringify(sourceInfo), 'utf-8');
1140
1181
  if (userConfigBackup !== null) {
1141
1182
  fs.writeFileSync(userConfigPath, userConfigBackup, 'utf-8');
1142
1183
  }
1143
1184
  }
1185
+ // Re-stamp .source with the freshly pulled manifest version so the baseline
1186
+ // tracks what's now on disk (keeps the heal "unmodified?" check honest).
1187
+ const freshVersion = loadPluginManifest(plugin.root)?.version;
1188
+ fs.writeFileSync(path.join(plugin.root, SOURCE_FILE), JSON.stringify({ ...sourceInfo, version: freshVersion }), 'utf-8');
1144
1189
  }
1145
1190
  catch (err) {
1146
1191
  return { success: false, error: err.message };
@@ -22,8 +22,11 @@ export interface RulesLayerDir {
22
22
  dir: string;
23
23
  }
24
24
  /**
25
- * List subrule markdown files in a directory.
26
- * Returns names without the .md extension.
25
+ * List subrule names in a directory.
26
+ *
27
+ * A name comes from either the flat form `<name>.md` OR the dir form
28
+ * `<name>/rule.md`; a directory without `rule.md` is not a subrule. Returns
29
+ * names without the .md extension.
27
30
  */
28
31
  export declare function listSubrulesInDir(subrulesDir: string): string[];
29
32
  /**
@@ -14,19 +14,46 @@ import * as path from 'path';
14
14
  import { getSystemRulesDir, getUserRulesDir, getProjectAgentsDir, getEnabledExtraRepos, } from '../state.js';
15
15
  const SUBRULES_DIR = 'subrules';
16
16
  const SUBRULES_README = 'README.md';
17
+ /** Inside a dir-form subrule, the prose file. */
18
+ const SUBRULE_RULE_FILE = 'rule.md';
17
19
  /**
18
- * List subrule markdown files in a directory.
19
- * Returns names without the .md extension.
20
+ * Resolve the prose file path for a subrule named `name` under `subrulesDir`.
21
+ *
22
+ * Dir form `<name>/rule.md` wins when present; otherwise the flat `<name>.md`.
23
+ * Returns null when neither exists.
24
+ */
25
+ function resolveSubruleFile(subrulesDir, name) {
26
+ const dirForm = path.join(subrulesDir, name, SUBRULE_RULE_FILE);
27
+ if (fs.existsSync(dirForm))
28
+ return dirForm;
29
+ const flatForm = path.join(subrulesDir, `${name}.md`);
30
+ if (fs.existsSync(flatForm))
31
+ return flatForm;
32
+ return null;
33
+ }
34
+ /**
35
+ * List subrule names in a directory.
36
+ *
37
+ * A name comes from either the flat form `<name>.md` OR the dir form
38
+ * `<name>/rule.md`; a directory without `rule.md` is not a subrule. Returns
39
+ * names without the .md extension.
20
40
  */
21
41
  export function listSubrulesInDir(subrulesDir) {
22
42
  if (!fs.existsSync(subrulesDir))
23
43
  return [];
24
44
  try {
25
- return fs
26
- .readdirSync(subrulesDir)
27
- .filter((f) => f.endsWith('.md') && f !== SUBRULES_README)
28
- .map((f) => f.slice(0, -3))
29
- .sort();
45
+ const names = new Set();
46
+ for (const entry of fs.readdirSync(subrulesDir, { withFileTypes: true })) {
47
+ if (entry.isDirectory()) {
48
+ if (fs.existsSync(path.join(subrulesDir, entry.name, SUBRULE_RULE_FILE))) {
49
+ names.add(entry.name);
50
+ }
51
+ }
52
+ else if (entry.name.endsWith('.md') && entry.name !== SUBRULES_README) {
53
+ names.add(entry.name.slice(0, -3));
54
+ }
55
+ }
56
+ return [...names].sort();
30
57
  }
31
58
  catch {
32
59
  return [];
@@ -77,7 +104,9 @@ export function listAllRules(layers) {
77
104
  if (seen.has(name))
78
105
  continue;
79
106
  seen.add(name);
80
- const filePath = path.join(subrulesDir, `${name}.md`);
107
+ const filePath = resolveSubruleFile(subrulesDir, name);
108
+ if (!filePath)
109
+ continue;
81
110
  let content = '';
82
111
  try {
83
112
  content = fs.readFileSync(filePath, 'utf-8');
@@ -101,8 +130,8 @@ export function listAllRules(layers) {
101
130
  */
102
131
  export function resolveRule(name, layers) {
103
132
  for (const { layer, dir } of layers) {
104
- const filePath = path.join(dir, SUBRULES_DIR, `${name}.md`);
105
- if (fs.existsSync(filePath)) {
133
+ const filePath = resolveSubruleFile(path.join(dir, SUBRULES_DIR), name);
134
+ if (filePath) {
106
135
  let content = '';
107
136
  try {
108
137
  content = fs.readFileSync(filePath, 'utf-8');
@@ -18,6 +18,7 @@
18
18
  * No filesystem writes happen here — callers (`syncResourcesToVersion`,
19
19
  * project-rules compile) decide where to land the composed output.
20
20
  */
21
+ import type { ManifestHook } from '../types.js';
21
22
  export type LayerScope = 'project' | 'user' | 'extra' | 'system';
22
23
  export interface RulesLayer {
23
24
  scope: LayerScope;
@@ -43,6 +44,8 @@ export interface ComposedSubrule {
43
44
  sourcePath: string;
44
45
  layerScope: LayerScope;
45
46
  layerAlias?: string;
47
+ /** Set when the subrule is dir-form (`subrules/<name>/`); the dir itself. */
48
+ subruleDir?: string;
46
49
  }
47
50
  export interface ComposeResult {
48
51
  /** Fully concatenated, no @-imports. */
@@ -76,3 +79,22 @@ export declare function composeRulesFromState(opts?: {
76
79
  preset?: string;
77
80
  cwd?: string;
78
81
  }): ComposeResult;
82
+ /**
83
+ * Collect hooks declared inside the active subrule directories.
84
+ *
85
+ * Resolves the same composed subrule set as {@link composeRules} (preset-named
86
+ * plus auto-append, highest-layer-wins per name). For each dir-form subrule
87
+ * that ships a `hooks.yaml`, parses it, rewrites each hook's `script` to an
88
+ * ABSOLUTE path under the subrule dir, and namespaces the key as
89
+ * `<subruleName>__<hookName>` to avoid collisions across subrules.
90
+ *
91
+ * Returns an empty map for flat subrules and dir-form subrules without a
92
+ * `hooks.yaml`. A malformed hooks.yaml is skipped (try/catch) so a bad file
93
+ * never breaks rule composition or hook registration.
94
+ */
95
+ export declare function collectSubruleHooks(layers: RulesLayer[], presetName?: string): Record<string, ManifestHook>;
96
+ /** Convenience wrapper — discovers layers from state, then collects hooks. */
97
+ export declare function collectSubruleHooksFromState(opts?: {
98
+ preset?: string;
99
+ cwd?: string;
100
+ }): Record<string, ManifestHook>;
@@ -26,6 +26,28 @@ const SUBRULES_DIR_NAME = 'subrules';
26
26
  const RULES_YAML_NAME = 'rules.yaml';
27
27
  const DEFAULT_PRESET = 'default';
28
28
  const SUBRULES_README = 'README.md';
29
+ /** Inside a dir-form subrule, the prose file. */
30
+ const SUBRULE_RULE_FILE = 'rule.md';
31
+ /** Inside a dir-form subrule, the optional hook manifest. */
32
+ const SUBRULE_HOOKS_FILE = 'hooks.yaml';
33
+ /**
34
+ * Resolve the prose file for a subrule named `name` under `<rulesDir>/subrules/`.
35
+ *
36
+ * A subrule resolves to the DIRECTORY form `subrules/<name>/rule.md` when that
37
+ * file exists, otherwise the flat form `subrules/<name>.md`. Returns the
38
+ * markdown path plus the dir-form subrule directory when applicable (callers
39
+ * that fold hooks need the dir to resolve `hooks.yaml` and relative scripts).
40
+ */
41
+ function resolveSubrulePath(rulesDir, name) {
42
+ const dirForm = path.join(rulesDir, SUBRULES_DIR_NAME, name, SUBRULE_RULE_FILE);
43
+ if (fs.existsSync(dirForm)) {
44
+ return { sourcePath: dirForm, subruleDir: path.join(rulesDir, SUBRULES_DIR_NAME, name) };
45
+ }
46
+ const flatForm = path.join(rulesDir, SUBRULES_DIR_NAME, `${name}.md`);
47
+ if (fs.existsSync(flatForm))
48
+ return { sourcePath: flatForm };
49
+ return null;
50
+ }
29
51
  function readRulesYaml(rulesDir) {
30
52
  const p = path.join(rulesDir, RULES_YAML_NAME);
31
53
  if (!fs.existsSync(p))
@@ -51,22 +73,33 @@ function resolvePreset(layers, preset) {
51
73
  }
52
74
  function findSubrule(layers, name) {
53
75
  for (const layer of layers) {
54
- const p = path.join(layer.rulesDir, SUBRULES_DIR_NAME, `${name}.md`);
55
- if (fs.existsSync(p))
56
- return { sourcePath: p, layer };
76
+ const found = resolveSubrulePath(layer.rulesDir, name);
77
+ if (found)
78
+ return { ...found, layer };
57
79
  }
58
80
  return null;
59
81
  }
82
+ /**
83
+ * List subrule names in a layer. A name is contributed by either the flat
84
+ * form `subrules/<name>.md` OR the dir form `subrules/<name>/rule.md`; a
85
+ * directory without `rule.md` is not a subrule and is skipped.
86
+ */
60
87
  function listLayerSubruleNames(layer) {
61
88
  const dir = path.join(layer.rulesDir, SUBRULES_DIR_NAME);
62
89
  if (!fs.existsSync(dir))
63
90
  return [];
64
91
  try {
65
- return fs
66
- .readdirSync(dir)
67
- .filter((f) => f.endsWith('.md') && f !== SUBRULES_README)
68
- .map((f) => f.slice(0, -3))
69
- .sort();
92
+ const names = new Set();
93
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
94
+ if (entry.isDirectory()) {
95
+ if (fs.existsSync(path.join(dir, entry.name, SUBRULE_RULE_FILE)))
96
+ names.add(entry.name);
97
+ }
98
+ else if (entry.name.endsWith('.md') && entry.name !== SUBRULES_README) {
99
+ names.add(entry.name.slice(0, -3));
100
+ }
101
+ }
102
+ return [...names].sort();
70
103
  }
71
104
  catch {
72
105
  return [];
@@ -98,6 +131,7 @@ export function composeRules(opts) {
98
131
  sourcePath: found.sourcePath,
99
132
  layerScope: found.layer.scope,
100
133
  layerAlias: found.layer.alias,
134
+ subruleDir: found.subruleDir,
101
135
  });
102
136
  seen.add(name);
103
137
  }
@@ -109,11 +143,15 @@ export function composeRules(opts) {
109
143
  for (const name of listLayerSubruleNames(layer)) {
110
144
  if (seen.has(name))
111
145
  continue;
146
+ const resolved = resolveSubrulePath(layer.rulesDir, name);
147
+ if (!resolved)
148
+ continue;
112
149
  composed.push({
113
150
  name,
114
- sourcePath: path.join(layer.rulesDir, SUBRULES_DIR_NAME, `${name}.md`),
151
+ sourcePath: resolved.sourcePath,
115
152
  layerScope: layer.scope,
116
153
  layerAlias: layer.alias,
154
+ subruleDir: resolved.subruleDir,
117
155
  });
118
156
  seen.add(name);
119
157
  }
@@ -168,3 +206,70 @@ export function composeRulesFromState(opts = {}) {
168
206
  const layers = discoverRulesLayers({ cwd: opts.cwd });
169
207
  return composeRules({ preset: opts.preset, layers });
170
208
  }
209
+ /**
210
+ * hooks.yaml shape (the bare-map form, chosen for brevity):
211
+ *
212
+ * <hookName>:
213
+ * script: enforce.sh # relative to the subrule dir
214
+ * events: [PreToolUse]
215
+ * matcher: "Edit|Write" # optional
216
+ * timeout: 30 # optional
217
+ *
218
+ * A wrapped `{ hooks: { <hookName>: {...} } }` form is also accepted so a
219
+ * hooks.yaml can carry sibling keys without confusing the parser.
220
+ */
221
+ function parseSubruleHooksFile(file) {
222
+ const parsed = yaml.parse(fs.readFileSync(file, 'utf-8'));
223
+ if (!parsed || typeof parsed !== 'object')
224
+ return {};
225
+ const map = parsed.hooks ?? parsed;
226
+ return map || {};
227
+ }
228
+ /**
229
+ * Collect hooks declared inside the active subrule directories.
230
+ *
231
+ * Resolves the same composed subrule set as {@link composeRules} (preset-named
232
+ * plus auto-append, highest-layer-wins per name). For each dir-form subrule
233
+ * that ships a `hooks.yaml`, parses it, rewrites each hook's `script` to an
234
+ * ABSOLUTE path under the subrule dir, and namespaces the key as
235
+ * `<subruleName>__<hookName>` to avoid collisions across subrules.
236
+ *
237
+ * Returns an empty map for flat subrules and dir-form subrules without a
238
+ * `hooks.yaml`. A malformed hooks.yaml is skipped (try/catch) so a bad file
239
+ * never breaks rule composition or hook registration.
240
+ */
241
+ export function collectSubruleHooks(layers, presetName) {
242
+ const result = {};
243
+ let composed;
244
+ try {
245
+ composed = composeRules({ preset: presetName, layers });
246
+ }
247
+ catch {
248
+ return result;
249
+ }
250
+ for (const sub of composed.subrules) {
251
+ if (!sub.subruleDir)
252
+ continue; // flat subrule — no hooks
253
+ const hooksFile = path.join(sub.subruleDir, SUBRULE_HOOKS_FILE);
254
+ if (!fs.existsSync(hooksFile))
255
+ continue;
256
+ try {
257
+ const hooks = parseSubruleHooksFile(hooksFile);
258
+ for (const [hookName, def] of Object.entries(hooks)) {
259
+ if (!def || typeof def !== 'object' || typeof def.script !== 'string')
260
+ continue;
261
+ const absScript = path.resolve(sub.subruleDir, def.script);
262
+ result[`${sub.name}__${hookName}`] = { ...def, script: absScript };
263
+ }
264
+ }
265
+ catch {
266
+ // Malformed hooks.yaml — skip this subrule's hooks, keep the rest.
267
+ }
268
+ }
269
+ return result;
270
+ }
271
+ /** Convenience wrapper — discovers layers from state, then collects hooks. */
272
+ export function collectSubruleHooksFromState(opts = {}) {
273
+ const layers = discoverRulesLayers({ cwd: opts.cwd });
274
+ return collectSubruleHooks(layers, opts.preset);
275
+ }
@@ -140,8 +140,10 @@ export declare function agentGetSync(name: string): {
140
140
  bundle: SecretsBundle;
141
141
  env: Record<string, string>;
142
142
  } | null;
143
- /** True when `secrets.agent.auto` is enabled in agents.yaml. Best-effort; a
144
- * missing/unreadable meta reads as off. */
143
+ /** True unless `secrets.agent.auto` is explicitly disabled in agents.yaml. The
144
+ * broker is the mechanism that delivers the `daily` default policy (one Touch ID
145
+ * per ~24h), so auto-caching is ON by default; opt out with
146
+ * `secrets.agent.auto: false`. Best-effort; an unreadable meta reads as on. */
145
147
  export declare function secretsAgentAutoEnabled(): boolean;
146
148
  /**
147
149
  * Fire-and-forget: populate the broker with a freshly-resolved bundle so the
@@ -529,14 +529,16 @@ export function agentGetSync(name) {
529
529
  return null;
530
530
  }
531
531
  }
532
- /** True when `secrets.agent.auto` is enabled in agents.yaml. Best-effort; a
533
- * missing/unreadable meta reads as off. */
532
+ /** True unless `secrets.agent.auto` is explicitly disabled in agents.yaml. The
533
+ * broker is the mechanism that delivers the `daily` default policy (one Touch ID
534
+ * per ~24h), so auto-caching is ON by default; opt out with
535
+ * `secrets.agent.auto: false`. Best-effort; an unreadable meta reads as on. */
534
536
  export function secretsAgentAutoEnabled() {
535
537
  try {
536
- return readMeta().secrets?.agent?.auto === true;
538
+ return readMeta().secrets?.agent?.auto !== false;
537
539
  }
538
540
  catch {
539
- return false;
541
+ return true;
540
542
  }
541
543
  }
542
544
  /**
@@ -40,18 +40,19 @@ export interface VarMeta {
40
40
  }
41
41
  /**
42
42
  * A bundle's prompt policy — how often macOS asks for Touch ID to read it:
43
- * - `always` (default): asks every time. Only an explicit `agents secrets
44
- * unlock` ever holds it in the secrets-agent; every other read pops Touch ID.
45
- * Use for high-value bundles you want to confirm every time.
46
- * - `daily`: ask once, then hold it silently. Eligible for the secrets-agent
47
- * `unlock` it, or (when `secrets.agent.auto` is enabled) the first real
48
- * keychain read auto-loads it so concurrent runs read it silently. Held up to
49
- * ~24h from that unlock (not refreshed on use); re-asks sooner after
50
- * screen-lock, sleep, logout, or `agents secrets lock`.
43
+ * - `daily` (default): ask once, then hold it silently for up to ~24h. Eligible
44
+ * for the secrets-agent the first real keychain read auto-loads it (auto-cache
45
+ * is on by default) so concurrent runs read it silently, or `unlock` it
46
+ * explicitly. Held from that unlock (not refreshed on use); re-asks sooner
47
+ * after screen-lock, sleep, logout, or `agents secrets lock`.
48
+ * - `always`: asks every time. Never auto-held only an explicit `agents
49
+ * secrets unlock` ever holds it; every other read pops Touch ID. Opt a
50
+ * high-value bundle into this when you want to confirm every single read.
51
51
  *
52
- * Stored on disk under the legacy `tier` key (`session` == `daily`; absent ==
53
- * `always`) so bundles stay readable across mixed CLI versions on synced
54
- * machines. The in-memory and user-facing vocabulary is `policy`/`always`/`daily`.
52
+ * The default is configurable via `secrets.policy` in agents.yaml. Stored on disk
53
+ * under the legacy `tier` key (`session` == `daily`, `biometry` == explicit
54
+ * `always`, absent == inherit the default) so bundles stay readable across mixed
55
+ * CLI versions on synced machines. The user-facing vocabulary is `policy`/`always`/`daily`.
55
56
  */
56
57
  export type SecretsPolicy = 'always' | 'daily';
57
58
  /** A named set of environment variable definitions backed by various secret providers. */
@@ -61,8 +62,8 @@ export interface SecretsBundle {
61
62
  allow_exec?: boolean;
62
63
  /** Which store carries this bundle's items. Absent ⇒ `keychain` (the default). */
63
64
  backend?: SecretsBackend;
64
- /** Prompt policy. Absent ⇒ `always` (the safe default). Serialized under the
65
- * legacy `tier` key — see SecretsPolicy. */
65
+ /** Prompt policy. Absent ⇒ the configured default (`daily`). Serialized under
66
+ * the legacy `tier` key — see SecretsPolicy. */
66
67
  policy?: SecretsPolicy;
67
68
  /** ISO 8601 UTC timestamp. Set once on the first writeBundle() for a bundle. */
68
69
  created_at?: string;
@@ -97,7 +98,12 @@ export declare function validateSecretType(t: string): asserts t is SecretType;
97
98
  export declare function validateExpiresFutureDated(iso: string): void;
98
99
  export declare function bundleExists(name: string): boolean;
99
100
  export declare function readBundle(name: string): SecretsBundle;
100
- /** The effective prompt policy of a bundle (absent `always`). */
101
+ /** The default prompt policy applied to bundles without an explicit per-bundle
102
+ * policy. Configurable via `secrets.policy` in agents.yaml; `daily` (one Touch
103
+ * ID per ~24h) unless the user explicitly opts back into prompt-every-time with
104
+ * `always`. Best-effort: an unreadable config falls back to the `daily` default. */
105
+ export declare function secretsDefaultPolicy(): SecretsPolicy;
106
+ /** The effective prompt policy of a bundle (absent ⇒ the configured default). */
101
107
  export declare function bundlePolicy(bundle: SecretsBundle): SecretsPolicy;
102
108
  export declare function writeBundle(bundle: SecretsBundle): void;
103
109
  export declare function deleteBundle(name: string): boolean;
@@ -24,6 +24,7 @@ import * as yaml from 'yaml';
24
24
  import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
25
25
  import { fileStore } from './filestore.js';
26
26
  import { emit } from '../events.js';
27
+ import { readMeta } from '../state.js';
27
28
  import { agentGetSync, agentAutoLoadSync, secretsAgentAutoEnabled, DEFAULT_TTL_MS } from './agent.js';
28
29
  const keychainStore = {
29
30
  has: hasKeychainToken,
@@ -244,16 +245,34 @@ export function readBundle(name) {
244
245
  }
245
246
  return bundle;
246
247
  }
247
- /** Normalize the persisted prompt policy. The on-disk `tier` key uses the
248
- * legacy `session` token for `daily` (and `biometry`/absent for the default),
249
- * so accept both the legacy and current tokens. Anything but `daily`/`session`
250
- * undefined (resolves to the `always` default). */
248
+ /** Normalize the persisted prompt policy. The on-disk `tier` key uses legacy
249
+ * tokens for cross-version compatibility: `session` `daily`, `biometry` an
250
+ * explicit `always`. An absent token undefined, which resolves to the
251
+ * configured default policy (`daily`). Persisting an explicit `always` as the
252
+ * legacy `biometry` token keeps older CLIs correct — they don't know `daily`,
253
+ * read `biometry` as undefined, and fall back to their own always default. */
251
254
  function parsePolicy(raw) {
252
- return raw === 'daily' || raw === 'session' ? 'daily' : undefined;
255
+ if (raw === 'daily' || raw === 'session')
256
+ return 'daily';
257
+ if (raw === 'always' || raw === 'biometry')
258
+ return 'always';
259
+ return undefined;
253
260
  }
254
- /** The effective prompt policy of a bundle (absent `always`). */
261
+ /** The default prompt policy applied to bundles without an explicit per-bundle
262
+ * policy. Configurable via `secrets.policy` in agents.yaml; `daily` (one Touch
263
+ * ID per ~24h) unless the user explicitly opts back into prompt-every-time with
264
+ * `always`. Best-effort: an unreadable config falls back to the `daily` default. */
265
+ export function secretsDefaultPolicy() {
266
+ try {
267
+ return readMeta().secrets?.policy === 'always' ? 'always' : 'daily';
268
+ }
269
+ catch {
270
+ return 'daily';
271
+ }
272
+ }
273
+ /** The effective prompt policy of a bundle (absent ⇒ the configured default). */
255
274
  export function bundlePolicy(bundle) {
256
- return bundle.policy ?? 'always';
275
+ return bundle.policy ?? secretsDefaultPolicy();
257
276
  }
258
277
  export function writeBundle(bundle) {
259
278
  validateBundleName(bundle.name);
@@ -292,9 +311,11 @@ export function writeBundle(bundle) {
292
311
  description: bundle.description,
293
312
  allow_exec: bundle.allow_exec ? true : undefined,
294
313
  backend: backend === 'file' ? 'file' : undefined,
295
- // Wire format: persist `daily` under the legacy `tier`/`session` token so
296
- // older CLI versions on other synced machines keep reading the policy.
297
- tier: bundle.policy === 'daily' ? 'session' : undefined,
314
+ // Wire format: persist the policy under the legacy `tier` token so older CLI
315
+ // versions on other synced machines keep reading it — `daily`⇒`session`,
316
+ // explicit `always`⇒`biometry`. An absent policy omits the token entirely
317
+ // and resolves to the configured default (`daily`) on read.
318
+ tier: bundle.policy === 'daily' ? 'session' : bundle.policy === 'always' ? 'biometry' : undefined,
298
319
  created_at: bundle.created_at,
299
320
  updated_at: bundle.updated_at,
300
321
  last_used: bundle.last_used,
@@ -0,0 +1,33 @@
1
+ /**
2
+ * SSH target: a bare ssh-config host alias (e.g. `yosemite-s1`) or `user@host`.
3
+ * The strict allowlist blocks shell metacharacters and a leading `-`, so a target
4
+ * can never be smuggled in as an ssh argv flag.
5
+ */
6
+ export declare const SSH_TARGET_RE: RegExp;
7
+ export declare function assertValidSshTarget(host: string): void;
8
+ /** POSIX single-quote a string for safe interpolation into a remote shell command. */
9
+ export declare function shellQuote(s: string): string;
10
+ /**
11
+ * Strip the `--host`/`-H` flag (and its value) from a raw `agents sessions` argv,
12
+ * leaving the args to forward to the remote unchanged. The remote runs the same
13
+ * binary, so every other flag (`--since`, `--last`, `--json`, query, …) carries
14
+ * over for free. Handles every form commander accepts: `--host h`, `--host=h`,
15
+ * `-H h`, `-H=h`, and the glued short form `-Hh`.
16
+ *
17
+ * @param argv full process argv; the sessions args begin at index 2
18
+ * (`[runtime, script, 'sessions', ...]`).
19
+ */
20
+ export declare function buildForwardedArgs(argv: string[], hosts?: Set<string>): string[];
21
+ /**
22
+ * Build the single remote command string for `ssh <host> <cmd>`. Forwarded args
23
+ * are quoted for the inner login shell, then the whole `agents …` invocation is
24
+ * quoted again so it survives `bash -lc <...>`.
25
+ */
26
+ export declare function buildRemoteCommand(forwardedArgs: string[]): string;
27
+ /**
28
+ * Run the current `agents sessions` invocation on one or more remote machines over
29
+ * SSH, streaming each remote's output to the terminal. Sets `process.exitCode = 1`
30
+ * if any host fails. Reads the invocation from `process.argv` (override via
31
+ * `argv` for testing).
32
+ */
33
+ export declare function runRemoteSessions(hosts: string[], argv?: string[]): void;