@phnx-labs/agents-cli 1.18.1 → 1.18.3

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 (56) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/commands/doctor.js +19 -5
  3. package/dist/commands/exec.js +9 -4
  4. package/dist/commands/plugins.js +58 -14
  5. package/dist/commands/view.js +16 -7
  6. package/dist/index.js +30 -0
  7. package/dist/lib/hooks.js +21 -3
  8. package/dist/lib/migrate.js +35 -12
  9. package/dist/lib/plugin-marketplace.d.ts +93 -0
  10. package/dist/lib/plugin-marketplace.js +239 -0
  11. package/dist/lib/plugins.d.ts +25 -13
  12. package/dist/lib/plugins.js +350 -566
  13. package/dist/lib/shims.d.ts +3 -1
  14. package/dist/lib/shims.js +81 -7
  15. package/dist/lib/staleness/checkers/commands.d.ts +7 -0
  16. package/dist/lib/staleness/checkers/commands.js +27 -0
  17. package/dist/lib/staleness/checkers/hooks.d.ts +13 -0
  18. package/dist/lib/staleness/checkers/hooks.js +63 -0
  19. package/dist/lib/staleness/checkers/mcp.d.ts +12 -0
  20. package/dist/lib/staleness/checkers/mcp.js +38 -0
  21. package/dist/lib/staleness/checkers/permissions.d.ts +17 -0
  22. package/dist/lib/staleness/checkers/permissions.js +73 -0
  23. package/dist/lib/staleness/checkers/plugins.d.ts +11 -0
  24. package/dist/lib/staleness/checkers/plugins.js +39 -0
  25. package/dist/lib/staleness/checkers/rules.d.ts +19 -0
  26. package/dist/lib/staleness/checkers/rules.js +86 -0
  27. package/dist/lib/staleness/checkers/skills.d.ts +7 -0
  28. package/dist/lib/staleness/checkers/skills.js +34 -0
  29. package/dist/lib/staleness/checkers/subagents.d.ts +12 -0
  30. package/dist/lib/staleness/checkers/subagents.js +39 -0
  31. package/dist/lib/staleness/checkers/types.d.ts +44 -0
  32. package/dist/lib/staleness/checkers/types.js +20 -0
  33. package/dist/lib/staleness/checkers/workflows.d.ts +10 -0
  34. package/dist/lib/staleness/checkers/workflows.js +37 -0
  35. package/dist/lib/staleness/fingerprint.d.ts +38 -0
  36. package/dist/lib/staleness/fingerprint.js +154 -0
  37. package/dist/lib/staleness/index.d.ts +26 -0
  38. package/dist/lib/staleness/index.js +122 -0
  39. package/dist/lib/staleness/layers.d.ts +37 -0
  40. package/dist/lib/staleness/layers.js +100 -0
  41. package/dist/lib/staleness/types.d.ts +56 -0
  42. package/dist/lib/staleness/types.js +6 -0
  43. package/dist/lib/state.d.ts +2 -0
  44. package/dist/lib/state.js +2 -0
  45. package/dist/lib/teams/agents.d.ts +11 -20
  46. package/dist/lib/teams/agents.js +55 -202
  47. package/dist/lib/teams/index.d.ts +3 -2
  48. package/dist/lib/teams/index.js +2 -2
  49. package/dist/lib/teams/persistence.d.ts +0 -38
  50. package/dist/lib/teams/persistence.js +7 -329
  51. package/dist/lib/teams/registry.js +7 -5
  52. package/dist/lib/types.d.ts +6 -0
  53. package/dist/lib/versions.js +34 -12
  54. package/package.json +1 -1
  55. package/dist/lib/sync-manifest.d.ts +0 -81
  56. package/dist/lib/sync-manifest.js +0 -450
@@ -1,84 +1,19 @@
1
1
  /**
2
- * Teams configuration persistence.
2
+ * Teams data-directory resolution.
3
3
  *
4
- * Manages reading, writing, and migrating the teams config file
5
- * (~/.agents/teams/config.json) that stores per-agent-type settings
6
- * (command templates, enabled state, pinned models) and provider configs.
7
- * Also resolves the base data directory for teammate process storage.
4
+ * Resolves the base directory for teammate metadata + the per-team agents
5
+ * dir, with temp-dir fallbacks for unwritable homedirs. The teams subsystem
6
+ * does NOT carry its own agent-registry config `agents teams` discovers
7
+ * agents through the same machinery as `agents view` (installed versions
8
+ * via `listInstalledVersions`) and invokes them through `agents run`.
8
9
  */
