@phnx-labs/agents-cli 1.18.0 → 1.18.2

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 (49) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/dist/commands/doctor.js +19 -5
  3. package/dist/commands/exec.js +9 -4
  4. package/dist/index.js +30 -0
  5. package/dist/lib/hooks.js +21 -3
  6. package/dist/lib/migrate.js +35 -12
  7. package/dist/lib/shims.d.ts +3 -1
  8. package/dist/lib/shims.js +81 -7
  9. package/dist/lib/staleness/checkers/commands.d.ts +7 -0
  10. package/dist/lib/staleness/checkers/commands.js +27 -0
  11. package/dist/lib/staleness/checkers/hooks.d.ts +13 -0
  12. package/dist/lib/staleness/checkers/hooks.js +63 -0
  13. package/dist/lib/staleness/checkers/mcp.d.ts +12 -0
  14. package/dist/lib/staleness/checkers/mcp.js +38 -0
  15. package/dist/lib/staleness/checkers/permissions.d.ts +17 -0
  16. package/dist/lib/staleness/checkers/permissions.js +73 -0
  17. package/dist/lib/staleness/checkers/plugins.d.ts +11 -0
  18. package/dist/lib/staleness/checkers/plugins.js +39 -0
  19. package/dist/lib/staleness/checkers/rules.d.ts +19 -0
  20. package/dist/lib/staleness/checkers/rules.js +86 -0
  21. package/dist/lib/staleness/checkers/skills.d.ts +7 -0
  22. package/dist/lib/staleness/checkers/skills.js +34 -0
  23. package/dist/lib/staleness/checkers/subagents.d.ts +12 -0
  24. package/dist/lib/staleness/checkers/subagents.js +39 -0
  25. package/dist/lib/staleness/checkers/types.d.ts +44 -0
  26. package/dist/lib/staleness/checkers/types.js +20 -0
  27. package/dist/lib/staleness/checkers/workflows.d.ts +10 -0
  28. package/dist/lib/staleness/checkers/workflows.js +37 -0
  29. package/dist/lib/staleness/fingerprint.d.ts +38 -0
  30. package/dist/lib/staleness/fingerprint.js +154 -0
  31. package/dist/lib/staleness/index.d.ts +26 -0
  32. package/dist/lib/staleness/index.js +122 -0
  33. package/dist/lib/staleness/layers.d.ts +37 -0
  34. package/dist/lib/staleness/layers.js +100 -0
  35. package/dist/lib/staleness/types.d.ts +56 -0
  36. package/dist/lib/staleness/types.js +6 -0
  37. package/dist/lib/state.d.ts +2 -0
  38. package/dist/lib/state.js +2 -0
  39. package/dist/lib/teams/agents.d.ts +11 -20
  40. package/dist/lib/teams/agents.js +55 -202
  41. package/dist/lib/teams/index.d.ts +3 -2
  42. package/dist/lib/teams/index.js +2 -2
  43. package/dist/lib/teams/persistence.d.ts +0 -38
  44. package/dist/lib/teams/persistence.js +7 -329
  45. package/dist/lib/teams/registry.js +7 -5
  46. package/dist/lib/versions.js +34 -12
  47. package/package.json +1 -1
  48. package/dist/lib/sync-manifest.d.ts +0 -81
  49. package/dist/lib/sync-manifest.js +0 -450
