@phnx-labs/agents-cli 1.18.1 → 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.
- package/CHANGELOG.md +12 -0
- package/dist/commands/doctor.js +19 -5
- package/dist/commands/exec.js +9 -4
- package/dist/index.js +30 -0
- package/dist/lib/hooks.js +21 -3
- package/dist/lib/migrate.js +35 -12
- package/dist/lib/shims.d.ts +3 -1
- package/dist/lib/shims.js +81 -7
- package/dist/lib/staleness/checkers/commands.d.ts +7 -0
- package/dist/lib/staleness/checkers/commands.js +27 -0
- package/dist/lib/staleness/checkers/hooks.d.ts +13 -0
- package/dist/lib/staleness/checkers/hooks.js +63 -0
- package/dist/lib/staleness/checkers/mcp.d.ts +12 -0
- package/dist/lib/staleness/checkers/mcp.js +38 -0
- package/dist/lib/staleness/checkers/permissions.d.ts +17 -0
- package/dist/lib/staleness/checkers/permissions.js +73 -0
- package/dist/lib/staleness/checkers/plugins.d.ts +11 -0
- package/dist/lib/staleness/checkers/plugins.js +39 -0
- package/dist/lib/staleness/checkers/rules.d.ts +19 -0
- package/dist/lib/staleness/checkers/rules.js +86 -0
- package/dist/lib/staleness/checkers/skills.d.ts +7 -0
- package/dist/lib/staleness/checkers/skills.js +34 -0
- package/dist/lib/staleness/checkers/subagents.d.ts +12 -0
- package/dist/lib/staleness/checkers/subagents.js +39 -0
- package/dist/lib/staleness/checkers/types.d.ts +44 -0
- package/dist/lib/staleness/checkers/types.js +20 -0
- package/dist/lib/staleness/checkers/workflows.d.ts +10 -0
- package/dist/lib/staleness/checkers/workflows.js +37 -0
- package/dist/lib/staleness/fingerprint.d.ts +38 -0
- package/dist/lib/staleness/fingerprint.js +154 -0
- package/dist/lib/staleness/index.d.ts +26 -0
- package/dist/lib/staleness/index.js +122 -0
- package/dist/lib/staleness/layers.d.ts +37 -0
- package/dist/lib/staleness/layers.js +100 -0
- package/dist/lib/staleness/types.d.ts +56 -0
- package/dist/lib/staleness/types.js +6 -0
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +2 -0
- package/dist/lib/teams/agents.d.ts +11 -20
- package/dist/lib/teams/agents.js +55 -202
- package/dist/lib/teams/index.d.ts +3 -2
- package/dist/lib/teams/index.js +2 -2
- package/dist/lib/teams/persistence.d.ts +0 -38
- package/dist/lib/teams/persistence.js +7 -329
- package/dist/lib/teams/registry.js +7 -5
- package/dist/lib/versions.js +34 -12
- package/package.json +1 -1
- package/dist/lib/sync-manifest.d.ts +0 -81
- package/dist/lib/sync-manifest.js +0 -450
|
@@ -1,84 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Teams
|
|
2
|
+
* Teams data-directory resolution.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
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
|
|
5
|
-
*
|
|
6
|
-
* (
|
|
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 {
|
|
15
|
+
import { getTeamsRegistryPath } from '../state.js';
|
|
14
16
|
async function registryPath() {
|
|
15
|
-
return
|
|
17
|
+
return getTeamsRegistryPath();
|
|
16
18
|
}
|
|
17
19
|
/**
|
|
18
20
|
* Atomic JSON write: writes to a unique sibling tmp file then renames over
|
package/dist/lib/versions.js
CHANGED
|
@@ -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 {
|
|
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).
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
// not
|
|
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()
|
|
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 ~
|
|
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 =
|
|
1534
|
-
if (manifest && !
|
|
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
|
|
1868
|
-
|
|
1869
|
-
|
|
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,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 {};
|