9
10
  import * as fs from 'fs/promises';
10
- import * as fsSync from 'fs';
11
11
  import * as path from 'path';
12
- import { homedir, tmpdir } from 'os';
12
+ import { tmpdir } from 'os';
13
13
  import { constants as fsConstants } from 'fs';
14
- import { randomBytes } from 'crypto';
15
- import lockfile from 'proper-lockfile';
16
14
  import { getTeamsDir, getTeamsAgentsDir } from '../state.js';
17
- /**
18
- * Atomic JSON write: writes to a sibling tmp file then renames over the
19
- * target. rename(2) is atomic on POSIX, so a crashed/interrupted write
20
- * leaves the old config intact instead of leaving a half-written file the
21
- * next read will reject.
22
- */
23
- async function atomicWriteJson(p, data) {
24
- await fs.mkdir(path.dirname(p), { recursive: true });
25
- const tmp = `${p}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`;
26
- const body = JSON.stringify(data, null, 2);
27
- await fs.writeFile(tmp, body);
28
- try {
29
- await fs.rename(tmp, p);
30
- }
31
- catch (err) {
32
- await fs.unlink(tmp).catch(() => { });
33
- throw err;
34
- }
35
- }
36
- /**
37
- * Hold an exclusive cross-process lock on the config file while running
38
- * `fn`. proper-lockfile requires the target to exist, so we touch it
39
- * first. Stale locks auto-expire after 10s.
40
- */
41
- async function withConfigLock(p, fn) {
42
- await fs.mkdir(path.dirname(p), { recursive: true });
43
- if (!fsSync.existsSync(p)) {
44
- try {
45
- await fs.writeFile(p, '{}', { flag: 'wx' });
46
- }
47
- catch (err) {
48
- if (err && err.code !== 'EEXIST')
49
- throw err;
50
- }
51
- }
52
- const release = await lockfile.lock(p, {
53
- retries: { retries: 60, minTimeout: 25, maxTimeout: 250, factor: 1.5 },
54
- stale: 10_000,
55
- });
56
- try {
57
- return await fn();
58
- }
59
- finally {
60
- await release();
61
- }
62
- }
63
- // All supported teammate agent types
64
- const ALL_AGENTS = ['claude', 'codex', 'gemini', 'cursor', 'opencode'];
65
- // Teams config + registry live under ~/.agents/teams/ (definitions);
66
- // per-run agent execution dirs live under ~/.agents/.history/teams/agents/.
67
15
  const TEAMS_DIR = getTeamsDir();
68
- // Legacy paths (for migration)
69
- const LEGACY_CONFIG_DIR = path.join(homedir(), '.agents');
70
- // Legacy migration from pre-OSS brand; safe to remove after 2026-07
71
- const LEGACY_BASE_DIR = path.join(homedir(), '.swarmify');
72
16
  const TMP_FALLBACK_DIR = path.join(tmpdir(), 'agents');