package/CHANGELOG.md CHANGED
@@ -1,6 +1,25 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 1.18.2
4
+
5
+ **Teams**
6
+
7
+ - Dropped `~/.agents/teams/config.json` entirely. It duplicated information agents-cli already has — agent commands, enabled flags, model defaults, provider endpoints — none of which the team runner was actually reading. Teams now discover agents via `listInstalledVersions()` (the same source `agents view` uses) and invoke them via the canonical `agents run` subcommand. One spawn path, one canonical exec module (`src/lib/exec.ts`). The deprecated `AGENT_COMMANDS`, `applyEditMode`, `applyFullMode`, `readConfig`, `writeConfig`, `setAgentEnabled`, `AgentConfig`, `SwarmConfig`, `ProviderConfig`, `ModelOverrides`, `ReadConfigResult`, and `EffortLevel` (the persistence-module copy) exports are removed from `@phnx-labs/agents-cli/teams`. Migration deletes both `~/.agents/teams/config.json` and the legacy `~/.agents/config.json`.
8
+ - `~/.agents/teams/registry.json` moves to `~/.agents/.history/teams/registry.json` — it's per-machine runtime state (timestamps + absolute worktree paths) and shouldn't be synced across machines via `agents repo push`.
9
+ - New `agents run --quiet` flag suppresses the rotation banner and `Running: …` preamble lines. Used by the team runner so stream-json events reach the parser without non-JSON preamble.
10
+
11
+ **Dev builds**
12
+
13
+ - The CLI auto-detects dev builds (version stamped `0.0.0-dev.<sha>` by `scripts/install.sh`, or invoked from a working tree where `<cli-dir>/../.git/` exists) and defaults `AGENTS_NO_AUTOPULL=1`, `AGENTS_SKIP_MIGRATION=1`, and `AGENTS_CLI_DISABLE_AUTO_UPDATE=1`. No more typing those three env vars on every iteration. Production installs (registry global, no `.git/` at package root) are unaffected.
14
+
15
+ ## 1.18.1
16
+
17
+ **Fixes**
18
+
19
+ - `scripts/build.sh` now sets mode `0o755` on every file declared in `package.json#bin` after `tsc` emits dist/. Newer npm versions preserve file mode from the published tarball and do NOT auto-chmod the bin target during `npm install -g`, so 1.18.0 shipped with mode-644 entrypoints. Users hit `zsh: permission denied: agents` after auto-update. Re-install to recover: `npm install -g @phnx-labs/agents-cli@latest`.
20
+ - New `scripts/install.sh` builds the working tree as a side-by-side dev install at `$HOME/.local/agents-cli-dev/`, symlinked into `$HOME/.local/bin/agents`. The registry install is never touched — `agents --version` shows `0.0.0-dev.<sha>[-dirty]` when the dev build is on PATH.
21
+
22
+ ## 1.18.0
4
23
 
5
24
  **Plugins**
6
25
 
@@ -1,8 +1,8 @@
1
1
  import chalk from 'chalk';
2
2
  import { checkAllClis } from '../lib/teams/agents.js';
3
3
  import { AGENTS, ALL_AGENT_IDS, resolveAgentName, formatAgentError } from '../lib/agents.js';
4
- import { getAvailableResources, getGlobalDefault, getVersionHomePath, isVersionInstalled, listInstalledVersions, parseAgentSpec, } from '../lib/versions.js';
5
- import { loadSyncManifest, isSyncStale } from '../lib/sync-manifest.js';
4
+ import { getGlobalDefault, getVersionHomePath, isVersionInstalled, listInstalledVersions, parseAgentSpec, } from '../lib/versions.js';
5
+ import { loadManifest, isStale } from '../lib/staleness/index.js';
6
6
  import { diffVersionCommands, iterCommandsCapableVersions } from '../lib/commands.js';
7
7
  import { diffVersionSkills, iterSkillsCapableVersions } from '../lib/skills.js';
8
8
  import { diffVersionHooks, iterHooksCapableVersions } from '../lib/hooks.js';
@@ -17,13 +17,12 @@ function checkSyncStatus(cwd) {
17
17
  const version = getGlobalDefault(agent);
18
18
  if (!version)
19
19
  continue;
20
- const manifest = loadSyncManifest(agent, version);
20
+ const manifest = loadManifest(agent, version);
21
21
  if (!manifest) {
22
22
  rows.push({ agent, version, status: 'never-synced' });
23
23
  continue;
24
24
  }
25
- const available = getAvailableResources(cwd);
26
- const stale = isSyncStale(manifest, available, agent, version, cwd);
25
+ const stale = isStale(manifest, agent, version, cwd);
27
26
  rows.push({ agent, version, status: stale ? 'stale' : 'fresh' });
28
27
  }
29
28
  return rows;
@@ -270,6 +269,21 @@ function renderTargetText(report, options) {
270
269
  : null,
271
270
  ].filter(Boolean).join(' ');
272
271
  console.log(chalk.gray(` layers: ${layerStr}`));
