@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.
- package/CHANGELOG.md +22 -0
- package/dist/commands/doctor.js +19 -5
- package/dist/commands/exec.js +9 -4
- package/dist/commands/plugins.js +58 -14
- package/dist/commands/view.js +16 -7
- package/dist/index.js +30 -0
- package/dist/lib/hooks.js +21 -3
- package/dist/lib/migrate.js +35 -12
- package/dist/lib/plugin-marketplace.d.ts +93 -0
- package/dist/lib/plugin-marketplace.js +239 -0
- package/dist/lib/plugins.d.ts +25 -13
- package/dist/lib/plugins.js +350 -566
- 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/types.d.ts +6 -0
- 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
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract every resource staleness checker implements. Each checker owns
|
|
3
|
+
* the layer-resolution and entry-shape rules for one resource type. The
|
|
4
|
+
* aggregator in `../index.ts` doesn't know any of those details — it just
|
|
5
|
+
* calls these three methods.
|
|
6
|
+
*
|
|
7
|
+
* Why three methods (not just "isStale"):
|
|
8
|
+
* - `listNames` feeds both the manifest writer (what to record) and the
|
|
9
|
+
* name-set diff (`stored` vs. `current` reveals adds/removes).
|
|
10
|
+
* - `build` produces the entry for a single name; called per name
|
|
11
|
+
* after listing. Pure — no comparison logic.
|
|
12
|
+
* - `isFresh` checks one stored entry against current state; called
|
|
13
|
+
* when the name set already matches and we need content-level certainty.
|
|
14
|
+
*
|
|
15
|
+
* The `unknown` entry type is intentional — each checker round-trips its
|
|
16
|
+
* own concrete shape through JSON (commands/hooks/mcp use FileEntry; skills
|
|
17
|
+
* /subagents/workflows/plugins use DirEntry; rules/permissions use their
|
|
18
|
+
* own composite entries).
|
|
19
|
+
*/
|
|
20
|
+
export interface ResourceChecker {
|
|
21
|
+
/** Stable identifier; matches the manifest field name. */
|
|
22
|
+
readonly type: string;
|
|
23
|
+
/** Names of every resource currently available across this checker's layers. */
|
|
24
|
+
listNames(cwd: string): string[];
|
|
25
|
+
/**
|
|
26
|
+
* Build a manifest entry for one name. Returns null when no source file is
|
|
27
|
+
* found — the aggregator drops nulls so name-set diff stays accurate.
|
|
28
|
+
*/
|
|
29
|
+
build(name: string, cwd: string): unknown | null;
|
|
30
|
+
/**
|
|
31
|
+
* Check whether a stored entry still reflects current source. Called only
|
|
32
|
+
* after the name-set already matches. Returns true when fresh, false when
|
|
33
|
+
* the entry should trigger a re-sync.
|
|
34
|
+
*/
|
|
35
|
+
isFresh(name: string, stored: unknown, cwd: string): boolean;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Helper for checkers whose entry shape varies. Strict-typed convenience
|
|
39
|
+
* wrapper that callers can use to avoid `unknown` casts in their own code.
|
|
40
|
+
*/
|
|
41
|
+
export interface TypedResourceChecker<TEntry> extends ResourceChecker {
|
|
42
|
+
build(name: string, cwd: string): TEntry | null;
|
|
43
|
+
isFresh(name: string, stored: TEntry, cwd: string): boolean;
|
|
44
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract every resource staleness checker implements. Each checker owns
|
|
3
|
+
* the layer-resolution and entry-shape rules for one resource type. The
|
|
4
|
+
* aggregator in `../index.ts` doesn't know any of those details — it just
|
|
5
|
+
* calls these three methods.
|
|
6
|
+
*
|
|
7
|
+
* Why three methods (not just "isStale"):
|
|
8
|
+
* - `listNames` feeds both the manifest writer (what to record) and the
|
|
9
|
+
* name-set diff (`stored` vs. `current` reveals adds/removes).
|
|
10
|
+
* - `build` produces the entry for a single name; called per name
|
|
11
|
+
* after listing. Pure — no comparison logic.
|
|
12
|
+
* - `isFresh` checks one stored entry against current state; called
|
|
13
|
+
* when the name set already matches and we need content-level certainty.
|
|
14
|
+
*
|
|
15
|
+
* The `unknown` entry type is intentional — each checker round-trips its
|
|
16
|
+
* own concrete shape through JSON (commands/hooks/mcp use FileEntry; skills
|
|
17
|
+
* /subagents/workflows/plugins use DirEntry; rules/permissions use their
|
|
18
|
+
* own composite entries).
|
|
19
|
+
*/
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflows staleness — one directory per workflow (must contain
|
|
3
|
+
* WORKFLOW.md), first-wins across project > user > system > extras.
|
|
4
|
+
*
|
|
5
|
+
* Not tracked in v1 manifests; treated as a new section that's empty on old
|
|
6
|
+
* files, which causes one re-sync (filling the field) and then steady-state.
|
|
7
|
+
*/
|
|
8
|
+
import type { DirEntry } from '../types.js';
|
|
9
|
+
import type { TypedResourceChecker } from './types.js';
|
|
10
|
+
export declare const workflowsChecker: TypedResourceChecker<DirEntry>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflows staleness — one directory per workflow (must contain
|
|
3
|
+
* WORKFLOW.md), first-wins across project > user > system > extras.
|
|
4
|
+
*
|
|
5
|
+
* Not tracked in v1 manifests; treated as a new section that's empty on old
|
|
6
|
+
* files, which causes one re-sync (filling the field) and then steady-state.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import { firstWinsLayers, listAcrossLayers, resolveByName } from '../layers.js';
|
|
11
|
+
import { fingerprintDir, isDirStale } from '../fingerprint.js';
|
|
12
|
+
function isWorkflowDir(full) {
|
|
13
|
+
try {
|
|
14
|
+
return fs.statSync(full).isDirectory() && fs.existsSync(path.join(full, 'WORKFLOW.md'));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export const workflowsChecker = {
|
|
21
|
+
type: 'workflows',
|
|
22
|
+
listNames(cwd) {
|
|
23
|
+
return listAcrossLayers(firstWinsLayers(cwd), 'workflows', (_, full) => isWorkflowDir(full));
|
|
24
|
+
},
|
|
25
|
+
build(name, cwd) {
|
|
26
|
+
const resolved = resolveByName(firstWinsLayers(cwd), path.join('workflows', name), isWorkflowDir);
|
|
27
|
+
if (!resolved)
|
|
28
|
+
return null;
|
|
29
|
+
return { dirPath: resolved.path, files: fingerprintDir(resolved.path) };
|
|
30
|
+
},
|
|
31
|
+
isFresh(name, stored, cwd) {
|
|
32
|
+
const resolved = resolveByName(firstWinsLayers(cwd), path.join('workflows', name), isWorkflowDir);
|
|
33
|
+
if (!resolved)
|
|
34
|
+
return false;
|
|
35
|
+
return !isDirStale(stored.dirPath, stored.files, resolved.path);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File and directory fingerprinting primitives shared by every resource
|
|
3
|
+
* checker. Two-tier comparison: stat (mtime+size) first for the hot path,
|
|
4
|
+
* sha256 only on miss.
|
|
5
|
+
*/
|
|
6
|
+
/** Fingerprint of a single source file. */
|
|
7
|
+
export interface Fingerprint {
|
|
8
|
+
path: string;
|
|
9
|
+
mtime: number;
|
|
10
|
+
size: number;
|
|
11
|
+
sha256: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function sha256(content: string): string;
|
|
14
|
+
/** Fingerprint a single file. Returns null when the file is unreadable. */
|
|
15
|
+
export declare function fingerprintFile(filePath: string): Fingerprint | null;
|
|
16
|
+
/**
|
|
17
|
+
* Fingerprint all files in a directory recursively. Returned sorted by
|
|
18
|
+
* absolute path so ordering is deterministic regardless of readdir order.
|
|
19
|
+
* Noise entries (see `FINGERPRINT_SKIP`) are excluded.
|
|
20
|
+
*/
|
|
21
|
+
export declare function fingerprintDir(dirPath: string): Fingerprint[];
|
|
22
|
+
/** Hot-path file staleness: stat-only when mtime+size match, sha256 on miss. */
|
|
23
|
+
export declare function isFileStale(stored: Fingerprint, currentPath: string): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Hot-path directory staleness. Compares sorted paths first (catches add /
|
|
26
|
+
* remove / rename), then stat each file (skips reads when mtime+size match),
|
|
27
|
+
* sha256 only on stat mismatch.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isDirStale(storedDirPath: string, storedFiles: Fingerprint[], currentDirPath: string): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Walk a directory and return sorted absolute paths of every regular file.
|
|
32
|
+
* No content reads. Uses the same FINGERPRINT_SKIP allowlist as
|
|
33
|
+
* `fingerprintDir` so both produce the same path set (required for the
|
|
34
|
+
* dir-stale path comparison to work).
|
|
35
|
+
*/
|
|
36
|
+
export declare function walkDirPaths(dirPath: string): string[];
|
|
37
|
+
/** True if two sorted-or-unsorted name sets differ. */
|
|
38
|
+
export declare function nameSetDiffers(a: string[], b: string[]): boolean;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File and directory fingerprinting primitives shared by every resource
|
|
3
|
+
* checker. Two-tier comparison: stat (mtime+size) first for the hot path,
|
|
4
|
+
* sha256 only on miss.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import * as crypto from 'crypto';
|
|
9
|
+
export function sha256(content) {
|
|
10
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
11
|
+
}
|
|
12
|
+
/** Fingerprint a single file. Returns null when the file is unreadable. */
|
|
13
|
+
export function fingerprintFile(filePath) {
|
|
14
|
+
try {
|
|
15
|
+
const stat = fs.statSync(filePath);
|
|
16
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
17
|
+
return { path: filePath, mtime: stat.mtimeMs, size: stat.size, sha256: sha256(content) };
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Names we never fingerprint: OS metadata, VCS bookkeeping, dep caches,
|
|
25
|
+
* build outputs. Matches the SKILL_COPY_IGNORE set used by the sync writer
|
|
26
|
+
* in `src/lib/versions.ts`.
|
|
27
|
+
*
|
|
28
|
+
* Important: this is an allowlist of noise, NOT a blanket "skip every
|
|
29
|
+
* dot-prefixed entry". Plugins keep their manifest at
|
|
30
|
+
* `.claude-plugin/plugin.json` — a dot-prefix skip would make plugin
|
|
31
|
+
* manifests invisible to the fingerprint and silently break staleness
|
|
32
|
+
* detection for plugins.
|
|
33
|
+
*/
|
|
34
|
+
const FINGERPRINT_SKIP = new Set([
|
|
35
|
+
'.DS_Store',
|
|
36
|
+
'.git',
|
|
37
|
+
'.gitignore',
|
|
38
|
+
'.venv',
|
|
39
|
+
'__pycache__',
|
|
40
|
+
'node_modules',
|
|
41
|
+
]);
|
|
42
|
+
/**
|
|
43
|
+
* Fingerprint all files in a directory recursively. Returned sorted by
|
|
44
|
+
* absolute path so ordering is deterministic regardless of readdir order.
|
|
45
|
+
* Noise entries (see `FINGERPRINT_SKIP`) are excluded.
|
|
46
|
+
*/
|
|
47
|
+
export function fingerprintDir(dirPath) {
|
|
48
|
+
const results = [];
|
|
49
|
+
function walk(dir) {
|
|
50
|
+
let entries;
|
|
51
|
+
try {
|
|
52
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (FINGERPRINT_SKIP.has(entry.name))
|
|
59
|
+
continue;
|
|
60
|
+
const full = path.join(dir, entry.name);
|
|
61
|
+
if (entry.isDirectory())
|
|
62
|
+
walk(full);
|
|
63
|
+
else if (entry.isFile()) {
|
|
64
|
+
const fp = fingerprintFile(full);
|
|
65
|
+
if (fp)
|
|
66
|
+
results.push(fp);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
walk(dirPath);
|
|
71
|
+
results.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
72
|
+
return results;
|
|
73
|
+
}
|
|
74
|
+
/** Hot-path file staleness: stat-only when mtime+size match, sha256 on miss. */
|
|
75
|
+
export function isFileStale(stored, currentPath) {
|
|
76
|
+
if (stored.path !== currentPath)
|
|
77
|
+
return true;
|
|
78
|
+
try {
|
|
79
|
+
const stat = fs.statSync(currentPath);
|
|
80
|
+
if (stat.mtimeMs === stored.mtime && stat.size === stored.size)
|
|
81
|
+
return false;
|
|
82
|
+
return sha256(fs.readFileSync(currentPath, 'utf-8')) !== stored.sha256;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Hot-path directory staleness. Compares sorted paths first (catches add /
|
|
90
|
+
* remove / rename), then stat each file (skips reads when mtime+size match),
|
|
91
|
+
* sha256 only on stat mismatch.
|
|
92
|
+
*/
|
|
93
|
+
export function isDirStale(storedDirPath, storedFiles, currentDirPath) {
|
|
94
|
+
if (storedDirPath !== currentDirPath)
|
|
95
|
+
return true;
|
|
96
|
+
const currentPaths = walkDirPaths(currentDirPath);
|
|
97
|
+
if (currentPaths.length !== storedFiles.length)
|
|
98
|
+
return true;
|
|
99
|
+
for (let i = 0; i < currentPaths.length; i++) {
|
|
100
|
+
const stored = storedFiles[i];
|
|
101
|
+
const cur = currentPaths[i];
|
|
102
|
+
if (stored.path !== cur)
|
|
103
|
+
return true;
|
|
104
|
+
try {
|
|
105
|
+
const stat = fs.statSync(cur);
|
|
106
|
+
if (stat.mtimeMs === stored.mtime && stat.size === stored.size)
|
|
107
|
+
continue;
|
|
108
|
+
if (sha256(fs.readFileSync(cur, 'utf-8')) !== stored.sha256)
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Walk a directory and return sorted absolute paths of every regular file.
|
|
119
|
+
* No content reads. Uses the same FINGERPRINT_SKIP allowlist as
|
|
120
|
+
* `fingerprintDir` so both produce the same path set (required for the
|
|
121
|
+
* dir-stale path comparison to work).
|
|
122
|
+
*/
|
|
123
|
+
export function walkDirPaths(dirPath) {
|
|
124
|
+
const results = [];
|
|
125
|
+
function walk(dir) {
|
|
126
|
+
let entries;
|
|
127
|
+
try {
|
|
128
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
if (FINGERPRINT_SKIP.has(entry.name))
|
|
135
|
+
continue;
|
|
136
|
+
const full = path.join(dir, entry.name);
|
|
137
|
+
if (entry.isDirectory())
|
|
138
|
+
walk(full);
|
|
139
|
+
else if (entry.isFile())
|
|
140
|
+
results.push(full);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
walk(dirPath);
|
|
144
|
+
results.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
145
|
+
return results;
|
|
146
|
+
}
|
|
147
|
+
/** True if two sorted-or-unsorted name sets differ. */
|
|
148
|
+
export function nameSetDiffers(a, b) {
|
|
149
|
+
if (a.length !== b.length)
|
|
150
|
+
return true;
|
|
151
|
+
const sortedA = [...a].sort();
|
|
152
|
+
const sortedB = [...b].sort();
|
|
153
|
+
return sortedA.some((n, i) => n !== sortedB[i]);
|
|
154
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staleness library entrypoint. Aggregates per-resource checkers into the
|
|
3
|
+
* two operations the rest of the codebase needs:
|
|
4
|
+
*
|
|
5
|
+
* - `buildManifest(agent, version, cwd)` — snapshot current state.
|
|
6
|
+
* - `isStale(manifest, agent, version, cwd)` — true when any tracked
|
|
7
|
+
* resource has drifted from its stored fingerprint.
|
|
8
|
+
*
|
|
9
|
+
* `loadManifest` / `saveManifest` round-trip the on-disk JSON. The format
|
|
10
|
+
* version stays at 1; new optional fields (workflows, plugins) on old files
|
|
11
|
+
* read as empty maps which forces a single re-sync.
|
|
12
|
+
*/
|
|
13
|
+
import type { AgentId } from '../types.js';
|
|
14
|
+
import { type SyncManifest, type FileEntry, type DirEntry, type PluginEntry, type RulesEntry } from './types.js';
|
|
15
|
+
export type { SyncManifest } from './types.js';
|
|
16
|
+
export { MANIFEST_VERSION } from './types.js';
|
|
17
|
+
export declare function loadManifest(agent: AgentId, version: string): SyncManifest | null;
|
|
18
|
+
export declare function saveManifest(agent: AgentId, version: string, manifest: SyncManifest): void;
|
|
19
|
+
export declare function buildManifest(agent: AgentId, version: string, cwd: string): SyncManifest;
|
|
20
|
+
/**
|
|
21
|
+
* True when any tracked resource has drifted from the stored manifest.
|
|
22
|
+
* Walks every resource type in turn and returns true at the first miss —
|
|
23
|
+
* sync detection should be cheap when nothing changed.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isStale(manifest: SyncManifest, agent: AgentId, version: string, cwd: string): boolean;
|
|
26
|
+
export type { FileEntry, DirEntry, PluginEntry, RulesEntry };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staleness library entrypoint. Aggregates per-resource checkers into the
|
|
3
|
+
* two operations the rest of the codebase needs:
|
|
4
|
+
*
|
|
5
|
+
* - `buildManifest(agent, version, cwd)` — snapshot current state.
|
|
6
|
+
* - `isStale(manifest, agent, version, cwd)` — true when any tracked
|
|
7
|
+
* resource has drifted from its stored fingerprint.
|
|
8
|
+
*
|
|
9
|
+
* `loadManifest` / `saveManifest` round-trip the on-disk JSON. The format
|
|
10
|
+
* version stays at 1; new optional fields (workflows, plugins) on old files
|
|
11
|
+
* read as empty maps which forces a single re-sync.
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from 'fs';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
import { getVersionsDir } from '../state.js';
|
|
16
|
+
import { commandsChecker } from './checkers/commands.js';
|
|
17
|
+
import { skillsChecker } from './checkers/skills.js';
|
|
18
|
+
import { hooksChecker } from './checkers/hooks.js';
|
|
19
|
+
import { mcpChecker } from './checkers/mcp.js';
|
|
20
|
+
import { subagentsChecker } from './checkers/subagents.js';
|
|
21
|
+
import { workflowsChecker } from './checkers/workflows.js';
|
|
22
|
+
import { pluginsChecker } from './checkers/plugins.js';
|
|
23
|
+
import { buildPermissions, isPermissionsStale } from './checkers/permissions.js';
|
|
24
|
+
import { buildRules, isRulesStale } from './checkers/rules.js';
|
|
25
|
+
import { MANIFEST_VERSION, } from './types.js';
|
|
26
|
+
import { nameSetDiffers } from './fingerprint.js';
|
|
27
|
+
export { MANIFEST_VERSION } from './types.js';
|
|
28
|
+
/**
|
|
29
|
+
* Standard checkers — uniform contract. Rules and permissions have extra
|
|
30
|
+
* context (agent/version, preset env) so they're wired explicitly below.
|
|
31
|
+
*/
|
|
32
|
+
const STANDARD_CHECKERS = [
|
|
33
|
+
{ checker: commandsChecker, field: 'commands' },
|
|
34
|
+
{ checker: skillsChecker, field: 'skills' },
|
|
35
|
+
{ checker: hooksChecker, field: 'hooks' },
|
|
36
|
+
{ checker: mcpChecker, field: 'mcp' },
|
|
37
|
+
{ checker: subagentsChecker, field: 'subagents' },
|
|
38
|
+
{ checker: workflowsChecker, field: 'workflows' },
|
|
39
|
+
{ checker: pluginsChecker, field: 'plugins' },
|
|
40
|
+
];
|
|
41
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
42
|
+
function manifestPath(agent, version) {
|
|
43
|
+
return path.join(getVersionsDir(), agent, version, 'home', '.sync-manifest.json');
|
|
44
|
+
}
|
|
45
|
+
export function loadManifest(agent, version) {
|
|
46
|
+
const p = manifestPath(agent, version);
|
|
47
|
+
try {
|
|
48
|
+
const raw = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
49
|
+
if (raw.v !== MANIFEST_VERSION)
|
|
50
|
+
return null;
|
|
51
|
+
return raw;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function saveManifest(agent, version, manifest) {
|
|
58
|
+
const p = manifestPath(agent, version);
|
|
59
|
+
const tmp = p + '.tmp';
|
|
60
|
+
try {
|
|
61
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
62
|
+
fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2));
|
|
63
|
+
fs.renameSync(tmp, p);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
try {
|
|
67
|
+
fs.unlinkSync(tmp);
|
|
68
|
+
}
|
|
69
|
+
catch { /* ignore */ }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export function buildManifest(agent, version, cwd) {
|
|
73
|
+
const manifest = {
|
|
74
|
+
v: MANIFEST_VERSION,
|
|
75
|
+
syncedAt: new Date().toISOString(),
|
|
76
|
+
commands: {},
|
|
77
|
+
skills: {},
|
|
78
|
+
hooks: {},
|
|
79
|
+
rules: { files: {} },
|
|
80
|
+
mcp: {},
|
|
81
|
+
permissions: { groups: {}, permissionPreset: null },
|
|
82
|
+
subagents: {},
|
|
83
|
+
workflows: {},
|
|
84
|
+
plugins: {},
|
|
85
|
+
};
|
|
86
|
+
for (const { checker, field } of STANDARD_CHECKERS) {
|
|
87
|
+
const target = manifest[field];
|
|
88
|
+
for (const name of checker.listNames(cwd)) {
|
|
89
|
+
const entry = checker.build(name, cwd);
|
|
90
|
+
if (entry !== null)
|
|
91
|
+
target[name] = entry;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
manifest.rules = buildRules(agent, version, cwd);
|
|
95
|
+
manifest.permissions = buildPermissions();
|
|
96
|
+
return manifest;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* True when any tracked resource has drifted from the stored manifest.
|
|
100
|
+
* Walks every resource type in turn and returns true at the first miss —
|
|
101
|
+
* sync detection should be cheap when nothing changed.
|
|
102
|
+
*/
|
|
103
|
+
export function isStale(manifest, agent, version, cwd) {
|
|
104
|
+
for (const { checker, field } of STANDARD_CHECKERS) {
|
|
105
|
+
const storedMap = (manifest[field] ?? {});
|
|
106
|
+
const currentNames = checker.listNames(cwd);
|
|
107
|
+
if (nameSetDiffers(Object.keys(storedMap), currentNames))
|
|
108
|
+
return true;
|
|
109
|
+
for (const name of currentNames) {
|
|
110
|
+
const entry = storedMap[name];
|
|
111
|
+
if (entry === undefined)
|
|
112
|
+
return true;
|
|
113
|
+
if (!checker.isFresh(name, entry, cwd))
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (isPermissionsStale(manifest.permissions))
|
|
118
|
+
return true;
|
|
119
|
+
if (isRulesStale(manifest.rules, agent, version, cwd))
|
|
120
|
+
return true;
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer resolution for resources. Encapsulates which DotAgents repos a given
|
|
3
|
+
* resource type reads from and in what precedence. Centralized so every
|
|
4
|
+
* checker, plus the sync writer, agrees on the same set.
|
|
5
|
+
*
|
|
6
|
+
* Resolution model:
|
|
7
|
+
* - "first-wins" (commands, skills, mcp, subagents, workflows, plugins):
|
|
8
|
+
* project > user > system > extras. Same-named entries shadow.
|
|
9
|
+
* - "first-wins, no project" (hooks): security exclusion — see
|
|
10
|
+
* `src/lib/versions.ts:1832-1836` for the rationale. User > system > extras.
|
|
11
|
+
* - "merged" (permissions): every layer contributes; first-wins on name.
|
|
12
|
+
* - "composed" (rules): preset + subrules resolved per-name across layers.
|
|
13
|
+
*/
|
|
14
|
+
/** Layer of provenance for a single resource entry. */
|
|
15
|
+
export type LayerScope = 'project' | 'user' | 'system' | 'extra';
|
|
16
|
+
export interface Layer {
|
|
17
|
+
scope: LayerScope;
|
|
18
|
+
/** Absolute path of the repo root (e.g. `~/.agents`). */
|
|
19
|
+
base: string;
|
|
20
|
+
/** Set only when scope === 'extra'. */
|
|
21
|
+
alias?: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function clearLayerCache(): void;
|
|
24
|
+
/** All layers a "first-wins with project" resource consults, in precedence order. */
|
|
25
|
+
export declare function firstWinsLayers(cwd: string): Layer[];
|
|
26
|
+
/** Hooks-only: layers excluding project (security exclusion). */
|
|
27
|
+
export declare function hookLayers(): Layer[];
|
|
28
|
+
/**
|
|
29
|
+
* Resolve a single resource by name. Returns the first matching layer's
|
|
30
|
+
* absolute path plus the layer scope, or null when no layer has it.
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveByName(layers: Layer[], relative: string, predicate: (full: string) => boolean): {
|
|
33
|
+
path: string;
|
|
34
|
+
layer: Layer;
|
|
35
|
+
} | null;
|
|
36
|
+
/** Convenience: list names found by reading a relative subdir across all given layers. */
|
|
37
|
+
export declare function listAcrossLayers(layers: Layer[], relative: string, filter: (name: string, fullPath: string) => boolean): string[];
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer resolution for resources. Encapsulates which DotAgents repos a given
|
|
3
|
+
* resource type reads from and in what precedence. Centralized so every
|
|
4
|
+
* checker, plus the sync writer, agrees on the same set.
|
|
5
|
+
*
|
|
6
|
+
* Resolution model:
|
|
7
|
+
* - "first-wins" (commands, skills, mcp, subagents, workflows, plugins):
|
|
8
|
+
* project > user > system > extras. Same-named entries shadow.
|
|
9
|
+
* - "first-wins, no project" (hooks): security exclusion — see
|
|
10
|
+
* `src/lib/versions.ts:1832-1836` for the rationale. User > system > extras.
|
|
11
|
+
* - "merged" (permissions): every layer contributes; first-wins on name.
|
|
12
|
+
* - "composed" (rules): preset + subrules resolved per-name across layers.
|
|
13
|
+
*/
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
import { getProjectAgentsDir, getUserAgentsDir, getAgentsDir, getEnabledExtraRepos, } from '../state.js';
|
|
17
|
+
// ─── Per-process memoization ─────────────────────────────────────────────────
|
|
18
|
+
//
|
|
19
|
+
// `firstWinsLayers(cwd)` and `hookLayers()` get called from every checker for
|
|
20
|
+
// every resource — easily 50+ times per `isStale` call. Each call invokes
|
|
21
|
+
// `getProjectAgentsDir(cwd)` (walks up the filesystem) and `getEnabledExtraRepos()`
|
|
22
|
+
// (reads agents.yaml). Memoizing at process scope eliminates the redundancy.
|
|
23
|
+
//
|
|
24
|
+
// Safety: in a CLI invocation, neither cwd nor the user/system base dirs
|
|
25
|
+
// change mid-process, so the cache is always correct. Tests that exercise
|
|
26
|
+
// different HOMEs/cwds run in separate subprocesses (per `_harness.ts`), so
|
|
27
|
+
// the module's process-scope cache resets between scenarios.
|
|
28
|
+
//
|
|
29
|
+
// `clearLayerCache()` is exposed for tests or long-running daemons that need
|
|
30
|
+
// to force re-discovery.
|
|
31
|
+
const firstWinsCache = new Map();
|
|
32
|
+
let hookLayersCache = null;
|
|
33
|
+
export function clearLayerCache() {
|
|
34
|
+
firstWinsCache.clear();
|
|
35
|
+
hookLayersCache = null;
|
|
36
|
+
}
|
|
37
|
+
/** All layers a "first-wins with project" resource consults, in precedence order. */
|
|
38
|
+
export function firstWinsLayers(cwd) {
|
|
39
|
+
const cached = firstWinsCache.get(cwd);
|
|
40
|
+
if (cached)
|
|
41
|
+
return cached;
|
|
42
|
+
const layers = [];
|
|
43
|
+
const project = getProjectAgentsDir(cwd);
|
|
44
|
+
if (project)
|
|
45
|
+
layers.push({ scope: 'project', base: project });
|
|
46
|
+
layers.push({ scope: 'user', base: getUserAgentsDir() });
|
|
47
|
+
layers.push({ scope: 'system', base: getAgentsDir() });
|
|
48
|
+
for (const extra of getEnabledExtraRepos()) {
|
|
49
|
+
layers.push({ scope: 'extra', base: extra.dir, alias: extra.alias });
|
|
50
|
+
}
|
|
51
|
+
firstWinsCache.set(cwd, layers);
|
|
52
|
+
return layers;
|
|
53
|
+
}
|
|
54
|
+
/** Hooks-only: layers excluding project (security exclusion). */
|
|
55
|
+
export function hookLayers() {
|
|
56
|
+
if (hookLayersCache)
|
|
57
|
+
return hookLayersCache;
|
|
58
|
+
const layers = [];
|
|
59
|
+
layers.push({ scope: 'user', base: getUserAgentsDir() });
|
|
60
|
+
layers.push({ scope: 'system', base: getAgentsDir() });
|
|
61
|
+
for (const extra of getEnabledExtraRepos()) {
|
|
62
|
+
layers.push({ scope: 'extra', base: extra.dir, alias: extra.alias });
|
|
63
|
+
}
|
|
64
|
+
hookLayersCache = layers;
|
|
65
|
+
return layers;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Resolve a single resource by name. Returns the first matching layer's
|
|
69
|
+
* absolute path plus the layer scope, or null when no layer has it.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveByName(layers, relative, predicate) {
|
|
72
|
+
for (const layer of layers) {
|
|
73
|
+
const full = path.join(layer.base, relative);
|
|
74
|
+
if (predicate(full))
|
|
75
|
+
return { path: full, layer };
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
/** Convenience: list names found by reading a relative subdir across all given layers. */
|
|
80
|
+
export function listAcrossLayers(layers, relative, filter) {
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
for (const layer of layers) {
|
|
83
|
+
const dir = path.join(layer.base, relative);
|
|
84
|
+
let entries;
|
|
85
|
+
try {
|
|
86
|
+
entries = fs.readdirSync(dir);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
for (const name of entries) {
|
|
92
|
+
if (name.startsWith('.'))
|
|
93
|
+
continue;
|
|
94
|
+
if (!filter(name, path.join(dir, name)))
|
|
95
|
+
continue;
|
|
96
|
+
seen.add(name);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return Array.from(seen);
|
|
100
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public types for the staleness library. The on-disk manifest shape stays
|
|
3
|
+
* at `v: 1` for backward compatibility — see `src/lib/sync-manifest.ts` for
|
|
4
|
+
* the loader/saver that consumes these.
|
|
5
|
+
*/
|
|
6
|
+
import type { Fingerprint } from './fingerprint.js';
|
|
7
|
+
export declare const MANIFEST_VERSION: 1;
|
|
8
|
+
/** A single-file resource (commands, hooks, MCP server YAML, permission groups). */
|
|
9
|
+
export interface FileEntry {
|
|
10
|
+
source: Fingerprint;
|
|
11
|
+
}
|
|
12
|
+
/** A directory resource (skills, subagents, workflows). */
|
|
13
|
+
export interface DirEntry {
|
|
14
|
+
/** Winning source dir, absolute. */
|
|
15
|
+
dirPath: string;
|
|
16
|
+
/** All files inside the dir, sorted by absolute path. */
|
|
17
|
+
files: Fingerprint[];
|
|
18
|
+
}
|
|
19
|
+
/** Rules section — fingerprints of every source file (rules.yaml + active subrules). */
|
|
20
|
+
export interface RulesEntry {
|
|
21
|
+
files: Record<string, FileEntry>;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Permissions section — merged across layers (every group across every scope
|
|
25
|
+
* contributes; same name first-wins user > system). Plus the active preset
|
|
26
|
+
* env value, since preset selection changes which groups are applied.
|
|
27
|
+
*/
|
|
28
|
+
export interface PermEntry {
|
|
29
|
+
groups: Record<string, FileEntry>;
|
|
30
|
+
permissionPreset: string | null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Plugin entry. Plugins have a complex layout (`.claude-plugin/plugin.json`
|
|
34
|
+
* plus optional `skills/`, `commands/`, ...). We fingerprint the entire
|
|
35
|
+
* plugin root, same shape as a DirEntry.
|
|
36
|
+
*/
|
|
37
|
+
export type PluginEntry = DirEntry;
|
|
38
|
+
/**
|
|
39
|
+
* Full manifest. `workflows` and `plugins` are optional so older v1 files
|
|
40
|
+
* stay loadable; missing fields are treated as empty maps — name-set diff
|
|
41
|
+
* then triggers a single re-sync that fills them in.
|
|
42
|
+
*/
|
|
43
|
+
export interface SyncManifest {
|
|
44
|
+
v: typeof MANIFEST_VERSION;
|
|
45
|
+
syncedAt: string;
|
|
46
|
+
commands: Record<string, FileEntry>;
|
|
47
|
+
skills: Record<string, DirEntry>;
|
|
48
|
+
hooks: Record<string, FileEntry>;
|
|
49
|
+
rules: RulesEntry;
|
|
50
|
+
mcp: Record<string, FileEntry>;
|
|
51
|
+
permissions: PermEntry;
|
|
52
|
+
subagents: Record<string, DirEntry>;
|
|
53
|
+
workflows?: Record<string, DirEntry>;
|
|
54
|
+
plugins?: Record<string, PluginEntry>;
|
|
55
|
+
}
|
|
56
|
+
export type ResourceType = 'commands' | 'skills' | 'hooks' | 'mcp' | 'rules' | 'subagents' | 'workflows' | 'plugins' | 'permissions';
|
package/dist/lib/state.d.ts
CHANGED
|
@@ -125,6 +125,8 @@ export declare function getSessionsDbPath(): string;
|
|
|
125
125
|
export declare function getTeamsDir(): string;
|
|
126
126
|
/** Path to teams execution history (~/.agents/.history/teams/agents/). */
|
|
127
127
|
export declare function getTeamsAgentsDir(): string;
|
|
128
|
+
/** Path to the team registry — list of named teams with timestamps. Durable runtime, per-machine. */
|
|
129
|
+
export declare function getTeamsRegistryPath(): string;
|
|
128
130
|
/** Path to cloud dispatch cache (~/.agents/.cache/cloud/). */
|
|
129
131
|
export declare function getCloudDir(): string;
|
|
130
132
|
/** Path to terminal session metadata (~/.agents/.cache/terminals/). */
|