73
- async function pathExists(p) {
74
- try {
75
- await fs.access(p);
76
- return true;
77
- }
78
- catch {
79
- return false;
80
- }
81
- }
82
17
  async function ensureWritableDir(p) {
83
18
  try {
84
19
  await fs.mkdir(p, { recursive: true });
@@ -113,18 +48,7 @@ async function resolveAgentsPath() {
113
48
  }
114
49
  throw new Error('Unable to determine a writable agents directory');
115
50
  }
116
- async function resolveConfigPath() {
117
- await fs.mkdir(TEAMS_DIR, { recursive: true });
118
- return path.join(TEAMS_DIR, 'config.json');
119
- }
120
- async function resolveLegacyConfigPath() {
121
- return path.join(LEGACY_CONFIG_DIR, 'config.json');
122
- }
123
- async function resolveLegacySwarmifyConfigPath() {
124
- return path.join(LEGACY_BASE_DIR, 'agents', 'config.json');
125
- }
126
51
  let AGENTS_DIR = null;
127
- let CONFIG_PATH = null;
128
52
  /** Resolve and ensure the agents subdirectory exists under the teams base dir. */
129
53
  export async function resolveAgentsDir() {
130
54
  if (!AGENTS_DIR) {
@@ -133,249 +57,3 @@ export async function resolveAgentsDir() {
133
57
  await fs.mkdir(AGENTS_DIR, { recursive: true });
134
58
  return AGENTS_DIR;
135
59
  }
136
- async function ensureConfigPath() {
137
- if (!CONFIG_PATH) {
138
- CONFIG_PATH = await resolveConfigPath();
139
- }
140
- const dir = path.dirname(CONFIG_PATH);
141
- await fs.mkdir(dir, { recursive: true });
142
- return CONFIG_PATH;
143
- }
144
- // Get default agent configuration. `model` is null by default — the teammate
145
- // launcher omits --model and the agent's CLI picks its own default, which is
146
- // what "drop hardcoded model mapping" means in practice.
147
- function getDefaultAgentConfig(agentType) {
148
- const defaults = {
149
- claude: {
150
- command: 'claude -p \'{prompt}\' --output-format stream-json --json',
151
- enabled: true,
152
- model: null,
153
- provider: 'anthropic'
154
- },
155
- codex: {
156
- command: 'codex exec --sandbox workspace-write \'{prompt}\' --json',
157
- enabled: true,
158
- model: null,
159
- provider: 'openai'
160
- },
161
- gemini: {
162
- command: 'gemini \'{prompt}\' --output-format stream-json',
163
- enabled: true,
164
- model: null,
165
- provider: 'google'
166
- },
167
- cursor: {
168
- command: 'cursor-agent -p --output-format stream-json \'{prompt}\'',
169
- enabled: true,
170
- model: null,
171
- provider: 'custom'
172
- },
173
- opencode: {
174
- command: 'opencode run --format json \'{prompt}\'',
175
- enabled: true,
176
- model: null,
177
- provider: 'custom'
178
- }
179
- };
180
- return defaults[agentType];
181
- }
182
- // Get default provider configuration
183
- function getDefaultProviderConfig() {
184
- return {
185
- anthropic: {
186
- apiEndpoint: 'https://api.anthropic.com'
187
- },
188
- openai: {
189
- apiEndpoint: 'https://api.openai.com/v1'
190
- },
191
- google: {
192
- apiEndpoint: 'https://generativelanguage.googleapis.com/v1'
193
- },
194
- custom: {
195
- apiEndpoint: null
196
- }
197
- };
198
- }
199
- // Get default full configuration
200
- function getDefaultSwarmConfig() {
201
- const agents = {};
202
- for (const agentType of ALL_AGENTS) {
203
- agents[agentType] = getDefaultAgentConfig(agentType);
204
- }
205
- return {
206
- agents,
207
- providers: getDefaultProviderConfig()
208
- };
209
- }
210
- // Try to read a config file as either SwarmConfig or legacy format
211
- async function tryReadLegacyConfig(configPath) {
212
- try {
213
- const data = await fs.readFile(configPath, 'utf-8');
214
- const parsed = JSON.parse(data);
215
- // New format: has agents object with nested configs. We detect it by any
216
- // recognizable per-agent field (model, models, command, enabled) — the old
217
- // 'models' field is still accepted so pre-existing configs load cleanly.
218
- if (parsed.agents && typeof parsed.agents === 'object') {
219
- const firstValue = Object.values(parsed.agents)[0];
220
- if (firstValue && typeof firstValue === 'object') {
221
- const obj = firstValue;
222
- if ('model' in obj || 'models' in obj || 'command' in obj || 'enabled' in obj) {
223
- return parsed;
224
- }
225
- }
226
- }
227
- // Old format: { enabledAgents: string[] }
228
- if (parsed.enabledAgents && Array.isArray(parsed.enabledAgents)) {
229
- const defaultConfig = getDefaultSwarmConfig();
230
- for (const agentType of parsed.enabledAgents) {
231
- if (ALL_AGENTS.includes(agentType)) {
232
- defaultConfig.agents[agentType].enabled = true;
233
- }
234
- }
235
- return defaultConfig;
236
- }
237
- return null;
238
- }
239
- catch {
240
- return null;
241
- }
242
- }
243
- // Migrate from legacy config locations
244
- async function migrateLegacyConfig() {
245
- // Try ~/.agents/config.json first (most recent legacy location)
246
- const legacyConfigPath = await resolveLegacyConfigPath();
247
- let config = await tryReadLegacyConfig(legacyConfigPath);
248
- // Try ~/.swarmify/agents/config.json (legacy pre-OSS brand path)
249
- if (!config) {
250
- const legacyBrandConfigPath = await resolveLegacySwarmifyConfigPath();
251
- config = await tryReadLegacyConfig(legacyBrandConfigPath);
252
- }
253
- if (!config)
254
- return null;
255
- // Write migrated config to new location
256
- const newConfigPath = await ensureConfigPath();
257
- await fs.writeFile(newConfigPath, JSON.stringify(config, null, 2));
258
- console.warn(`[agents] Migrated config to ${newConfigPath}`);
259
- return config;
260
- }
261
- /** Read teams config from disk, migrating legacy formats if needed. Returns defaults when no config exists. */
262
- export async function readConfig() {
263
- const configPath = await ensureConfigPath();
264
- // Try to read new config first
265
- try {
266
- const data = await fs.readFile(configPath, 'utf-8');
267
- const config = JSON.parse(data);
268
- const enabledAgents = [];
269
- const agentConfigs = {};
270
- const providerConfigs = {};
271
- // Parse agent configs
272
- if (config.agents && typeof config.agents === 'object') {
273
- for (const [agentKey, agentValue] of Object.entries(config.agents)) {
274
- if (!ALL_AGENTS.includes(agentKey))
275
- continue;
276
- const agentType = agentKey;
277
- // Merge with defaults for missing fields
278
- const defaultAgentConfig = getDefaultAgentConfig(agentType);
279
- const mergedAgentConfig = {
280
- ...defaultAgentConfig,
281
- ...agentValue
282
- };
283
- if (mergedAgentConfig.enabled) {
284
- enabledAgents.push(agentType);
285
- }
286
- agentConfigs[agentType] = mergedAgentConfig;
287
- }
288
- }
289
- // Fill in missing agents with defaults
290
- for (const agentType of ALL_AGENTS) {
291
- if (!agentConfigs[agentType]) {
292
- agentConfigs[agentType] = getDefaultAgentConfig(agentType);
293
- }
294
- }
295
- // Parse provider configs
296
- if (config.providers && typeof config.providers === 'object') {
297
- for (const [providerKey, providerValue] of Object.entries(config.providers)) {
298
- const providerConfig = providerValue;
299
- providerConfigs[providerKey] = providerConfig;
300
- }
301
- }
302
- // Fill in missing providers with defaults
303
- const defaultProviders = getDefaultProviderConfig();
304
- for (const [providerKey, providerValue] of Object.entries(defaultProviders)) {
305
- if (!providerConfigs[providerKey]) {
306
- providerConfigs[providerKey] = providerValue;
307
- }
308
- }
309
- return { enabledAgents, agentConfigs, providerConfigs, hasConfig: true };
310
- }
311
- catch {
312
- // Config doesn't exist or is invalid, try migration
313
- const migratedConfig = await migrateLegacyConfig();
314
- if (migratedConfig) {
315
- const enabledAgents = [];
316
- const agentConfigs = {};
317
- const providerConfigs = migratedConfig.providers;
318
- for (const [agentKey, agentValue] of Object.entries(migratedConfig.agents)) {
319
- const agentType = agentKey;
320
- agentConfigs[agentType] = agentValue;
321
- if (agentValue.enabled) {
322
- enabledAgents.push(agentType);
323
- }
324
- }
325
- return { enabledAgents, agentConfigs, providerConfigs, hasConfig: true };
326
- }
327
- // No config and no legacy config, return defaults
328
- const defaultConfig = getDefaultSwarmConfig();
329
- const enabledAgents = [];
330
- const agentConfigs = defaultConfig.agents;
331
- const providerConfigs = defaultConfig.providers;
332
- for (const [agentKey, agentValue] of Object.entries(defaultConfig.agents)) {
333
- if (agentValue.enabled) {
334
- enabledAgents.push(agentKey);
335
- }
336
- }
337
- // Write default config to file
338
- await fs.writeFile(configPath, JSON.stringify(defaultConfig, null, 2));
339
- return { enabledAgents, agentConfigs, providerConfigs, hasConfig: false };
340
- }
341
- }
342
- /** Write teams config to disk. */
343
- export async function writeConfig(config) {
344
- const configPath = await ensureConfigPath();
345
- await withConfigLock(configPath, async () => {
346
- await atomicWriteJson(configPath, config);
347
- });
348
- }
349
- /** Update the enabled/disabled status of a specific agent type in the config file. */
350
- export async function setAgentEnabled(agentType, enabled) {
351
- const configPath = await ensureConfigPath();
352
- await withConfigLock(configPath, async () => {
353
- let parsed;
354
- try {
355
- const raw = await fs.readFile(configPath, 'utf-8');
356
- parsed = JSON.parse(raw);
357
- }
358
- catch (err) {
359
- // ENOENT: the lock held briefly above touched a placeholder, but a
360
- // racing writer could still have produced an empty file. Fall back
361
- // to defaults rather than wedge the call.
362
- if (err && err.code === 'ENOENT') {
363
- parsed = getDefaultSwarmConfig();
364
- }
365
- else if (err instanceof SyntaxError) {
366
- throw new Error(`Teams config corrupted at ${configPath}: ${err.message}. Inspect and restore from backup.`);
367
- }
368
- else {
369
- throw err;
370
- }
371
- }
372
- if (!parsed.agents) {
373
- parsed.agents = getDefaultSwarmConfig().agents;
374
- }
375
- if (!parsed.agents[agentType]) {
376
- parsed.agents[agentType] = getDefaultAgentConfig(agentType);
377
- }
378
- parsed.agents[agentType].enabled = enabled;
379
- await atomicWriteJson(configPath, parsed);
380
- });
381
- }
@@ -1,18 +1,20 @@
1
1
  /**
2
2
  * Team registry.
3
3
  *
4
- * Manages the persistent registry of named teams stored in registry.json
5
- * under the teams data directory. Provides CRUD operations for team metadata
6
- * (creation timestamp and optional description).
4
+ * Manages the persistent registry of named teams stored at
5
+ * ~/.agents/.history/teams/registry.json. This is per-machine runtime
6
+ * state (timestamps + worktree paths that include absolute filesystem
7
+ * paths) and intentionally lives under .history/ so it's NOT pulled in
8
+ * by `agents repo push`.
7
9
  */
8
10
  import * as fs from 'fs/promises';
9
11
  import * as fsSync from 'fs';
10
12
  import * as path from 'path';
11
13
  import { randomBytes } from 'crypto';
12
14
  import lockfile from 'proper-lockfile';
13
- import { getTeamsDir } from '../state.js';
15
+ import { getTeamsRegistryPath } from '../state.js';
14
16
  async function registryPath() {
15
- return path.join(getTeamsDir(), 'registry.json');
17
+ return getTeamsRegistryPath();
16
18
  }
17
19
  /**
18
20
  * Atomic JSON write: writes to a unique sibling tmp file then renames over
@@ -346,6 +346,12 @@ export interface DiscoveredPlugin {
346
346
  agentDefs: string[];
347
347
  /** Executable files in the plugin's bin/ directory. */
348
348
  bin: string[];
349
+ /** MCP server names parsed from .mcp.json. */
350
+ mcpServers: string[];
351
+ /** LSP server keys parsed from .lsp.json. */
352
+ lspServers: string[];
353
+ /** Monitor names parsed from monitors/monitors.json. */
354
+ monitors: string[];
349
355
  /** Whether the plugin root contains a .mcp.json file. */
350
356
  hasMcp: boolean;
351
357
  /** Whether the plugin root contains a settings.json with non-permission keys to merge. */
@@ -37,7 +37,7 @@ import { registerHooksToSettings } from './hooks.js';
37
37
  import { supports, explainSkip } from './capabilities.js';
38
38
  import { discoverPlugins, syncPluginToVersion, isPluginSynced, pluginSupportsAgent, cleanOrphanedPluginSkills } from './plugins.js';
39
39
  import { composeRulesFromState } from './rules/compose.js';
40
- import { loadSyncManifest, saveSyncManifest, buildManifest, isSyncStale } from './sync-manifest.js';
40
+ import { loadManifest, saveManifest, buildManifest as buildSyncManifest, isStale } from './staleness/index.js';
41
41
  import { PLUGINS_CAPABLE_AGENTS } from './agents.js';
42
42
  import { emit } from './events.js';
43
43
  import { safeJoin } from './paths.js';
@@ -109,10 +109,14 @@ export function getAvailableResources(cwd = process.cwd()) {
109
109
  }
110
110
  }
111
111
  result.skills = Array.from(skillNames);
112
- // Hooks (files). Only executable files in hooks/ count as hooks. Auxiliary
113
- // files like README.md (docs) or promptcuts.yaml (data read directly by a
114
- // hook script) live alongside hooks but are not hooks themselves and must
115
- // not be synced as such.
112
+ // Hooks (files). A hook is an actual script: known script extension, OR
113
+ // executable bit on a file with a non-data extension. Auxiliary content
114
+ // like `README.md` (docs) or `promptcuts.yaml` (data read directly by the
115
+ // expand-promptcuts script) lives in hooks/ but is not a hook. Older sync
116
+ // runs chmod 0o755'd everything they copied, so an exec bit alone can no
117
+ // longer be trusted as the signal.
118
+ const NON_SCRIPT_EXTS = new Set(['.md', '.markdown', '.rst', '.txt', '.yaml', '.yml', '.json', '.toml', '.ini', '.conf']);
119
+ const SCRIPT_EXTS = new Set(['.sh', '.bash', '.zsh', '.py', '.js', '.ts', '.mjs', '.cjs', '.rb', '.pl', '.ps1']);
116
120
  const hookNames = new Set();
117
121
  for (const { base } of resourceBases) {
118
122
  const hooksDir = path.join(base, 'hooks');
@@ -123,7 +127,14 @@ export function getAvailableResources(cwd = process.cwd()) {
123
127
  continue;
124
128
  try {
125
129
  const stat = fs.statSync(path.join(hooksDir, name));
126
- if (stat.isFile() && (stat.mode & 0o111) !== 0)
130
+ if (!stat.isFile())
131
+ continue;
132
+ const ext = path.extname(name).toLowerCase();
133
+ if (SCRIPT_EXTS.has(ext)) {
134
+ hookNames.add(name);
135
+ continue;
136
+ }
137
+ if ((stat.mode & 0o111) !== 0 && !NON_SCRIPT_EXTS.has(ext))
127
138
  hookNames.add(name);
128
139
  }
129
140
  catch { /* ignore unreadable */ }
@@ -1451,6 +1462,11 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
1451
1462
  const versionHome = getVersionHomePath(agent, version);
1452
1463
  const agentDir = path.join(versionHome, `.${agent}`);
1453
1464
  fs.mkdirSync(agentDir, { recursive: true });
1465
+ // Capture whether the caller passed a selection. The pattern-expansion
1466
+ // path below reassigns `selection`, but for manifest write semantics we
1467
+ // care about the ORIGINAL intent: a caller passing no selection means
1468
+ // "full sync; persist the staleness manifest after."
1469
+ const userPassedSelection = selection !== undefined;
1454
1470
  const result = { commands: false, skills: false, hooks: false, memory: [], permissions: false, mcp: [], subagents: [], plugins: [], workflows: [] };
1455
1471
  const cwd = options.cwd || process.cwd();
1456
1472
  const projectAgentsDir = options.projectDir || getProjectAgentsDir(cwd);
@@ -1528,10 +1544,12 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
1528
1544
  }
1529
1545
  // Fast guard: skip the entire sync when no selection is active and nothing
1530
1546
  // has changed since the last full sync. Drops steady-state cost from ~16s
1531
- // (unconditional file copies) to ~2ms (stat calls + manifest read).
1547
+ // (unconditional file copies) to ~8-15ms (`isStale` walks the manifest's
1548
+ // fingerprints, short-circuiting at the first mismatch). Numbers from
1549
+ // scripts/bench-staleness.ts against a real ~50-resource project.
1532
1550
  if (!selection && !options.force) {
1533
- const manifest = loadSyncManifest(agent, version);
1534
- if (manifest && !isSyncStale(manifest, available, agent, version, cwd)) {
1551
+ const manifest = loadManifest(agent, version);
1552
+ if (manifest && !isStale(manifest, agent, version, cwd)) {
1535
1553
  return { commands: false, skills: false, hooks: false, memory: [], permissions: false, mcp: [], subagents: [], plugins: [], workflows: [] };
1536
1554
  }
1537
1555
  }
@@ -1864,9 +1882,13 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
1864
1882
  }
1865
1883
  // workflow patterns already written via ensureVersionResourcePatterns above.
1866
1884
  }
1867
- // Write manifest after a successful full sync so the next launch can skip this work.
1868
- if (!selection) {
1869
- saveSyncManifest(agent, version, buildManifest(agent, version, available, cwd));
1885
+ // Write manifest after a full sync (no user-passed selection) so the next
1886
+ // launch can skip the slow path. Pattern-derived selections still count as
1887
+ // "full" the agents.yaml patterns describe the intended scope, not a
1888
+ // one-off override, so the resulting state matches what the manifest
1889
+ // records as the synced set.
1890
+ if (!userPassedSelection) {
1891
+ saveManifest(agent, version, buildSyncManifest(agent, version, cwd));
1870
1892
  }
1871
1893
  return result;
1872
1894
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.18.1",
3
+ "version": "1.18.3",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,81 +0,0 @@
1
- /**
2
- * Sync manifest — fast staleness guard for syncResourcesToVersion.
3
- *
4
- * Written after each full sync; read before the next to skip unconditional
5
- * re-copies when nothing has changed. Two-tier check:
6
- * Tier 1 — stat mtime+size ~0.01ms/file (kernel VFS cache)
7
- * Tier 2 — sha256 on miss ~0.1–0.5ms/file
8
- *
9
- * Env vars in MCP YAML are NOT substituted by agents-cli — ${VAR} is stored
10
- * verbatim and passed as-is to `claude mcp add`. Value-only env var changes
11
- * (YAML content unchanged) are not detected and are not in scope.
12
- *
13
- * Layering model:
14
- * First-wins (commands, skills, hooks, MCP, rules): manifest stores fingerprint
15
- * of the WINNING source (project > user > system > extra). A scope change
16
- * (e.g. project file added/removed) is detected via resolved-path mismatch.
17
- *
18
- * Merged (permissions): all group files across all scopes contribute. Any
19
- * change in any scope file triggers re-sync.
20
- *
21
- * The manifest is written only after a full (unselected) sync and is only used
22
- * as a guard for full syncs — partial/interactive syncs bypass it entirely.
23
- */
24
- import type { AgentId } from './types.js';
25
- import type { AvailableResources } from './versions.js';
26
- declare const MANIFEST_VERSION: 1;
27
- /** Fingerprint of a single source file. */
28
- export interface Fingerprint {
29
- path: string;
30
- mtime: number;
31
- size: number;
32
- sha256: string;
33
- }
34
- /** A single-file resource (command, hook, MCP server YAML). */
35
- interface FileEntry {
36
- source: Fingerprint;
37
- }
38
- /** A directory resource (skill). */
39
- interface DirEntry {
40
- dirPath: string;
41
- files: Fingerprint[];
42
- }
43
- /** Rules/memory: per-file first-wins fingerprints. */
44
- interface RulesEntry {
45
- files: Record<string, FileEntry>;
46
- }
47
- /** Permissions: all group files across all scopes (merged). */
48
- interface PermEntry {
49
- groups: Record<string, FileEntry>;
50
- permissionPreset: string | null;
51
- }
52
- export interface SyncManifest {
53
- v: typeof MANIFEST_VERSION;
54
- syncedAt: string;
55
- commands: Record<string, FileEntry>;
56
- skills: Record<string, DirEntry>;
57
- hooks: Record<string, FileEntry>;
58
- rules: RulesEntry;
59
- mcp: Record<string, FileEntry>;
60
- permissions: PermEntry;
61
- subagents: Record<string, DirEntry>;
62
- }
63
- /** Load the manifest for a given agent+version. Returns null on miss or parse error. */
64
- export declare function loadSyncManifest(agent: AgentId, version: string): SyncManifest | null;
65
- /** Write the manifest atomically (tmp + rename). */
66
- export declare function saveSyncManifest(agent: AgentId, version: string, manifest: SyncManifest): void;
67
- /**
68
- * Build a manifest by fingerprinting current source state.
69
- * Call this after a successful full sync.
70
- */
71
- export declare function buildManifest(agent: AgentId, version: string, available: AvailableResources, cwd: string): SyncManifest;
72
- /**
73
- * Check if sources have changed since the manifest was written.
74
- * Returns true (stale) at the first detected mismatch — no need to scan everything.
75
- * Returns false (clean) only after all checks pass.
76
- *
77
- * For rules, also delegates to isRulesStale() to catch @-import changes
78
- * for agents that pre-compile their rules file.
79
- */
80
- export declare function isSyncStale(manifest: SyncManifest, available: AvailableResources, agent: AgentId, version: string, cwd: string): boolean;
81
- export {};