@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.
- package/CHANGELOG.md +37 -0
- package/dist/commands/doctor.d.ts +5 -2
- package/dist/commands/doctor.js +126 -27
- package/dist/commands/inspect.d.ts +2 -1
- package/dist/commands/inspect.js +1 -1
- package/dist/commands/menubar.js +6 -1
- package/dist/commands/repo.js +40 -0
- package/dist/commands/secrets.js +16 -12
- package/dist/commands/sessions.js +20 -1
- package/dist/index.js +2 -12
- package/dist/lib/agent-spec.d.ts +36 -0
- package/dist/lib/agent-spec.js +157 -0
- package/dist/lib/agents.js +1 -0
- package/dist/lib/daemon.js +32 -0
- package/dist/lib/doctor-diff.d.ts +7 -0
- package/dist/lib/doctor-diff.js +18 -13
- package/dist/lib/fs-atomic.d.ts +3 -2
- package/dist/lib/fs-atomic.js +22 -7
- package/dist/lib/heal.d.ts +107 -0
- package/dist/lib/heal.js +279 -0
- package/dist/lib/hooks.js +36 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +27 -3
- package/dist/lib/menubar/install-menubar.js +74 -9
- package/dist/lib/plugin-marketplace.d.ts +18 -0
- package/dist/lib/plugin-marketplace.js +67 -1
- package/dist/lib/plugins.d.ts +23 -1
- package/dist/lib/plugins.js +55 -10
- package/dist/lib/resources/rules.d.ts +5 -2
- package/dist/lib/resources/rules.js +39 -10
- package/dist/lib/rules/compose.d.ts +22 -0
- package/dist/lib/rules/compose.js +114 -9
- package/dist/lib/secrets/agent.d.ts +4 -2
- package/dist/lib/secrets/agent.js +6 -4
- package/dist/lib/secrets/bundles.d.ts +20 -14
- package/dist/lib/secrets/bundles.js +31 -10
- package/dist/lib/session/remote.d.ts +33 -0
- package/dist/lib/session/remote.js +114 -0
- package/dist/lib/staleness/checkers/rules.js +13 -1
- package/dist/lib/staleness/detectors/commands.js +7 -6
- package/dist/lib/staleness/writers/commands.js +7 -12
- package/dist/lib/startup/dev-build.d.ts +22 -0
- package/dist/lib/startup/dev-build.js +41 -0
- package/dist/lib/types.d.ts +13 -2
- package/dist/lib/versions.d.ts +3 -1
- package/dist/lib/versions.js +35 -3
- package/package.json +3 -3
package/dist/lib/plugins.d.ts
CHANGED
|
@@ -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.
|
package/dist/lib/plugins.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
386
|
-
* the name (never spec.root), but we
|
|
387
|
-
* spec is honest for any caller that
|
|
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
|
-
|
|
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
|
|
26
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 =
|
|
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,
|
|
105
|
-
if (
|
|
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
|
|
55
|
-
if (
|
|
56
|
-
return {
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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:
|
|
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
|
|
144
|
-
*
|
|
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
|
|
533
|
-
*
|
|
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
|
|
538
|
+
return readMeta().secrets?.agent?.auto !== false;
|
|
537
539
|
}
|
|
538
540
|
catch {
|
|
539
|
-
return
|
|
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
|
-
* - `
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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 ⇒
|
|
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
|
|
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
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
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
|
-
|
|
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
|
|
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 ??
|
|
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
|
|
296
|
-
//
|
|
297
|
-
|
|
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;
|