@phnx-labs/agents-cli 1.20.24 → 1.20.26
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 +11 -0
- 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/lib/fs-atomic.d.ts +3 -2
- package/dist/lib/fs-atomic.js +22 -7
- package/dist/lib/hooks.js +36 -1
- package/dist/lib/menubar/install-menubar.d.ts +27 -3
- package/dist/lib/menubar/install-menubar.js +74 -9
- 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/staleness/checkers/rules.js +13 -1
- package/dist/lib/types.d.ts +6 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
**Secrets default policy is now `daily` (one Touch ID per ~24h), not `always`**
|
|
6
|
+
|
|
7
|
+
- The default prompt policy for bundles without an explicit one flipped from `always` (Touch ID on *every* read) to **`daily`** (one prompt, then held ~24h until screen-lock / sleep / logout). This is the fix for the prompt storm: a background reader like sessions-sync hammering a bundle now costs one Touch ID per ~24h instead of one per read.
|
|
8
|
+
- **Auto-cache is on by default.** The secrets-agent is the mechanism that delivers the daily policy, so it self-caches a `daily` bundle on first read with no `secrets.agent.auto: true` needed. Opt out with `secrets.agent.auto: false`.
|
|
9
|
+
- **Configurable, still flexible.** Set the global default in `agents.yaml` (`secrets.policy: always` to restore prompt-every-time), or override per bundle with `agents secrets policy <bundle> always` for high-value keys (signing, SSH) you want to confirm on every read.
|
|
10
|
+
- **Explicit `always` now persists** under the legacy `tier: biometry` token (older CLIs read it as their own always default). Bundles with no stored policy inherit the configured default — so an existing always-by-default bundle quietly becomes `daily` on first read by the new CLI, which is the intended migration.
|
|
11
|
+
|
|
12
|
+
**`agents repos view [name]`: inspect one repo's contents without opening it**
|
|
13
|
+
|
|
14
|
+
- New `agents repo view <name>` (also reachable as `agents repos view`, now a first-class alias of the `repo` command) prints a single repo's git state and per-kind resource counts — `system`, `user`, `project`, or an extra-repo alias. Omit the name for an interactive picker over the registered repos. It reuses the `inspect` repo renderer, so output matches `agents inspect <repo>`; supports `--brief` and `--json`. Source: `src/commands/repo.ts`, `src/commands/inspect.ts`.
|
|
15
|
+
|
|
5
16
|
**Secrets prompt policy: human-readable `always` / `daily`, and `secrets list` now shows it**
|
|
6
17
|
|
|
7
18
|
- Renamed the secrets-agent `tier` to a **prompt policy** with plain-language names: `biometry` → **`always`** (ask every time), `session` → **`daily`** (ask once, then held ~24h until screen-lock / sleep / logout). The old name `session` was misleading — it never meant "once per login session" — and collided with the half-dozen other "session" concepts in the CLI (`agents sessions`, sessions-sync, pty/browser sessions). Set it with `agents secrets policy <bundle> [always|daily]`.
|
|
@@ -32,7 +32,7 @@ interface ResourceItem {
|
|
|
32
32
|
/** For plugins: the resource categories (skills, commands, …) the bundle packages. */
|
|
33
33
|
groups?: PluginResourceGroup[];
|
|
34
34
|
}
|
|
35
|
-
interface InspectOptions {
|
|
35
|
+
export interface InspectOptions {
|
|
36
36
|
brief?: boolean;
|
|
37
37
|
json?: boolean;
|
|
38
38
|
commands?: boolean | string;
|
|
@@ -66,6 +66,7 @@ export interface RepoTarget {
|
|
|
66
66
|
* Returns null when the target is none of these.
|
|
67
67
|
*/
|
|
68
68
|
export declare function resolveRepoTarget(target: string, cwd?: string): RepoTarget | null;
|
|
69
|
+
export declare function inspectRepo(repo: RepoTarget, options: InspectOptions): Promise<void>;
|
|
69
70
|
/** List one resource kind from a single repo root — no layering, no overrides. */
|
|
70
71
|
export declare function collectRepoKind(repo: RepoTarget, kind: DrillableKind): ResourceItem[];
|
|
71
72
|
/** Recursive size + file count of a path; symlinks are not followed. */
|
package/dist/commands/inspect.js
CHANGED
|
@@ -227,7 +227,7 @@ function isDotAgentsRoot(dir) {
|
|
|
227
227
|
}
|
|
228
228
|
return false;
|
|
229
229
|
}
|
|
230
|
-
async function inspectRepo(repo, options) {
|
|
230
|
+
export async function inspectRepo(repo, options) {
|
|
231
231
|
const drill = pickDrillKind(options);
|
|
232
232
|
const jsonHead = { repo: repo.label, root: repo.root };
|
|
233
233
|
if (drill) {
|
package/dist/commands/menubar.js
CHANGED
|
@@ -61,9 +61,14 @@ export function registerMenubarCommands(program) {
|
|
|
61
61
|
console.log(` running ${yn(s.running)}`);
|
|
62
62
|
console.log(` service installed ${yn(s.serviceInstalled)}`);
|
|
63
63
|
console.log(` app installed ${s.installedApp ? chalk.gray(s.installedApp) : chalk.gray('no')}`);
|
|
64
|
+
console.log(` installed version ${s.installedVersion ? chalk.gray(s.installedVersion) : chalk.gray('unknown')}`);
|
|
65
|
+
console.log(` current version ${chalk.gray(s.currentVersion)}`);
|
|
64
66
|
console.log(` bundle source ${s.source ? chalk.gray(s.source) : chalk.red('missing (cannot enable)')}`);
|
|
65
67
|
console.log(` disabled by user ${yn(s.disabledByUser)}`);
|
|
66
|
-
if (
|
|
68
|
+
if (s.stale) {
|
|
69
|
+
console.log(chalk.yellow('\n Installed helper is stale — runs on next `agents` startup, or `agents menubar enable` now.'));
|
|
70
|
+
}
|
|
71
|
+
else if (!s.serviceInstalled && !s.disabledByUser) {
|
|
67
72
|
console.log(chalk.gray('\n Enable it with `agents menubar enable`.'));
|
|
68
73
|
}
|
|
69
74
|
});
|
package/dist/commands/repo.js
CHANGED
|
@@ -7,6 +7,8 @@ import simpleGit from 'simple-git';
|
|
|
7
7
|
import { confirm, input } from '@inquirer/prompts';
|
|
8
8
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
9
9
|
import { setHelpSections } from '../lib/help.js';
|
|
10
|
+
import { itemPicker } from '../lib/picker.js';
|
|
11
|
+
import { inspectRepo, resolveRepoTarget, } from './inspect.js';
|
|
10
12
|
const HOME = os.homedir();
|
|
11
13
|
/**
|
|
12
14
|
* Resolve a target argument to an absolute path.
|
|
@@ -162,6 +164,7 @@ async function listRepos(alias) {
|
|
|
162
164
|
export function registerRepoCommands(program) {
|
|
163
165
|
const repoCmd = program
|
|
164
166
|
.command('repo')
|
|
167
|
+
.alias('repos')
|
|
165
168
|
.description('Manage extra DotAgent repos alongside ~/.agents/ (for private or team skills).');
|
|
166
169
|
setHelpSections(repoCmd, {
|
|
167
170
|
examples: `
|
|
@@ -180,6 +183,9 @@ export function registerRepoCommands(program) {
|
|
|
180
183
|
# See what's registered
|
|
181
184
|
agents repo list
|
|
182
185
|
|
|
186
|
+
# View one repo's contents (git state + resource counts); omit the name for a picker
|
|
187
|
+
agents repos view system
|
|
188
|
+
|
|
183
189
|
# Temporarily disable without deleting
|
|
184
190
|
agents repo disable acme
|
|
185
191
|
`,
|
|
@@ -356,6 +362,40 @@ export function registerRepoCommands(program) {
|
|
|
356
362
|
.action(async (alias) => {
|
|
357
363
|
await listRepos(alias);
|
|
358
364
|
});
|
|
365
|
+
repoCmd
|
|
366
|
+
.command('view [name]')
|
|
367
|
+
.description("Show one repo's contents: git state and per-kind resource counts. Omit the name for an interactive picker.")
|
|
368
|
+
.option('--brief', 'header + git only; skip resource counts')
|
|
369
|
+
.option('--json', 'machine-readable JSON output')
|
|
370
|
+
.action(async (name, options) => {
|
|
371
|
+
if (name) {
|
|
372
|
+
const repo = resolveRepoTarget(name);
|
|
373
|
+
if (!repo) {
|
|
374
|
+
console.log(chalk.red(`Unknown repo "${name}". Use "system", "user", "project", or a registered extra alias.`));
|
|
375
|
+
process.exitCode = 1;
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
await inspectRepo(repo, options);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const targets = collectRepoTargets(undefined) || [];
|
|
382
|
+
if (!isInteractiveTerminal()) {
|
|
383
|
+
console.log(chalk.red('No repo name given and not an interactive terminal.'));
|
|
384
|
+
console.log(chalk.gray('Pass a name (e.g. `agents repos view system`) or run in a TTY for the picker.'));
|
|
385
|
+
process.exitCode = 1;
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const picked = await itemPicker({
|
|
389
|
+
message: 'Select a repo to view',
|
|
390
|
+
items: targets,
|
|
391
|
+
filter: (q) => targets.filter((t) => t.alias.toLowerCase().includes(q.toLowerCase())),
|
|
392
|
+
labelFor: (t) => `${chalk.cyan(t.alias.padEnd(10))} ${chalk.gray(t.dir)}`,
|
|
393
|
+
});
|
|
394
|
+
if (!picked)
|
|
395
|
+
return;
|
|
396
|
+
const repo = { label: picked.item.alias, root: picked.item.dir };
|
|
397
|
+
await inspectRepo(repo, options);
|
|
398
|
+
});
|
|
359
399
|
repoCmd
|
|
360
400
|
.command('remove <alias>')
|
|
361
401
|
.alias('rm')
|
package/dist/commands/secrets.js
CHANGED
|
@@ -424,14 +424,15 @@ export function registerSecretsCommands(program) {
|
|
|
424
424
|
never touch disk in plaintext. Every item is device-local and gated by Touch ID
|
|
425
425
|
or device passcode; cross-machine sync is handled by 'agents secrets push/pull'.
|
|
426
426
|
|
|
427
|
-
Touch ID noise: macOS pops a prompt per bundle per process
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
always (default) ask for Touch ID every time — never auto-held.
|
|
431
|
-
daily ask once, then hold it silently in the local agent up
|
|
427
|
+
Touch ID noise: macOS pops a prompt per bundle per process. Each bundle has
|
|
428
|
+
a prompt policy, shown in the POLICY column of 'agents secrets list':
|
|
429
|
+
daily (default) ask once, then hold it silently in the local agent up
|
|
432
430
|
to ~24h, until screen-lock / sleep / logout or 'lock'.
|
|
433
|
-
|
|
434
|
-
|
|
431
|
+
always ask for Touch ID every time — never auto-held.
|
|
432
|
+
The default is 'daily' (one Touch ID per ~24h); change it globally with
|
|
433
|
+
'secrets.policy' in agents.yaml, or per bundle with 'agents secrets policy
|
|
434
|
+
<bundle> always'. 'agents secrets unlock <bundle>' holds any bundle after one
|
|
435
|
+
prompt regardless of policy. Nothing on disk.
|
|
435
436
|
|
|
436
437
|
See also:
|
|
437
438
|
agents secrets policy <bundle> daily ask once a day, not every run
|
|
@@ -623,7 +624,7 @@ export function registerSecretsCommands(program) {
|
|
|
623
624
|
.description('Create an empty bundle')
|
|
624
625
|
.option('--description <text>', 'Free-form description')
|
|
625
626
|
.option('--allow-exec', 'Allow exec: refs in this bundle (off by default)')
|
|
626
|
-
.option('--policy <policy>', 'prompt policy:
|
|
627
|
+
.option('--policy <policy>', 'prompt policy: daily (default, ask once a day) or always (ask every time)')
|
|
627
628
|
.addOption(new Option('--tier <policy>', 'deprecated alias for --policy').hideHelp())
|
|
628
629
|
.option('--backend <backend>', 'storage backend: keychain (default) or file (passphrase-encrypted, headless-readable)', 'keychain')
|
|
629
630
|
.option('--force', 'Overwrite an existing bundle')
|
|
@@ -631,7 +632,10 @@ export function registerSecretsCommands(program) {
|
|
|
631
632
|
try {
|
|
632
633
|
const resolvedName = name ?? (await promptBundleName());
|
|
633
634
|
validateBundleName(resolvedName);
|
|
634
|
-
|
|
635
|
+
// Leave policy unset unless the user explicitly chose one, so the bundle
|
|
636
|
+
// inherits the configured default (`daily`) instead of being pinned.
|
|
637
|
+
const policyOpt = opts.policy ?? opts.tier;
|
|
638
|
+
const policy = policyOpt ? parsePolicyOpt(policyOpt) : undefined;
|
|
635
639
|
const backend = parseBackendOpt(opts.backend);
|
|
636
640
|
if (bundleExists(resolvedName) && !opts.force) {
|
|
637
641
|
console.error(chalk.red(`Bundle '${resolvedName}' already exists. Use --force to overwrite.`));
|
|
@@ -647,7 +651,7 @@ export function registerSecretsCommands(program) {
|
|
|
647
651
|
};
|
|
648
652
|
writeBundle(bundle);
|
|
649
653
|
const tags = [
|
|
650
|
-
|
|
654
|
+
bundlePolicy(bundle) === 'daily' ? 'policy: daily' : 'policy: always ask',
|
|
651
655
|
backend === 'file' ? 'backend: file' : null,
|
|
652
656
|
].filter(Boolean);
|
|
653
657
|
console.log(chalk.green(`Bundle '${resolvedName}' created (${tags.join(', ')}).`));
|
|
@@ -1430,7 +1434,7 @@ Examples:
|
|
|
1430
1434
|
cmd
|
|
1431
1435
|
.command('policy <bundle> [policy]')
|
|
1432
1436
|
.alias('tier')
|
|
1433
|
-
.description("Show or set a bundle's prompt policy:
|
|
1437
|
+
.description("Show or set a bundle's prompt policy: daily (default, ask once a day) or always (ask every time).")
|
|
1434
1438
|
.action((bundleName, policyArg) => {
|
|
1435
1439
|
try {
|
|
1436
1440
|
const bundle = readBundle(bundleName);
|
|
@@ -1443,7 +1447,7 @@ Examples:
|
|
|
1443
1447
|
writeBundle(bundle);
|
|
1444
1448
|
console.log(chalk.green(`${bundle.name} policy set to ${next}.`));
|
|
1445
1449
|
if (next === 'daily') {
|
|
1446
|
-
console.log(chalk.gray('Held by the secrets-agent after one unlock
|
|
1450
|
+
console.log(chalk.gray('Held by the secrets-agent for ~24h after one unlock (auto-cache is on by default; disable with `secrets.agent.auto: false` in agents.yaml).'));
|
|
1447
1451
|
}
|
|
1448
1452
|
else {
|
|
1449
1453
|
console.log(chalk.gray('Asks for Touch ID every time — never auto-held.'));
|
package/dist/lib/fs-atomic.d.ts
CHANGED
|
@@ -12,7 +12,8 @@ export declare function ensureLockTarget(filePath: string, initialContent?: stri
|
|
|
12
12
|
export declare function atomicWriteFileSync(filePath: string, content: string): void;
|
|
13
13
|
/**
|
|
14
14
|
* Acquires an exclusive proper-lockfile lock on filePath, runs fn, then
|
|
15
|
-
* releases the lock. Retries
|
|
16
|
-
* Breaks stale locks older than
|
|
15
|
+
* releases the lock. Retries with capped linear back-off until either the lock
|
|
16
|
+
* is acquired or LOCK_ACQUIRE_TIMEOUT_MS elapses. Breaks stale locks older than
|
|
17
|
+
* LOCK_STALE_MS, so a crashed holder never blocks past the stale window.
|
|
17
18
|
*/
|
|
18
19
|
export declare function withFileLock<T>(filePath: string, fn: () => T): T;
|
package/dist/lib/fs-atomic.js
CHANGED
|
@@ -3,7 +3,18 @@ import * as path from 'path';
|
|
|
3
3
|
import { randomBytes } from 'crypto';
|
|
4
4
|
import lockfile from 'proper-lockfile';
|
|
5
5
|
const LOCK_STALE_MS = 5_000;
|
|
6
|
-
|
|
6
|
+
// Wall-clock budget to acquire the lock before giving up. A count-bounded retry
|
|
7
|
+
// (the old 5 attempts / ~750ms ceiling) could expire while a peer legitimately
|
|
8
|
+
// held the lock — under CI/parallel load two `agents` invocations mutating
|
|
9
|
+
// agents.yaml would have one throw and silently drop its write. The budget must
|
|
10
|
+
// comfortably exceed both a normal critical-section hold and the stale-break
|
|
11
|
+
// window (LOCK_STALE_MS): a dead holder's lock turns stale at 5s and is then
|
|
12
|
+
// broken on the next attempt, so this only ever waits out a live, in-progress
|
|
13
|
+
// holder. Bounded (not unbounded) so a truly wedged holder still surfaces an
|
|
14
|
+
// error instead of hanging the CLI forever.
|
|
15
|
+
const LOCK_ACQUIRE_TIMEOUT_MS = 30_000;
|
|
16
|
+
const LOCK_RETRY_MIN_MS = 50;
|
|
17
|
+
const LOCK_RETRY_MAX_MS = 250;
|
|
7
18
|
// Reused across all sleepSync calls — avoids allocating a new SAB each time.
|
|
8
19
|
const _sleepBuf = new Int32Array(new SharedArrayBuffer(4));
|
|
9
20
|
export function sleepSync(ms) {
|
|
@@ -46,26 +57,30 @@ export function atomicWriteFileSync(filePath, content) {
|
|
|
46
57
|
}
|
|
47
58
|
/**
|
|
48
59
|
* Acquires an exclusive proper-lockfile lock on filePath, runs fn, then
|
|
49
|
-
* releases the lock. Retries
|
|
50
|
-
* Breaks stale locks older than
|
|
60
|
+
* releases the lock. Retries with capped linear back-off until either the lock
|
|
61
|
+
* is acquired or LOCK_ACQUIRE_TIMEOUT_MS elapses. Breaks stale locks older than
|
|
62
|
+
* LOCK_STALE_MS, so a crashed holder never blocks past the stale window.
|
|
51
63
|
*/
|
|
52
64
|
export function withFileLock(filePath, fn) {
|
|
53
65
|
let release = null;
|
|
54
66
|
let lastError;
|
|
55
|
-
|
|
67
|
+
const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS;
|
|
68
|
+
for (let attempt = 0;; attempt++) {
|
|
56
69
|
try {
|
|
57
70
|
release = lockfile.lockSync(filePath, { stale: LOCK_STALE_MS });
|
|
58
71
|
break;
|
|
59
72
|
}
|
|
60
73
|
catch (err) {
|
|
61
74
|
lastError = err;
|
|
62
|
-
if (
|
|
63
|
-
|
|
75
|
+
if (Date.now() >= deadline)
|
|
76
|
+
break;
|
|
77
|
+
const backoff = Math.min(LOCK_RETRY_MIN_MS * (attempt + 1), LOCK_RETRY_MAX_MS);
|
|
78
|
+
sleepSync(Math.min(backoff, Math.max(0, deadline - Date.now())));
|
|
64
79
|
}
|
|
65
80
|
}
|
|
66
81
|
if (!release) {
|
|
67
82
|
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
|
68
|
-
throw new Error(`Could not acquire lock for ${filePath}: ${message}`);
|
|
83
|
+
throw new Error(`Could not acquire lock for ${filePath} after ${LOCK_ACQUIRE_TIMEOUT_MS}ms: ${message}`);
|
|
69
84
|
}
|
|
70
85
|
try {
|
|
71
86
|
return fn();
|
package/dist/lib/hooks.js
CHANGED
|
@@ -15,7 +15,8 @@ import * as TOML from 'smol-toml';
|
|
|
15
15
|
import { AGENTS, agentConfigDirName } from './agents.js';
|
|
16
16
|
import { supports, explainSkip, capableAgents } from './capabilities.js';
|
|
17
17
|
import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from './gemini-settings.js';
|
|
18
|
-
import { getHooksDir as getSystemHooksDir, getUserHooksDir, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getTrashHooksDir, getEnabledExtraRepos } from './state.js';
|
|
18
|
+
import { getHooksDir as getSystemHooksDir, getUserHooksDir, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getTrashHooksDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir } from './state.js';
|
|
19
|
+
import { collectSubruleHooksFromState } from './rules/compose.js';
|
|
19
20
|
function getCentralHooksDir() { return getUserHooksDir(); }
|
|
20
21
|
/**
|
|
21
22
|
* Resolve a hook script's absolute path. Checks user dir first, then enabled
|
|
@@ -52,6 +53,12 @@ function getManagedHookPrefixes() {
|
|
|
52
53
|
path.join(getUserAgentsDir(), 'hooks') + path.sep,
|
|
53
54
|
...extraDirs.map(d => path.join(d, 'hooks') + path.sep),
|
|
54
55
|
path.join(getSystemAgentsDir(), 'hooks') + path.sep,
|
|
56
|
+
// Subrule-dir hook scripts register by their absolute source path under a
|
|
57
|
+
// rules `subrules/` tree. Cover those trees so a removed subrule/hook's
|
|
58
|
+
// stale settings entry gets garbage-collected like any other managed hook.
|
|
59
|
+
path.join(getUserRulesDir(), 'subrules') + path.sep,
|
|
60
|
+
...extraDirs.map(d => path.join(d, 'rules', 'subrules') + path.sep),
|
|
61
|
+
path.join(getResolvedRulesDir(), 'subrules') + path.sep,
|
|
55
62
|
];
|
|
56
63
|
}
|
|
57
64
|
/**
|
|
@@ -159,6 +166,19 @@ const SCRIPT_EXTENSIONS = new Set([
|
|
|
159
166
|
function isExecutable(mode) {
|
|
160
167
|
return (mode & 0o111) !== 0;
|
|
161
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Ensure a script file carries an exec bit. Subrule-dir hook scripts are
|
|
171
|
+
* registered by their source path (not copied), so they must be executable in
|
|
172
|
+
* place. Best-effort: a chmod failure (read-only fs, foreign owner) is ignored.
|
|
173
|
+
*/
|
|
174
|
+
function ensureExecutable(scriptPath) {
|
|
175
|
+
try {
|
|
176
|
+
const mode = fs.statSync(scriptPath).mode;
|
|
177
|
+
if (!isExecutable(mode))
|
|
178
|
+
fs.chmodSync(scriptPath, mode | 0o755);
|
|
179
|
+
}
|
|
180
|
+
catch { /* best effort */ }
|
|
181
|
+
}
|
|
162
182
|
function getHooksDir(agentId) {
|
|
163
183
|
const agent = AGENTS[agentId];
|
|
164
184
|
const home = getEffectiveHome(agentId);
|
|
@@ -666,6 +686,15 @@ export function parseHookManifest(opts = {}) {
|
|
|
666
686
|
const warn = opts.warn !== false;
|
|
667
687
|
const merged = {};
|
|
668
688
|
const systemHooks = {};
|
|
689
|
+
// Lowest-precedence layer: hooks declared inside active subrule directories.
|
|
690
|
+
// Seeded first so any same-key entry from system/user agents.yaml wins.
|
|
691
|
+
// Gated so a malformed hooks.yaml never breaks rule sync.
|
|
692
|
+
try {
|
|
693
|
+
const subruleHooks = collectSubruleHooksFromState();
|
|
694
|
+
for (const [name, def] of Object.entries(subruleHooks))
|
|
695
|
+
merged[name] = def;
|
|
696
|
+
}
|
|
697
|
+
catch { /* subrule hook collection is best-effort */ }
|
|
669
698
|
// System layer: hooks: section of agents.yaml (npm-shipped, separate repo).
|
|
670
699
|
const systemPath = path.join(getSystemAgentsDir(), 'agents.yaml');
|
|
671
700
|
if (fs.existsSync(systemPath)) {
|
|
@@ -780,6 +809,12 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
|
|
|
780
809
|
? path.join(versionHome, agentConfigDirName(agentId), AGENTS[agentId].hooksDir)
|
|
781
810
|
: null;
|
|
782
811
|
const resolveScript = (script) => {
|
|
812
|
+
// Subrule-dir hooks declare an already-absolute script path. Use it
|
|
813
|
+
// directly (made executable) — these are not copied into the version home.
|
|
814
|
+
if (path.isAbsolute(script) && fs.existsSync(script)) {
|
|
815
|
+
ensureExecutable(script);
|
|
816
|
+
return script;
|
|
817
|
+
}
|
|
783
818
|
if (overrideRoots) {
|
|
784
819
|
return resolveContainedHookPath(path.join(overrideRoots[0], 'hooks'), script);
|
|
785
820
|
}
|
|
@@ -35,21 +35,45 @@ export declare function ensureMenubarAppInstalled(opts?: {
|
|
|
35
35
|
export declare function enableMenubarService(opts?: {
|
|
36
36
|
clearOptOut?: boolean;
|
|
37
37
|
}): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Pure staleness decision (no I/O) so the truth table is unit-testable. The
|
|
40
|
+
* installed service is stale when the helper binary is gone, or when it was
|
|
41
|
+
* installed by a different CLI version than the one now running — a version
|
|
42
|
+
* change is the signal that the plist's baked interpreter/entry/bundle paths
|
|
43
|
+
* and the helper binary itself may have drifted. A null installedVersion
|
|
44
|
+
* (pre-stamp install) counts as stale so old installs get re-stamped once.
|
|
45
|
+
*/
|
|
46
|
+
export declare function isMenubarStale(opts: {
|
|
47
|
+
installedVersion: string | null;
|
|
48
|
+
currentVersion: string;
|
|
49
|
+
execExists: boolean;
|
|
50
|
+
}): boolean;
|
|
38
51
|
/**
|
|
39
52
|
* Stop + remove the menu-bar service and write the sticky opt-out so the
|
|
40
53
|
* upgrade migration won't re-enable it.
|
|
41
54
|
*/
|
|
42
55
|
export declare function disableMenubarService(): void;
|
|
43
56
|
/**
|
|
44
|
-
*
|
|
45
|
-
* No-ops
|
|
46
|
-
*
|
|
57
|
+
* Startup self-heal, run on every darwin CLI invocation (see src/index.ts).
|
|
58
|
+
* No-ops cheaply (a couple of existsSync + a tiny file read) unless work is
|
|
59
|
+
* needed:
|
|
60
|
+
* - fresh install (no service yet) -> enable
|
|
61
|
+
* - upgrade (version stamp changed) or -> re-enable: recopy the new helper
|
|
62
|
+
* the App Support helper went missing binary + rewrite the plist + kick
|
|
63
|
+
*
|
|
64
|
+
* Without the staleness re-enable, `npm update` refreshed the CLI but left the
|
|
65
|
+
* menu bar running the previous release's helper binary on a possibly-stale
|
|
66
|
+
* plist. No-ops if: not darwin, the user opted out, or no helper bundle ships.
|
|
67
|
+
* Best-effort — never throws into startup.
|
|
47
68
|
*/
|
|
48
69
|
export declare function installMenubarLaunchAgentOnUpgrade(): void;
|
|
49
70
|
export interface MenubarStatus {
|
|
50
71
|
platform: string;
|
|
51
72
|
source: string | null;
|
|
52
73
|
installedApp: string | null;
|
|
74
|
+
installedVersion: string | null;
|
|
75
|
+
currentVersion: string;
|
|
76
|
+
stale: boolean;
|
|
53
77
|
serviceInstalled: boolean;
|
|
54
78
|
running: boolean;
|
|
55
79
|
disabledByUser: boolean;
|
|
@@ -20,15 +20,38 @@ import * as fs from 'fs';
|
|
|
20
20
|
import * as os from 'os';
|
|
21
21
|
import * as path from 'path';
|
|
22
22
|
import { getRuntimeStateDir, getHelpersDir } from '../state.js';
|
|
23
|
+
import { getCliVersion } from '../version.js';
|
|
23
24
|
const APP_BUNDLE_NAME = 'MenubarHelper.app';
|
|
24
25
|
const INSTALL_DIR_NAME = 'agents-cli';
|
|
25
26
|
const SERVICE_LABEL = 'com.phnx-labs.agents-menubar';
|
|
26
27
|
function onDarwin() {
|
|
27
28
|
return process.platform === 'darwin';
|
|
28
29
|
}
|
|
30
|
+
/** ~/Library/Application Support/agents-cli */
|
|
31
|
+
function installDir() {
|
|
32
|
+
return path.join(os.homedir(), 'Library', 'Application Support', INSTALL_DIR_NAME);
|
|
33
|
+
}
|
|
29
34
|
/** ~/Library/Application Support/agents-cli/MenubarHelper.app */
|
|
30
35
|
function installedAppPath() {
|
|
31
|
-
return path.join(
|
|
36
|
+
return path.join(installDir(), APP_BUNDLE_NAME);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Version stamp written next to the installed bundle. The upgrade self-heal
|
|
40
|
+
* compares this against the running CLI's version to decide whether the App
|
|
41
|
+
* Support copy + plist need to be rebuilt — without it, a `npm update` refreshes
|
|
42
|
+
* dist/index.js but leaves the menu bar running the OLD helper binary and a
|
|
43
|
+
* plist whose baked paths may have drifted.
|
|
44
|
+
*/
|
|
45
|
+
function installedVersionMarkerPath() {
|
|
46
|
+
return path.join(installDir(), '.menubar-version');
|
|
47
|
+
}
|
|
48
|
+
function readInstalledMenubarVersion() {
|
|
49
|
+
try {
|
|
50
|
+
return fs.readFileSync(installedVersionMarkerPath(), 'utf-8').trim() || null;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
32
55
|
}
|
|
33
56
|
/** Executable inside the installed bundle. */
|
|
34
57
|
function installedExecutablePath() {
|
|
@@ -222,8 +245,34 @@ export function enableMenubarService(opts = { clearOptOut: true }) {
|
|
|
222
245
|
execFileSync('launchctl', ['kickstart', '-k', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
223
246
|
}
|
|
224
247
|
catch { /* best effort */ }
|
|
248
|
+
// Stamp the version we just installed so the upgrade self-heal can tell when
|
|
249
|
+
// a later release ships a newer helper that needs reinstalling.
|
|
250
|
+
try {
|
|
251
|
+
fs.writeFileSync(installedVersionMarkerPath(), getCliVersion());
|
|
252
|
+
}
|
|
253
|
+
catch { /* best effort */ }
|
|
225
254
|
return true;
|
|
226
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Pure staleness decision (no I/O) so the truth table is unit-testable. The
|
|
258
|
+
* installed service is stale when the helper binary is gone, or when it was
|
|
259
|
+
* installed by a different CLI version than the one now running — a version
|
|
260
|
+
* change is the signal that the plist's baked interpreter/entry/bundle paths
|
|
261
|
+
* and the helper binary itself may have drifted. A null installedVersion
|
|
262
|
+
* (pre-stamp install) counts as stale so old installs get re-stamped once.
|
|
263
|
+
*/
|
|
264
|
+
export function isMenubarStale(opts) {
|
|
265
|
+
if (!opts.execExists)
|
|
266
|
+
return true;
|
|
267
|
+
return opts.installedVersion !== opts.currentVersion;
|
|
268
|
+
}
|
|
269
|
+
function menubarSetupStale() {
|
|
270
|
+
return isMenubarStale({
|
|
271
|
+
installedVersion: readInstalledMenubarVersion(),
|
|
272
|
+
currentVersion: getCliVersion(),
|
|
273
|
+
execExists: fs.existsSync(installedExecutablePath()),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
227
276
|
/**
|
|
228
277
|
* Stop + remove the menu-bar service and write the sticky opt-out so the
|
|
229
278
|
* upgrade migration won't re-enable it.
|
|
@@ -253,9 +302,17 @@ export function disableMenubarService() {
|
|
|
253
302
|
catch { /* best effort */ }
|
|
254
303
|
}
|
|
255
304
|
/**
|
|
256
|
-
*
|
|
257
|
-
* No-ops
|
|
258
|
-
*
|
|
305
|
+
* Startup self-heal, run on every darwin CLI invocation (see src/index.ts).
|
|
306
|
+
* No-ops cheaply (a couple of existsSync + a tiny file read) unless work is
|
|
307
|
+
* needed:
|
|
308
|
+
* - fresh install (no service yet) -> enable
|
|
309
|
+
* - upgrade (version stamp changed) or -> re-enable: recopy the new helper
|
|
310
|
+
* the App Support helper went missing binary + rewrite the plist + kick
|
|
311
|
+
*
|
|
312
|
+
* Without the staleness re-enable, `npm update` refreshed the CLI but left the
|
|
313
|
+
* menu bar running the previous release's helper binary on a possibly-stale
|
|
314
|
+
* plist. No-ops if: not darwin, the user opted out, or no helper bundle ships.
|
|
315
|
+
* Best-effort — never throws into startup.
|
|
259
316
|
*/
|
|
260
317
|
export function installMenubarLaunchAgentOnUpgrade() {
|
|
261
318
|
try {
|
|
@@ -263,14 +320,18 @@ export function installMenubarLaunchAgentOnUpgrade() {
|
|
|
263
320
|
return;
|
|
264
321
|
if (menubarDisabledByUser())
|
|
265
322
|
return;
|
|
266
|
-
if (menubarServiceInstalled())
|
|
267
|
-
return;
|
|
268
323
|
if (!sourceAppPath())
|
|
269
324
|
return;
|
|
270
|
-
|
|
325
|
+
if (!menubarServiceInstalled()) {
|
|
326
|
+
enableMenubarService({ clearOptOut: false });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (menubarSetupStale()) {
|
|
330
|
+
enableMenubarService({ clearOptOut: false });
|
|
331
|
+
}
|
|
271
332
|
}
|
|
272
333
|
catch {
|
|
273
|
-
/* never block
|
|
334
|
+
/* never block startup on the menu bar */
|
|
274
335
|
}
|
|
275
336
|
}
|
|
276
337
|
export function getMenubarStatus() {
|
|
@@ -280,11 +341,15 @@ export function getMenubarStatus() {
|
|
|
280
341
|
const r = spawnSync('pgrep', ['-f', 'MenubarHelper'], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
|
|
281
342
|
running = r.status === 0 && (r.stdout || '').trim().length > 0;
|
|
282
343
|
}
|
|
344
|
+
const serviceInstalled = menubarServiceInstalled();
|
|
283
345
|
return {
|
|
284
346
|
platform: process.platform,
|
|
285
347
|
source: sourceAppPath(),
|
|
286
348
|
installedApp: fs.existsSync(dest) ? dest : null,
|
|
287
|
-
|
|
349
|
+
installedVersion: readInstalledMenubarVersion(),
|
|
350
|
+
currentVersion: getCliVersion(),
|
|
351
|
+
stale: onDarwin() && serviceInstalled && menubarSetupStale(),
|
|
352
|
+
serviceInstalled,
|
|
288
353
|
running,
|
|
289
354
|
disabledByUser: menubarDisabledByUser(),
|
|
290
355
|
};
|
|
@@ -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,
|
|
@@ -54,7 +54,19 @@ function activeSources(agent, version, cwd) {
|
|
|
54
54
|
result['rules.yaml'] = yamlPath;
|
|
55
55
|
}
|
|
56
56
|
for (const sub of compose.subrules) {
|
|
57
|
-
|
|
57
|
+
// Key off the actual source path so dir-form subrules (subrules/<name>/rule.md)
|
|
58
|
+
// and flat ones (subrules/<name>.md) both fingerprint the file that really
|
|
59
|
+
// contributes to the output, not a hard-coded `.md` that may not exist.
|
|
60
|
+
if (sub.subruleDir) {
|
|
61
|
+
result[`subrules/${sub.name}/rule.md`] = sub.sourcePath;
|
|
62
|
+
// Fingerprint hooks.yaml too so editing a hook re-syncs the rules section.
|
|
63
|
+
const hooksFile = path.join(sub.subruleDir, 'hooks.yaml');
|
|
64
|
+
if (fs.existsSync(hooksFile))
|
|
65
|
+
result[`subrules/${sub.name}/hooks.yaml`] = hooksFile;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
result[`subrules/${sub.name}.md`] = sub.sourcePath;
|
|
69
|
+
}
|
|
58
70
|
}
|
|
59
71
|
return result;
|
|
60
72
|
}
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -563,9 +563,13 @@ export interface ExtraRepoConfig {
|
|
|
563
563
|
export interface Meta {
|
|
564
564
|
agents?: Partial<Record<AgentId, string>>;
|
|
565
565
|
run?: RunConfig;
|
|
566
|
-
/** macOS secrets-agent config. `
|
|
567
|
-
*
|
|
566
|
+
/** macOS secrets-agent config. `policy` is the default prompt policy for
|
|
567
|
+
* bundles without an explicit per-bundle policy: `daily` (the default) asks
|
|
568
|
+
* once per ~24h, `always` asks every time. `auto` (default on) lets the first
|
|
569
|
+
* real keychain read of a `daily` bundle populate the broker so concurrent
|
|
570
|
+
* runs read silently — set it `false` to force a prompt on every read. */
|
|
568
571
|
secrets?: {
|
|
572
|
+
policy?: 'always' | 'daily';
|
|
569
573
|
agent?: {
|
|
570
574
|
auto?: boolean;
|
|
571
575
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.26",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|