272
+ // Staleness manifest verdict — single-line summary from the staleness
273
+ // library, sitting alongside the detailed per-resource diff below.
274
+ const manifest = loadManifest(report.agent, report.version);
275
+ if (!manifest) {
276
+ console.log(chalk.gray(` manifest: ${chalk.gray('cold')} (never synced)`));
277
+ }
278
+ else {
279
+ const stale = isStale(manifest, report.agent, report.version, report.cwd);
280
+ if (stale) {
281
+ console.log(chalk.gray(' manifest: ') + chalk.yellow('stale') + chalk.gray(' (sources changed since last sync)'));
282
+ }
283
+ else {
284
+ console.log(chalk.gray(' manifest: ') + chalk.green('fresh'));
285
+ }
286
+ }
273
287
  console.log();
274
288
  for (const kind of DOCTOR_ALL_KINDS) {
275
289
  const rows = report.kinds[kind];
@@ -51,6 +51,7 @@ export function registerRunCommand(program) {
51
51
  .option('--cwd <dir>', 'Working directory for the agent (defaults to current directory)')
52
52
  .option('--add-dir <dir>', 'Grant access to an additional directory outside the project (Claude only, repeatable)', (val, prev) => [...prev, val], [])
53
53
  .option('--json', 'Stream events as JSON lines (for parsing by other tools)')
54
+ .option('--quiet', 'Suppress preamble (rotation banner, "Running:" line). Useful when piping JSON events to a parser.', false)
54
55
  .option('--headless', 'Non-interactive mode (auto-enabled when prompt provided)', false)
55
56
  .option('-i, --interactive', 'Force interactive mode even when a prompt is provided')
56
57
  .option('--session-id <id>', 'Resume a previous conversation (Claude only)')
@@ -249,17 +250,19 @@ Examples:
249
250
  const resolved = await resolveRunVersion(agent, strategy, cwd);
250
251
  if (resolved.version) {
251
252
  version = resolved.version;
252
- if (resolved.rotation) {
253
+ if (resolved.rotation && !options.quiet) {
253
254
  const banner = formatRotationBanner(resolved.rotation, strategy);
254
255
  process.stderr.write(chalk.gray(banner + '\n'));
255
256
  }
256
257
  }
257
- else {
258
+ else if (!options.quiet) {
258
259
  process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} found no usable ${agent} version; falling back to defaults\n`));
259
260
  }
260
261
  }
261
262
  catch (err) {
262
- process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} skipped: ${err.message}\n`));
263
+ if (!options.quiet) {
264
+ process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} skipped: ${err.message}\n`));
265
+ }
263
266
  }
264
267
  }
265
268
  }
@@ -388,7 +391,9 @@ Examples:
388
391
  }
389
392
  }
390
393
  const cmd = buildExecCommand(execOptions);
391
- process.stderr.write(chalk.gray(`Running: ${cmd.join(' ')}\n\n`));
394
+ if (!options.quiet) {
395
+ process.stderr.write(chalk.gray(`Running: ${cmd.join(' ')}\n\n`));
396
+ }
392
397
  try {
393
398
  let exitCode;
394
399
  if (fallback.length > 0) {
package/dist/index.js CHANGED
@@ -23,6 +23,36 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
23
  const packageJsonPath = path.join(__dirname, '..', 'package.json');
24
24
  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
25
25
  const VERSION = packageJson.version;
26
+ // Detect dev/working-tree builds and default the noisy startup steps off.
27
+ // Three cases trip this:
28
+ // 1. Dev install (scripts/install.sh) — package.json version stamped 0.0.0-dev.<sha>
29
+ // 2. Running `node dist/index.js` from a working tree — repo root has .git/
30
+ // 3. Running tsx/ts-node from src/ — also has .git/ at the repo root
31
+ // For all three: skip auto-pull (no network noise + no surprise FF on the
32
+ // system repo while iterating), skip migration (a buggy in-progress migration
33
+ // must not scribble on the user's real ~/.agents/), and skip the update prompt
34
+ // (the "0.0.0-dev -> 1.x.y" message is misleading). Each individual env var
35
+ // can still be set explicitly to override (set to '0' to re-enable).
36
+ const IS_DEV_BUILD = (() => {
37
+ if (VERSION.startsWith('0.0.0-dev'))
38
+ return true;
39
+ try {
40
+ const cliPath = process.argv[1] || '';
41
+ const repoRoot = path.dirname(path.dirname(cliPath));
42
+ return fs.existsSync(path.join(repoRoot, '.git'));
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ })();
48
+ if (IS_DEV_BUILD) {
49
+ if (process.env.AGENTS_NO_AUTOPULL === undefined)
50
+ process.env.AGENTS_NO_AUTOPULL = '1';
51
+ if (process.env.AGENTS_SKIP_MIGRATION === undefined)
52
+ process.env.AGENTS_SKIP_MIGRATION = '1';
53
+ if (process.env.AGENTS_CLI_DISABLE_AUTO_UPDATE === undefined)
54
+ process.env.AGENTS_CLI_DISABLE_AUTO_UPDATE = '1';
55
+ }
26
56
  // Import command registrations
27
57
  import { registerPullCommand } from './commands/pull.js';
28
58
  import { registerRepoCommands } from './commands/repo.js';
package/dist/lib/hooks.js CHANGED
@@ -51,6 +51,18 @@ function isManagedHookCommand(command, prefixes) {
51
51
  return false;
52
52
  }
53
53
  import { getEffectiveHome, getVersionHomePath, listInstalledVersions } from './versions.js';
54
+ /**
55
+ * Extensions that are NEVER hooks — docs, configuration, plain data. A file
56
+ * in hooks/ with one of these extensions is auxiliary content (e.g., the
57
+ * `promptcuts.yaml` data file read directly by the expand-promptcuts
58
+ * script, or the `README.md` that documents the hooks directory). They
59
+ * sometimes carry an exec bit by accident (older sync runs chmod 0o755'd
60
+ * everything) but they are not scripts.
61
+ */
62
+ const NON_SCRIPT_EXTENSIONS = new Set([
63
+ '.md', '.markdown', '.rst', '.txt',
64
+ '.yaml', '.yml', '.json', '.toml', '.ini', '.conf',
65
+ ]);
54
66
  const SCRIPT_EXTENSIONS = new Set([
55
67
  '.sh',
56
68
  '.bash',
@@ -140,9 +152,15 @@ export function listHookEntriesFromDir(dir) {
140
152
  const entries = [];
141
153
  for (const [base, group] of grouped) {
142
154
  group.sort((a, b) => a.name.localeCompare(b.name));
143
- const script = group.find((f) => f.isExec) ||
144
- group.find((f) => SCRIPT_EXTENSIONS.has(f.ext.toLowerCase())) ||
145
- group[0];
155
+ // A group is a hook only if it has an actual script: a script extension,
156
+ // OR an executable bit on a file whose extension is not a known data /
157
+ // docs type. Files like `README.md` (docs) or `promptcuts.yaml` (data
158
+ // the expand-promptcuts hook reads directly) sit alongside hooks but
159
+ // are NOT hooks themselves and must not surface in the hooks list
160
+ // anywhere — doctor, sync, view, or otherwise. Older sync runs may have
161
+ // chmod 0o755'd these files; an exec bit alone is not enough.
162
+ const script = group.find((f) => SCRIPT_EXTENSIONS.has(f.ext.toLowerCase())) ||
163
+ group.find((f) => f.isExec && !NON_SCRIPT_EXTENSIONS.has(f.ext.toLowerCase()));
146
164
  if (!script)
147
165
  continue;
148
166
  const data = group.find((f) => f !== script);
@@ -58,14 +58,14 @@ function deleteSystemPromptsJson() {
58
58
  * The teams persistence layer already reads the legacy path as a fallback;
59
59
  * moving it here keeps the canonical location consistent.
60
60
  */
61
+ // Delete the legacy ~/.agents-system/config.json. This was the teams agent
62
+ // registry, which no longer exists — `agents teams` discovers agents through
63
+ // `listInstalledVersions` and invokes them through `agents run`.
61
64
  function migrateSystemConfigJson() {
62
65
  const src = path.join(SYSTEM_DIR, 'config.json');
63
- const dest = path.join(USER_DIR, 'teams', 'config.json');
64
- if (!fs.existsSync(src) || fs.existsSync(dest))
66
+ if (!fs.existsSync(src))
65
67
  return;
66
68
  try {
67
- fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
68
- fs.copyFileSync(src, dest);
69
69
  fs.unlinkSync(src);
70
70
  }
71
71
  catch { /* best-effort */ }
@@ -375,20 +375,41 @@ function deleteUserPromptsJson() {
375
375
  catch { /* best-effort */ }
376
376
  }
377
377
  /**
378
- * Delete ~/.agents/config.json. The canonical teams config is at
379
- * ~/.agents/teams/config.json (teams/persistence.ts). If the canonical
380
- * file exists we just unlink the legacy copy; otherwise migrate first.
378
+ * Delete ~/.agents/teams/config.json. The teams subsystem no longer carries
379
+ * its own agent registry — agent discovery flows through `listInstalledVersions`
380
+ * (the same source `agents view` uses) and invocation flows through
381
+ * `agents run`. The on-disk file is pure dead state on existing installs.
382
+ */
383
+ function deleteTeamsConfigJson() {
384
+ const f = path.join(USER_DIR, 'teams', 'config.json');
385
+ if (!fs.existsSync(f))
386
+ return;
387
+ try {
388
+ fs.unlinkSync(f);
389
+ }
390
+ catch { /* best-effort */ }
391
+ }
392
+ /**
393
+ * Move ~/.agents/teams/registry.json → ~/.agents/.history/teams/registry.json.
394
+ * The registry is per-machine runtime state (timestamps + absolute worktree
395
+ * paths) and belongs in the durable-runtime bucket, not at the user-root
396
+ * where `agents repo push` would sync it across machines.
397
+ */
398
+ function moveTeamsRegistryToHistory() {
399
+ const src = path.join(USER_DIR, 'teams', 'registry.json');
400
+ const dest = path.join(HISTORY_DIR, 'teams', 'registry.json');
401
+ moveFileOnce(src, dest);
402
+ }
403
+ /**
404
+ * Delete ~/.agents/config.json. This was the legacy teams config location;
405
+ * the teams subsystem no longer carries a config file at all, so the legacy
406
+ * copy is simply removed.
381
407
  */
382
408
  function cleanupUserConfigJson() {
383
409
  const legacy = path.join(USER_DIR, 'config.json');
384
410
  if (!fs.existsSync(legacy))
385
411
  return;
386
- const canonical = path.join(USER_DIR, 'teams', 'config.json');
387
412
  try {
388
- if (!fs.existsSync(canonical)) {
389
- fs.mkdirSync(path.dirname(canonical), { recursive: true, mode: 0o700 });
390
- fs.copyFileSync(legacy, canonical);
391
- }
392
413
  fs.unlinkSync(legacy);
393
414
  }
394
415
  catch { /* best-effort */ }
@@ -1455,6 +1476,8 @@ export async function runMigration() {
1455
1476
  migratePermissionSetsToPresets();
1456
1477
  deleteUserLinearJson();
1457
1478
  deleteUserPromptsJson();
1479
+ deleteTeamsConfigJson();
1480
+ moveTeamsRegistryToHistory();
1458
1481
  cleanupUserConfigJson();
1459
1482
  cleanupEmptyTopLevelRuns();
1460
1483
  foldUserHooksYamlIntoAgentsYaml();
@@ -50,8 +50,10 @@ export interface ConflictInfo {
50
50
  * (two-repo split: system = shipped defaults, user = operational state).
51
51
  * v9 — claude shim exports CLAUDE_CODE_OAUTH_TOKEN from per-version
52
52
  * .oauth_token file on Linux (keychain-less sandbox fallback).
53
+ * v11 — when no default is set or the configured version is not installed,
54
+ * interactively propose the latest already-installed version.
53
55
  */
54
- export declare const SHIM_SCHEMA_VERSION = 10;
56
+ export declare const SHIM_SCHEMA_VERSION = 11;
55
57
  /**
56
58
  * Generate the full bash shim script for the given agent. The returned string
57
59
  * is written to ~/.agents/shims/{cliCommand} and made executable.
package/dist/lib/shims.js CHANGED
@@ -173,8 +173,10 @@ async function promptConflictStrategy(conflictInfos) {
173
173
  * (two-repo split: system = shipped defaults, user = operational state).
174
174
  * v9 — claude shim exports CLAUDE_CODE_OAUTH_TOKEN from per-version
175
175
  * .oauth_token file on Linux (keychain-less sandbox fallback).
176
+ * v11 — when no default is set or the configured version is not installed,
177
+ * interactively propose the latest already-installed version.
176
178
  */
177
- export const SHIM_SCHEMA_VERSION = 10;
179
+ export const SHIM_SCHEMA_VERSION = 11;
178
180
  /** Internal marker string used to embed the schema version in shim scripts. */
179
181
  const SHIM_VERSION_MARKER = 'agents-shim-version:';
180
182
  /**
@@ -281,6 +283,31 @@ find_project_agents_dir() {
281
283
  return 1
282
284
  }
283
285
 
286
+ # Find the latest installed version by numeric component comparison.
287
+ # Handles both semver (2.1.138) and date-based (2026.5.7) version strings.
288
+ find_latest_installed() {
289
+ local versions_dir="$AGENTS_USER_DIR/.history/versions/$AGENT"
290
+ [ -d "$versions_dir" ] || return
291
+ ls "$versions_dir" 2>/dev/null | awk '
292
+ BEGIN { best="" }
293
+ {
294
+ cur = $0
295
+ n = split(cur, a, /[^0-9]+/)
296
+ m = split(best, b, /[^0-9]+/)
297
+ maxn = (n > m) ? n : m
298
+ winner = cur
299
+ for (i=1; i<=maxn; i++) {
300
+ ai = (i<=n) ? a[i]+0 : 0
301
+ bi = (i<=m) ? b[i]+0 : 0
302
+ if (ai > bi) { winner=cur; break }
303
+ if (ai < bi) { winner=best; break }
304
+ }
305
+ best = winner
306
+ }
307
+ END { print best }
308
+ '
309
+ }
310
+
284
311
  # Try project version first, then global default
285
312
  VERSION=$(find_project_version)
286
313
  VERSION_SOURCE="project"
@@ -290,9 +317,32 @@ if [ -z "$VERSION" ]; then
290
317
  fi
291
318
 
292
319
  if [ -z "$VERSION" ]; then
293
- echo "agents: no version of $AGENT configured" >&2
294
- echo "Run: agents add $AGENT@<version>" >&2
295
- exit 1
320
+ LATEST=$(find_latest_installed)
321
+ if [ -n "$LATEST" ]; then
322
+ echo "agents: no default set for $AGENT — found $AGENT@$LATEST installed" >&2
323
+ if [ -t 2 ]; then
324
+ printf " Set as default and continue? [Y/n] " >&2
325
+ read -r _ans </dev/tty
326
+ case "$_ans" in
327
+ ""|y|Y)
328
+ agents use "$AGENT" "$LATEST" >/dev/null 2>&1
329
+ VERSION="$LATEST"
330
+ VERSION_SOURCE="default"
331
+ ;;
332
+ *)
333
+ echo " Run: agents use $AGENT <version>" >&2
334
+ exit 1
335
+ ;;
336
+ esac
337
+ else
338
+ echo " Run: agents use $AGENT <version>" >&2
339
+ exit 1
340
+ fi
341
+ else
342
+ echo "agents: no version of $AGENT configured" >&2
343
+ echo " Run: agents add $AGENT@<version>" >&2
344
+ exit 1
345
+ fi
296
346
  fi
297
347
 
298
348
  VERSION_DIR="$AGENTS_USER_DIR/.history/versions/$AGENT/$VERSION"
@@ -329,9 +379,33 @@ if [ ! -x "$BINARY" ]; then
329
379
  exit 1
330
380
  fi
331
381
  else
332
- echo "agents: $AGENT@$VERSION not installed" >&2
333
- echo "Run: agents add $AGENT@$VERSION" >&2
334
- exit 1
382
+ LATEST=$(find_latest_installed)
383
+ if [ -n "$LATEST" ] && [ "$LATEST" != "$VERSION" ]; then
384
+ echo "agents: $AGENT@$VERSION not installed — found $AGENT@$LATEST installed" >&2
385
+ if [ -t 2 ]; then
386
+ printf " Switch default to $AGENT@$LATEST and continue? [Y/n] " >&2
387
+ read -r _ans </dev/tty
388
+ case "$_ans" in
389
+ ""|y|Y)
390
+ agents use "$AGENT" "$LATEST" >/dev/null 2>&1
391
+ VERSION="$LATEST"
392
+ VERSION_DIR="$AGENTS_USER_DIR/.history/versions/$AGENT/$VERSION"
393
+ BINARY="$VERSION_DIR/node_modules/.bin/$CLI_COMMAND"
394
+ ;;
395
+ *)
396
+ echo " Run: agents add $AGENT@$VERSION" >&2
397
+ exit 1
398
+ ;;
399
+ esac
400
+ else
401
+ echo " Run: agents add $AGENT@$VERSION" >&2
402
+ exit 1
403
+ fi
404
+ else
405
+ echo "agents: $AGENT@$VERSION not installed" >&2
406
+ echo " Run: agents add $AGENT@$VERSION" >&2
407
+ exit 1
408
+ fi
335
409
  fi
336
410
  fi
337
411
 
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Commands staleness — one `.md` file per command, first-wins across
3
+ * project > user > system > extras.
4
+ */
5
+ import type { FileEntry } from '../types.js';
6
+ import type { TypedResourceChecker } from './types.js';
7
+ export declare const commandsChecker: TypedResourceChecker<FileEntry>;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Commands staleness — one `.md` file per command, first-wins across
3
+ * project > user > system > extras.
4
+ */
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import { firstWinsLayers, listAcrossLayers, resolveByName } from '../layers.js';
8
+ import { fingerprintFile, isFileStale } from '../fingerprint.js';
9
+ export const commandsChecker = {
10
+ type: 'commands',
11
+ listNames(cwd) {
12
+ return listAcrossLayers(firstWinsLayers(cwd), 'commands', (name) => name.endsWith('.md')).map((n) => n.replace(/\.md$/, ''));
13
+ },
14
+ build(name, cwd) {
15
+ const resolved = resolveByName(firstWinsLayers(cwd), path.join('commands', `${name}.md`), (p) => fs.existsSync(p));
16
+ if (!resolved)
17
+ return null;
18
+ const fp = fingerprintFile(resolved.path);
19
+ return fp ? { source: fp } : null;
20
+ },
21
+ isFresh(name, stored, cwd) {
22
+ const resolved = resolveByName(firstWinsLayers(cwd), path.join('commands', `${name}.md`), (p) => fs.existsSync(p));
23
+ if (!resolved)
24
+ return false;
25
+ return !isFileStale(stored.source, resolved.path);
26
+ },
27
+ };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Hooks staleness — one executable file per hook. Project layer is EXCLUDED
3
+ * by design: a cloned public repo with `.agents/hooks/foo` must not plant a
4
+ * hook that fires next time the user runs an agent inside it (see
5
+ * `src/lib/versions.ts:1832-1836`). Only user + system + extras count.
6
+ *
7
+ * Auxiliary files (README.md, promptcuts.yaml) live in hooks/ but are not
8
+ * hooks — the executable bit on the source distinguishes them. This matches
9
+ * the filter in `getAvailableResources`.
10
+ */
11
+ import type { FileEntry } from '../types.js';
12
+ import type { TypedResourceChecker } from './types.js';
13
+ export declare const hooksChecker: TypedResourceChecker<FileEntry>;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Hooks staleness — one executable file per hook. Project layer is EXCLUDED
3
+ * by design: a cloned public repo with `.agents/hooks/foo` must not plant a
4
+ * hook that fires next time the user runs an agent inside it (see
5
+ * `src/lib/versions.ts:1832-1836`). Only user + system + extras count.
6
+ *
7
+ * Auxiliary files (README.md, promptcuts.yaml) live in hooks/ but are not
8
+ * hooks — the executable bit on the source distinguishes them. This matches
9
+ * the filter in `getAvailableResources`.
10
+ */
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import { hookLayers, listAcrossLayers, resolveByName } from '../layers.js';
14
+ import { fingerprintFile, isFileStale } from '../fingerprint.js';
15
+ /** Extensions that are NEVER hooks — docs, configuration, plain data. */
16
+ const NON_SCRIPT_EXTENSIONS = new Set([
17
+ '.md', '.markdown', '.rst', '.txt',
18
+ '.yaml', '.yml', '.json', '.toml', '.ini', '.conf',
19
+ ]);
20
+ /** Extensions that explicitly mark a file as a script regardless of exec bit. */
21
+ const SCRIPT_EXTENSIONS = new Set([
22
+ '.sh', '.bash', '.zsh',
23
+ '.py', '.js', '.ts', '.mjs', '.cjs',
24
+ '.rb', '.pl', '.ps1',
25
+ ]);
26
+ function isHookScript(full) {
27
+ try {
28
+ const stat = fs.statSync(full);
29
+ if (!stat.isFile())
30
+ return false;
31
+ const ext = path.extname(full).toLowerCase();
32
+ if (SCRIPT_EXTENSIONS.has(ext))
33
+ return true;
34
+ // Otherwise require exec bit AND a non-data extension. Older sync runs
35
+ // chmod 0o755'd everything including `promptcuts.yaml` / `README.md`,
36
+ // so exec bit alone can't be trusted.
37
+ if ((stat.mode & 0o111) === 0)
38
+ return false;
39
+ return !NON_SCRIPT_EXTENSIONS.has(ext);
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ }
45
+ export const hooksChecker = {
46
+ type: 'hooks',
47
+ listNames(_cwd) {
48
+ return listAcrossLayers(hookLayers(), 'hooks', (_, full) => isHookScript(full));
49
+ },
50
+ build(name, _cwd) {
51
+ const resolved = resolveByName(hookLayers(), path.join('hooks', name), isHookScript);
52
+ if (!resolved)
53
+ return null;
54
+ const fp = fingerprintFile(resolved.path);
55
+ return fp ? { source: fp } : null;
56
+ },
57
+ isFresh(name, stored, _cwd) {
58
+ const resolved = resolveByName(hookLayers(), path.join('hooks', name), isHookScript);
59
+ if (!resolved)
60
+ return false;
61
+ return !isFileStale(stored.source, resolved.path);
62
+ },
63
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * MCP server staleness — one `.yaml`/`.yml` file per server, first-wins
3
+ * across project > user > system > extras. Name is the `name:` field inside
4
+ * the YAML, NOT the filename (per `getAvailableResources`).
5
+ *
6
+ * We delegate name/path discovery to `listMcpServerConfigs(cwd)` which
7
+ * already handles parsing — keeps a single source of truth for "what counts
8
+ * as a discoverable MCP server."
9
+ */
10
+ import type { FileEntry } from '../types.js';
11
+ import type { TypedResourceChecker } from './types.js';
12
+ export declare const mcpChecker: TypedResourceChecker<FileEntry>;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * MCP server staleness — one `.yaml`/`.yml` file per server, first-wins
3
+ * across project > user > system > extras. Name is the `name:` field inside
4
+ * the YAML, NOT the filename (per `getAvailableResources`).
5
+ *
6
+ * We delegate name/path discovery to `listMcpServerConfigs(cwd)` which
7
+ * already handles parsing — keeps a single source of truth for "what counts
8
+ * as a discoverable MCP server."
9
+ */
10
+ import { fingerprintFile, isFileStale } from '../fingerprint.js';
11
+ import { listMcpServerConfigs } from '../../mcp.js';
12
+ function indexByName(cwd) {
13
+ const map = new Map();
14
+ for (const cfg of listMcpServerConfigs(cwd)) {
15
+ if (!map.has(cfg.name))
16
+ map.set(cfg.name, cfg.path);
17
+ }
18
+ return map;
19
+ }
20
+ export const mcpChecker = {
21
+ type: 'mcp',
22
+ listNames(cwd) {
23
+ return Array.from(indexByName(cwd).keys());
24
+ },
25
+ build(name, cwd) {
26
+ const src = indexByName(cwd).get(name);
27
+ if (!src)
28
+ return null;
29
+ const fp = fingerprintFile(src);
30
+ return fp ? { source: fp } : null;
31
+ },
32
+ isFresh(name, stored, cwd) {
33
+ const src = indexByName(cwd).get(name);
34
+ if (!src)
35
+ return false;
36
+ return !isFileStale(stored.source, src);
37
+ },
38
+ };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Permissions staleness — every `groups/*.yaml` across user + system
3
+ * contributes to the merged permission set (project layer not consulted by
4
+ * the current sync writer). First-wins on name collision (user > system).
5
+ *
6
+ * The active preset env value (`AGENTS_PERMISSION_PRESET`) is part of the
7
+ * fingerprint too — preset selection changes which groups get applied to
8
+ * the agent config, so a preset switch without a content change still
9
+ * counts as stale.
10
+ */
11
+ import type { PermEntry } from '../types.js';
12
+ /** Walk user + system permissions/groups/. First-wins user > system on names. */
13
+ export declare function collectPermissionGroupFiles(): Record<string, string>;
14
+ /** Build the permissions section of the manifest. */
15
+ export declare function buildPermissions(): PermEntry;
16
+ /** True when the stored permissions section no longer matches current state. */
17
+ export declare function isPermissionsStale(stored: PermEntry): boolean;