@apifuse/provider-sdk 2.2.0-beta.5 → 2.2.0-beta.7
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/AUTHORING.md +53 -0
- package/CHANGELOG.md +8 -0
- package/README.md +5 -1
- package/SUBMISSION.md +1 -1
- package/bin/apifuse-check.ts +26 -1
- package/bin/apifuse-pack-check.ts +14 -0
- package/bin/apifuse-submit-check.ts +193 -2
- package/bin/apifuse-sync-assets.ts +117 -0
- package/dist/cli/commands.d.ts +1 -1
- package/dist/cli/commands.js +8 -0
- package/dist/cli/create.d.ts +3 -0
- package/dist/cli/create.js +34 -35
- package/dist/cli/prompt-assets.d.ts +80 -0
- package/dist/cli/prompt-assets.js +743 -0
- package/dist/cli/templates/provider/AGENTS.md.tpl +17 -8
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/runtime/executor.js +7 -0
- package/dist/runtime/secrets.d.ts +27 -0
- package/dist/runtime/secrets.js +51 -0
- package/dist/server/serve.d.ts +5 -0
- package/dist/server/serve.js +39 -0
- package/package.json +1 -1
- package/src/cli/commands.ts +10 -0
- package/src/cli/create.ts +42 -35
- package/src/cli/prompt-assets.ts +865 -0
- package/src/cli/templates/provider/AGENTS.md.tpl +17 -8
- package/src/index.ts +5 -0
- package/src/runtime/executor.ts +8 -0
- package/src/runtime/secrets.ts +64 -0
- package/src/server/serve.ts +53 -0
- package/dist/cli/templates/provider/CLAUDE.md.tpl +0 -1
- package/src/cli/templates/provider/CLAUDE.md.tpl +0 -1
- /package/dist/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
- /package/dist/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
- /package/dist/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
- /package/dist/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
- /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
- /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
import { lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmdirSync, rmSync, symlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import packageJson from "../../package.json";
|
|
5
|
+
/**
|
|
6
|
+
* SDK-managed agent prompt assets.
|
|
7
|
+
*
|
|
8
|
+
* `apifuse create` scaffolds these files, `apifuse sync-assets` regenerates
|
|
9
|
+
* them in an existing provider root, and `apifuse check` / `submit-check`
|
|
10
|
+
* enforce that they byte-match the installed SDK version (fail closed).
|
|
11
|
+
*
|
|
12
|
+
* Layout contract:
|
|
13
|
+
* - AGENTS.md — real file (agent guide)
|
|
14
|
+
* - CLAUDE.md — symlink -> AGENTS.md
|
|
15
|
+
* - .agents/skills/<skill>/SKILL.md — real files
|
|
16
|
+
* - .agents/skills/upstream-notes/README.md — real file
|
|
17
|
+
* - .claude / .codex — symlinks -> .agents
|
|
18
|
+
* - .apifuse/prompt-assets.json — manifest (schema v2), written last
|
|
19
|
+
*/
|
|
20
|
+
export const PROMPT_ASSET_MANIFEST_PATH = ".apifuse/prompt-assets.json";
|
|
21
|
+
export const PROMPT_ASSET_MANIFEST_SCHEMA_VERSION = 2;
|
|
22
|
+
export const PROMPT_ASSET_SYNC_REMEDIATION = "Run `bun run sync-assets` (or `bunx apifuse sync-assets .`) to regenerate the SDK-managed agent prompt assets.";
|
|
23
|
+
export const PROMPT_ASSET_SYMLINKS = {
|
|
24
|
+
"CLAUDE.md": "AGENTS.md",
|
|
25
|
+
".claude": ".agents",
|
|
26
|
+
".codex": ".agents",
|
|
27
|
+
};
|
|
28
|
+
/** Legacy layout remnants that must not survive a sync (pre-.agents layout). */
|
|
29
|
+
const LEGACY_TOP_LEVEL_SKILLS_DIR = "skills";
|
|
30
|
+
/**
|
|
31
|
+
* Contributor-owned zone. `.agents/skills/upstream-notes/README.md` is
|
|
32
|
+
* SDK-managed (pristine-verified), but the README template explicitly
|
|
33
|
+
* instructs contributors to ADD per-vendor note files as sibling entries, and
|
|
34
|
+
* reviewers treat them as submission quality. Any file under this directory
|
|
35
|
+
* OTHER than README.md is therefore contributor-owned: the freshness gate must
|
|
36
|
+
* never flag it `unexpected`, and sync-assets (including its legacy cleanup)
|
|
37
|
+
* must never delete it — legacy authored notes are relocated here, not dropped.
|
|
38
|
+
*/
|
|
39
|
+
const UPSTREAM_NOTES_DIR = ".agents/skills/upstream-notes";
|
|
40
|
+
const UPSTREAM_NOTES_README = `${UPSTREAM_NOTES_DIR}/README.md`;
|
|
41
|
+
/**
|
|
42
|
+
* True for real, contributor-authored entries inside the upstream-notes zone
|
|
43
|
+
* (everything under it except the managed README.md). Symlinks are never
|
|
44
|
+
* treated as contributor-owned — callers must gate this behind an lstat that
|
|
45
|
+
* excludes symlinks so the exemption can never smuggle a path that escapes the
|
|
46
|
+
* provider root by naming it under upstream-notes.
|
|
47
|
+
*/
|
|
48
|
+
function isContributorOwnedUpstreamNotesPath(relativePath) {
|
|
49
|
+
return (relativePath.startsWith(`${UPSTREAM_NOTES_DIR}/`) && relativePath !== UPSTREAM_NOTES_README);
|
|
50
|
+
}
|
|
51
|
+
/** Relative asset paths whose content is rendered from `<path>.tpl`. */
|
|
52
|
+
export const PROMPT_ASSET_FILE_PATHS = [
|
|
53
|
+
"AGENTS.md",
|
|
54
|
+
".agents/skills/normalization-standards/SKILL.md",
|
|
55
|
+
".agents/skills/upstream-contract-verification/SKILL.md",
|
|
56
|
+
".agents/skills/fixtures-and-recording/SKILL.md",
|
|
57
|
+
".agents/skills/pagination-and-counts/SKILL.md",
|
|
58
|
+
".agents/skills/health-checks-and-fail-closed/SKILL.md",
|
|
59
|
+
".agents/skills/upstream-notes/README.md",
|
|
60
|
+
];
|
|
61
|
+
const TEMPLATE_DIR = fileURLToPath(new URL("./templates/provider/", import.meta.url));
|
|
62
|
+
function renderPromptAssetTemplateSync(fileName) {
|
|
63
|
+
const template = readFileSync(resolve(TEMPLATE_DIR, fileName), "utf8");
|
|
64
|
+
// Prompt asset templates take no values; mirror create.ts renderTemplate
|
|
65
|
+
// semantics (unknown keys render as empty strings) for byte-identical output.
|
|
66
|
+
return template.replace(/\{\{([A-Z_]+)\}\}/g, () => "");
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Build the full managed asset entry list (files first, symlinks after,
|
|
70
|
+
* manifest excluded). The renderer is injected so `apifuse create` can reuse
|
|
71
|
+
* its own template renderer; sync/verify use the SDK-internal renderer.
|
|
72
|
+
*/
|
|
73
|
+
export async function buildPromptAssetPlanEntries(renderTemplate) {
|
|
74
|
+
const entries = [];
|
|
75
|
+
for (const assetPath of PROMPT_ASSET_FILE_PATHS) {
|
|
76
|
+
entries.push({
|
|
77
|
+
path: assetPath,
|
|
78
|
+
content: await renderTemplate(`${assetPath}.tpl`, {}),
|
|
79
|
+
kind: "file",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
for (const [linkPath, target] of Object.entries(PROMPT_ASSET_SYMLINKS)) {
|
|
83
|
+
entries.push({ path: linkPath, content: target, kind: "symlink" });
|
|
84
|
+
}
|
|
85
|
+
return entries;
|
|
86
|
+
}
|
|
87
|
+
export function buildPromptAssetPlanEntriesSync() {
|
|
88
|
+
const entries = [];
|
|
89
|
+
for (const assetPath of PROMPT_ASSET_FILE_PATHS) {
|
|
90
|
+
entries.push({
|
|
91
|
+
path: assetPath,
|
|
92
|
+
content: renderPromptAssetTemplateSync(`${assetPath}.tpl`),
|
|
93
|
+
kind: "file",
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
for (const [linkPath, target] of Object.entries(PROMPT_ASSET_SYMLINKS)) {
|
|
97
|
+
entries.push({ path: linkPath, content: target, kind: "symlink" });
|
|
98
|
+
}
|
|
99
|
+
return entries;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Deterministic manifest serialization: schema v2, 2-space JSON, trailing
|
|
103
|
+
* newline, sorted paths (including symlink paths, excluding the manifest
|
|
104
|
+
* itself). `sdkVersion` + `paths` keep their v1 semantics so older parsers
|
|
105
|
+
* keep working; `schemaVersion` and `symlinks` are additive.
|
|
106
|
+
*/
|
|
107
|
+
export function buildPromptAssetManifest(entries, sdkVersion) {
|
|
108
|
+
const paths = entries.map((entry) => entry.path).sort();
|
|
109
|
+
return `${JSON.stringify({
|
|
110
|
+
schemaVersion: PROMPT_ASSET_MANIFEST_SCHEMA_VERSION,
|
|
111
|
+
sdkVersion,
|
|
112
|
+
paths,
|
|
113
|
+
symlinks: PROMPT_ASSET_SYMLINKS,
|
|
114
|
+
}, null, 2)}\n`;
|
|
115
|
+
}
|
|
116
|
+
export function installedSdkVersion() {
|
|
117
|
+
return packageJson.version;
|
|
118
|
+
}
|
|
119
|
+
function lstatSafe(path) {
|
|
120
|
+
try {
|
|
121
|
+
return lstatSync(path);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function readManifestRaw(manifestAbsPath) {
|
|
128
|
+
const stat = lstatSafe(manifestAbsPath);
|
|
129
|
+
if (!stat?.isFile()) {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
return readFileSync(manifestAbsPath, "utf8");
|
|
133
|
+
}
|
|
134
|
+
function parseManifest(raw) {
|
|
135
|
+
try {
|
|
136
|
+
const parsed = JSON.parse(raw);
|
|
137
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
138
|
+
return { paths: [] };
|
|
139
|
+
}
|
|
140
|
+
const record = parsed;
|
|
141
|
+
const sdkVersion = typeof record.sdkVersion === "string" ? record.sdkVersion : undefined;
|
|
142
|
+
const paths = Array.isArray(record.paths)
|
|
143
|
+
? record.paths.filter((value) => typeof value === "string")
|
|
144
|
+
: [];
|
|
145
|
+
return { sdkVersion, paths };
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return { paths: [] };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Reject manifest paths that could escape the provider root. */
|
|
152
|
+
function isSafeRelativeAssetPath(relativePath) {
|
|
153
|
+
if (!relativePath || relativePath.startsWith("/") || relativePath.includes("\\")) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
const segments = relativePath.split("/");
|
|
157
|
+
return segments.every((segment) => segment !== "" && segment !== "." && segment !== "..");
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Namespaces the SDK has ever managed. Manifest-driven orphan cleanup may
|
|
161
|
+
* only delete inside these — a pre-existing manifest is untrusted repository
|
|
162
|
+
* content (bounty submissions are adversarial), so listing e.g. `src/index.ts`
|
|
163
|
+
* or `.git/config` must never make sync-assets delete it.
|
|
164
|
+
*/
|
|
165
|
+
const MANAGED_TOP_LEVEL_NAMES = new Set([
|
|
166
|
+
"AGENTS.md",
|
|
167
|
+
"CLAUDE.md",
|
|
168
|
+
".claude",
|
|
169
|
+
".codex",
|
|
170
|
+
".agents",
|
|
171
|
+
".apifuse",
|
|
172
|
+
LEGACY_TOP_LEVEL_SKILLS_DIR,
|
|
173
|
+
]);
|
|
174
|
+
const MANAGED_PATH_PREFIXES = [
|
|
175
|
+
".agents/",
|
|
176
|
+
".apifuse/",
|
|
177
|
+
`${LEGACY_TOP_LEVEL_SKILLS_DIR}/`,
|
|
178
|
+
];
|
|
179
|
+
function isManagedNamespacePath(relativePath) {
|
|
180
|
+
return (MANAGED_TOP_LEVEL_NAMES.has(relativePath) ||
|
|
181
|
+
MANAGED_PATH_PREFIXES.some((prefix) => relativePath.startsWith(prefix)));
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Root of the auto-loaded skill tree. `.claude`/`.codex` symlink onto `.agents`,
|
|
185
|
+
* so every agent CLI loads `.agents/skills/<name>/` as guidance — this subtree,
|
|
186
|
+
* and only this subtree, is the guidance-injection vector the freshness gate
|
|
187
|
+
* polices for unauthorized content.
|
|
188
|
+
*/
|
|
189
|
+
const AGENTS_SKILLS_DIR = ".agents/skills";
|
|
190
|
+
/**
|
|
191
|
+
* Skill directory names the SDK authorizes: the managed skills plus the
|
|
192
|
+
* contributor-owned `upstream-notes` zone. Derived from PROMPT_ASSET_FILE_PATHS
|
|
193
|
+
* (`.agents/skills/<name>/...`), so upstream-notes is included via its managed
|
|
194
|
+
* README. Any OTHER directory directly under `.agents/skills/` is an injected
|
|
195
|
+
* skill.
|
|
196
|
+
*/
|
|
197
|
+
const AUTHORIZED_SKILL_DIR_NAMES = new Set(PROMPT_ASSET_FILE_PATHS.filter((assetPath) => assetPath.startsWith(`${AGENTS_SKILLS_DIR}/`)).map((assetPath) => assetPath.split("/")[2]));
|
|
198
|
+
/**
|
|
199
|
+
* Every managed directory ancestor implied by the real-file asset paths, sorted
|
|
200
|
+
* parent-before-child (fewest path segments first): `.agents`, `.agents/skills`,
|
|
201
|
+
* `.agents/skills/<each managed skill>`, `.agents/skills/upstream-notes`, … .
|
|
202
|
+
* Derived generically from PROMPT_ASSET_FILE_PATHS so no level is ever missed;
|
|
203
|
+
* sync normalizes each into a REAL directory before any relocation/comparison/
|
|
204
|
+
* write so nothing resolves through a symlink to an outside location.
|
|
205
|
+
*/
|
|
206
|
+
const MANAGED_DIRECTORY_ANCESTORS = (() => {
|
|
207
|
+
const dirs = new Set();
|
|
208
|
+
for (const assetPath of PROMPT_ASSET_FILE_PATHS) {
|
|
209
|
+
const segments = assetPath.split("/");
|
|
210
|
+
for (let end = 1; end < segments.length; end += 1) {
|
|
211
|
+
dirs.add(segments.slice(0, end).join("/"));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return [...dirs].sort((a, b) => a.split("/").length - b.split("/").length);
|
|
215
|
+
})();
|
|
216
|
+
/**
|
|
217
|
+
* Enumerate the guidance-injection risks under `.agents/skills/` — and ONLY
|
|
218
|
+
* those. Two things are flagged (never followed, never deleted by sync):
|
|
219
|
+
* 1. an unauthorized skill directory `.agents/skills/<name>/` whose <name> is
|
|
220
|
+
* neither a managed skill nor `upstream-notes` (an injected skill), and
|
|
221
|
+
* 2. any SYMLINK anywhere under `.agents/skills/` (at any depth).
|
|
222
|
+
*
|
|
223
|
+
* Everything else under `.agents/` is tool/user content that legitimately lands
|
|
224
|
+
* there through the `.claude`/`.codex` symlinks — `settings.json`,
|
|
225
|
+
* `config.toml`, `commands/`, `hooks/`, `references/`, authored files inside
|
|
226
|
+
* `.agents/skills/upstream-notes/`, etc. — and is NEVER flagged or swept. Real
|
|
227
|
+
* subdirectories inside authorized skills are still walked so nested symlinks
|
|
228
|
+
* stay visible. Returns sorted provider-root-relative paths (dirs get a
|
|
229
|
+
* trailing `/`). No-op when `.agents/skills` is missing or a symlink.
|
|
230
|
+
*/
|
|
231
|
+
function findUnexpectedAgentEntries(providerRoot) {
|
|
232
|
+
const skillsStat = lstatSafe(join(providerRoot, AGENTS_SKILLS_DIR));
|
|
233
|
+
if (!skillsStat?.isDirectory()) {
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
const unexpected = [];
|
|
237
|
+
const readDirentsSafe = (absDir) => {
|
|
238
|
+
try {
|
|
239
|
+
return readdirSync(absDir, { withFileTypes: true });
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
// Recurse through an authorized skill directory, flagging only symlinks
|
|
246
|
+
// (never following them); real files/dirs inside are the skill's content.
|
|
247
|
+
const flagNestedSymlinks = (relativeDir) => {
|
|
248
|
+
for (const dirent of readDirentsSafe(join(providerRoot, relativeDir))) {
|
|
249
|
+
const relativePath = `${relativeDir}/${dirent.name}`;
|
|
250
|
+
if (dirent.isSymbolicLink()) {
|
|
251
|
+
unexpected.push(relativePath);
|
|
252
|
+
}
|
|
253
|
+
else if (dirent.isDirectory()) {
|
|
254
|
+
flagNestedSymlinks(relativePath);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
for (const dirent of readDirentsSafe(join(providerRoot, AGENTS_SKILLS_DIR))) {
|
|
259
|
+
const relativePath = `${AGENTS_SKILLS_DIR}/${dirent.name}`;
|
|
260
|
+
if (dirent.isSymbolicLink()) {
|
|
261
|
+
// A symlink directly under the skills tree — the injection vector.
|
|
262
|
+
unexpected.push(relativePath);
|
|
263
|
+
}
|
|
264
|
+
else if (dirent.isDirectory()) {
|
|
265
|
+
if (AUTHORIZED_SKILL_DIR_NAMES.has(dirent.name)) {
|
|
266
|
+
flagNestedSymlinks(relativePath);
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
// An unauthorized skill directory — flagged, never deleted.
|
|
270
|
+
unexpected.push(`${relativePath}/`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Non-skill regular files directly under `.agents/skills/` are ignored.
|
|
274
|
+
}
|
|
275
|
+
return unexpected.sort();
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* First ancestor directory of `relativePath` (relative, final component
|
|
279
|
+
* excluded) that exists on disk as a symlink — undefined when every existing
|
|
280
|
+
* ancestor is a real directory. The lexical safety check cannot catch this:
|
|
281
|
+
* `join()` never follows links, but rmSync/readFileSync/writeFileSync resolve
|
|
282
|
+
* intermediate symlink components, so `linkdir -> /outside` plus a manifest
|
|
283
|
+
* path `linkdir/x` would otherwise read or delete outside the provider root.
|
|
284
|
+
*/
|
|
285
|
+
function findSymlinkAncestor(providerRoot, relativePath) {
|
|
286
|
+
const segments = relativePath.split("/").slice(0, -1);
|
|
287
|
+
let currentRelative = "";
|
|
288
|
+
for (const segment of segments) {
|
|
289
|
+
currentRelative = currentRelative === "" ? segment : `${currentRelative}/${segment}`;
|
|
290
|
+
const stat = lstatSafe(join(providerRoot, currentRelative));
|
|
291
|
+
if (!stat) {
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
if (stat.isSymbolicLink()) {
|
|
295
|
+
return currentRelative;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Verify the on-disk prompt assets against the set regenerated from the
|
|
302
|
+
* installed SDK. Uses lstat/readlink for symlinks (never follows), byte
|
|
303
|
+
* comparison for files, and exact-version comparison for the manifest.
|
|
304
|
+
*/
|
|
305
|
+
export function verifyPromptAssets(providerRoot) {
|
|
306
|
+
const entries = buildPromptAssetPlanEntriesSync();
|
|
307
|
+
const missing = [];
|
|
308
|
+
const stale = [];
|
|
309
|
+
const modified = [];
|
|
310
|
+
const legacy = [];
|
|
311
|
+
// Any top-level `skills` entry is legacy — directory, regular file, or
|
|
312
|
+
// symlink (a `skills -> elsewhere` link would keep serving stale prompt
|
|
313
|
+
// content to agents following pre-migration references). This mirrors the
|
|
314
|
+
// sync-assets removal predicate so verify-green always implies sync-no-op.
|
|
315
|
+
const legacySkillsStat = lstatSafe(join(providerRoot, LEGACY_TOP_LEVEL_SKILLS_DIR));
|
|
316
|
+
if (legacySkillsStat) {
|
|
317
|
+
const kind = legacySkillsStat.isDirectory()
|
|
318
|
+
? "directory"
|
|
319
|
+
: legacySkillsStat.isSymbolicLink()
|
|
320
|
+
? "symlink"
|
|
321
|
+
: "file";
|
|
322
|
+
legacy.push(`${LEGACY_TOP_LEVEL_SKILLS_DIR}/ (legacy top-level skills ${kind}; the managed copy lives in .agents/skills/)`);
|
|
323
|
+
}
|
|
324
|
+
// Managed assets must not resolve through symlinked directories: the
|
|
325
|
+
// layout contract allows symlinks only at CLAUDE.md/.claude/.codex. A
|
|
326
|
+
// symlinked `.agents` (or `.apifuse`) would let the effective prompt
|
|
327
|
+
// content live outside the repository while byte checks still pass.
|
|
328
|
+
const flaggedSymlinkAncestors = new Set();
|
|
329
|
+
const recordSymlinkAncestor = (relativePath) => {
|
|
330
|
+
const ancestor = findSymlinkAncestor(providerRoot, relativePath);
|
|
331
|
+
if (ancestor === undefined) {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
if (!flaggedSymlinkAncestors.has(ancestor)) {
|
|
335
|
+
flaggedSymlinkAncestors.add(ancestor);
|
|
336
|
+
modified.push(`${ancestor} (expected a real directory, found a symlink)`);
|
|
337
|
+
}
|
|
338
|
+
return true;
|
|
339
|
+
};
|
|
340
|
+
const manifestAbsPath = join(providerRoot, PROMPT_ASSET_MANIFEST_PATH);
|
|
341
|
+
const manifestRaw = recordSymlinkAncestor(PROMPT_ASSET_MANIFEST_PATH)
|
|
342
|
+
? undefined
|
|
343
|
+
: readManifestRaw(manifestAbsPath);
|
|
344
|
+
if (manifestRaw === undefined) {
|
|
345
|
+
missing.push(PROMPT_ASSET_MANIFEST_PATH);
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
const expectedManifest = buildPromptAssetManifest(entries, packageJson.version);
|
|
349
|
+
const { sdkVersion } = parseManifest(manifestRaw);
|
|
350
|
+
if (sdkVersion !== packageJson.version) {
|
|
351
|
+
stale.push(`${PROMPT_ASSET_MANIFEST_PATH} (sdkVersion ${sdkVersion ?? "unreadable"} != installed ${packageJson.version})`);
|
|
352
|
+
}
|
|
353
|
+
else if (manifestRaw !== expectedManifest) {
|
|
354
|
+
modified.push(`${PROMPT_ASSET_MANIFEST_PATH} (differs from the regenerated manifest)`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
for (const entry of entries) {
|
|
358
|
+
if (recordSymlinkAncestor(entry.path)) {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const absPath = join(providerRoot, entry.path);
|
|
362
|
+
const stat = lstatSafe(absPath);
|
|
363
|
+
if (!stat) {
|
|
364
|
+
missing.push(entry.path);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (entry.kind === "symlink") {
|
|
368
|
+
if (!stat.isSymbolicLink()) {
|
|
369
|
+
// A real directory here is user agent config, not a stale asset:
|
|
370
|
+
// report a distinct, actionable reason (never a generic `unexpected`,
|
|
371
|
+
// and it is never swept — findUnexpectedAgentEntries only walks
|
|
372
|
+
// `.agents/`). sync-assets throws on it rather than migrating.
|
|
373
|
+
modified.push(stat.isDirectory()
|
|
374
|
+
? `${entry.path} (must be a symlink to ${entry.content}; migrate its contents first)`
|
|
375
|
+
: `${entry.path} (expected symlink -> ${entry.content})`);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
const target = readlinkSync(absPath);
|
|
379
|
+
if (target !== entry.content) {
|
|
380
|
+
modified.push(`${entry.path} (symlink -> ${target}, expected -> ${entry.content})`);
|
|
381
|
+
}
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (!stat.isFile()) {
|
|
385
|
+
modified.push(`${entry.path} (expected a regular file)`);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (readFileSync(absPath, "utf8") !== entry.content) {
|
|
389
|
+
modified.push(`${entry.path} (content differs from the installed SDK template)`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// Extra, unlisted files under `.agents/` must fail closed: the freshness
|
|
393
|
+
// gate would otherwise let a contributor inject agent guidance that byte
|
|
394
|
+
// checks over the fixed expected set never see.
|
|
395
|
+
const unexpected = findUnexpectedAgentEntries(providerRoot);
|
|
396
|
+
return {
|
|
397
|
+
ok: missing.length === 0 &&
|
|
398
|
+
stale.length === 0 &&
|
|
399
|
+
modified.length === 0 &&
|
|
400
|
+
legacy.length === 0 &&
|
|
401
|
+
unexpected.length === 0,
|
|
402
|
+
missing,
|
|
403
|
+
stale,
|
|
404
|
+
modified,
|
|
405
|
+
legacy,
|
|
406
|
+
unexpected,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
export function formatPromptAssetIssues(verification) {
|
|
410
|
+
return [
|
|
411
|
+
...verification.missing.map((item) => `missing: ${item}`),
|
|
412
|
+
...verification.stale.map((item) => `stale: ${item}`),
|
|
413
|
+
...verification.modified.map((item) => `modified: ${item}`),
|
|
414
|
+
...verification.legacy.map((item) => `legacy: ${item}`),
|
|
415
|
+
...verification.unexpected.map((item) => `unexpected: ${item}`),
|
|
416
|
+
];
|
|
417
|
+
}
|
|
418
|
+
function removeEmptyParentDirectories(providerRoot, startDirectory) {
|
|
419
|
+
const rootPath = resolve(providerRoot);
|
|
420
|
+
let currentDirectory = resolve(startDirectory);
|
|
421
|
+
while (currentDirectory.startsWith(`${rootPath}/`) && currentDirectory !== rootPath) {
|
|
422
|
+
try {
|
|
423
|
+
rmdirSync(currentDirectory); // only succeeds when empty
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
currentDirectory = dirname(currentDirectory);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
/** Remove any non-directory ancestor blocking a managed file path, then mkdir -p. */
|
|
432
|
+
function ensureParentDirectory(providerRoot, relativeFilePath) {
|
|
433
|
+
const segments = relativeFilePath.split("/").slice(0, -1);
|
|
434
|
+
let currentPath = providerRoot;
|
|
435
|
+
for (const segment of segments) {
|
|
436
|
+
currentPath = join(currentPath, segment);
|
|
437
|
+
const stat = lstatSafe(currentPath);
|
|
438
|
+
if (stat && !stat.isDirectory()) {
|
|
439
|
+
rmSync(currentPath, { recursive: true, force: true });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
mkdirSync(join(providerRoot, segments.join("/")), { recursive: true });
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Relocate contributor-authored files from the legacy top-level
|
|
446
|
+
* `skills/upstream-notes/` into the managed `.agents/skills/upstream-notes/`
|
|
447
|
+
* zone before the legacy `skills/` tree is deleted. The legacy README.md is
|
|
448
|
+
* skipped (regenerated from the template). Only real files reached through real
|
|
449
|
+
* directories are moved — symlinks are never relocated or followed, so a
|
|
450
|
+
* hostile `skills/upstream-notes/link -> /outside` can never copy content into
|
|
451
|
+
* or out of the provider root.
|
|
452
|
+
*
|
|
453
|
+
* A newer `.agents` note is never overwritten. When the destination already
|
|
454
|
+
* exists: identical bytes mean the legacy copy is redundant (dropped, no
|
|
455
|
+
* write); differing bytes (or a non-regular-file destination) mean the legacy
|
|
456
|
+
* content is written to the first free `<name>.legacy[.N]<ext>` path alongside
|
|
457
|
+
* it so both versions are retained. Returns the paths actually written.
|
|
458
|
+
*
|
|
459
|
+
* Called only when the legacy `skills/` entry is itself a real directory.
|
|
460
|
+
*/
|
|
461
|
+
/**
|
|
462
|
+
* First non-colliding conflict path alongside `destRelativePath`, inserting a
|
|
463
|
+
* `.legacy` marker before the extension: `foo.md` -> `foo.legacy.md`, then
|
|
464
|
+
* `foo.legacy.1.md`, `foo.legacy.2.md`, … Uses lstat (never follows) so an
|
|
465
|
+
* existing symlink at a candidate name still counts as taken.
|
|
466
|
+
*/
|
|
467
|
+
function firstFreeConflictPath(providerRoot, destRelativePath) {
|
|
468
|
+
const slash = destRelativePath.lastIndexOf("/");
|
|
469
|
+
const dir = destRelativePath.slice(0, slash);
|
|
470
|
+
const filename = destRelativePath.slice(slash + 1);
|
|
471
|
+
const dot = filename.lastIndexOf(".");
|
|
472
|
+
const stem = dot > 0 ? filename.slice(0, dot) : filename;
|
|
473
|
+
const ext = dot > 0 ? filename.slice(dot) : "";
|
|
474
|
+
for (let index = 0;; index += 1) {
|
|
475
|
+
const candidateName = index === 0 ? `${stem}.legacy${ext}` : `${stem}.legacy.${index}${ext}`;
|
|
476
|
+
const candidateRelative = `${dir}/${candidateName}`;
|
|
477
|
+
if (!lstatSafe(join(providerRoot, candidateRelative))) {
|
|
478
|
+
return candidateRelative;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function relocateLegacyUpstreamNotes(providerRoot) {
|
|
483
|
+
const legacyNotesDir = `${LEGACY_TOP_LEVEL_SKILLS_DIR}/upstream-notes`;
|
|
484
|
+
const legacyNotesStat = lstatSafe(join(providerRoot, legacyNotesDir));
|
|
485
|
+
if (!legacyNotesStat?.isDirectory()) {
|
|
486
|
+
return [];
|
|
487
|
+
}
|
|
488
|
+
const relocated = [];
|
|
489
|
+
const readDirentsSafe = (absDir) => {
|
|
490
|
+
try {
|
|
491
|
+
return readdirSync(absDir, { withFileTypes: true });
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
return [];
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
const walk = (relativeDir) => {
|
|
498
|
+
for (const dirent of readDirentsSafe(join(providerRoot, relativeDir))) {
|
|
499
|
+
const relativePath = `${relativeDir}/${dirent.name}`;
|
|
500
|
+
if (dirent.isSymbolicLink()) {
|
|
501
|
+
continue; // never relocate a symlink or recurse through it
|
|
502
|
+
}
|
|
503
|
+
if (dirent.isDirectory()) {
|
|
504
|
+
walk(relativePath);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (!dirent.isFile()) {
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
const subPath = relativePath.slice(`${legacyNotesDir}/`.length);
|
|
511
|
+
if (subPath === "README.md") {
|
|
512
|
+
continue; // managed asset, regenerated from the template
|
|
513
|
+
}
|
|
514
|
+
const destRelativePath = `${UPSTREAM_NOTES_DIR}/${subPath}`;
|
|
515
|
+
const contents = readFileSync(join(providerRoot, relativePath));
|
|
516
|
+
const destAbsPath = join(providerRoot, destRelativePath);
|
|
517
|
+
const destStat = lstatSafe(destAbsPath);
|
|
518
|
+
if (!destStat) {
|
|
519
|
+
// No collision — relocate the legacy note as-is.
|
|
520
|
+
ensureParentDirectory(providerRoot, destRelativePath);
|
|
521
|
+
writeFileSync(destAbsPath, contents);
|
|
522
|
+
relocated.push(destRelativePath);
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
if (destStat.isFile() && readFileSync(destAbsPath).equals(contents)) {
|
|
526
|
+
// A byte-identical note already lives at the destination; the legacy
|
|
527
|
+
// copy is redundant. Write nothing — the caller removes the legacy
|
|
528
|
+
// tree, dropping the duplicate without touching the newer file.
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
// Destination exists with different bytes (or is not a plain file):
|
|
532
|
+
// never overwrite it. Retain both by writing the legacy content to the
|
|
533
|
+
// first free `<name>.legacy[.N]<ext>` path beside it.
|
|
534
|
+
const conflictRelativePath = firstFreeConflictPath(providerRoot, destRelativePath);
|
|
535
|
+
ensureParentDirectory(providerRoot, conflictRelativePath);
|
|
536
|
+
writeFileSync(join(providerRoot, conflictRelativePath), contents);
|
|
537
|
+
relocated.push(conflictRelativePath);
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
walk(legacyNotesDir);
|
|
541
|
+
return relocated;
|
|
542
|
+
}
|
|
543
|
+
/** Sorted immediate child names of a directory (empty on any read error). */
|
|
544
|
+
function readTopLevelEntryNames(absDir) {
|
|
545
|
+
try {
|
|
546
|
+
return readdirSync(absDir).sort();
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
return [];
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Actionable error for a pre-existing REAL directory occupying a managed
|
|
554
|
+
* symlink path (`.claude`/`.codex`). The agent-asset layout requires a symlink
|
|
555
|
+
* to `.agents`; sync-assets never merges or deletes such a directory (either
|
|
556
|
+
* would risk destroying user agent config), so it fails loudly and tells the
|
|
557
|
+
* user to reconcile it by hand. Deterministic and idempotent: once the user
|
|
558
|
+
* moves/removes the contents, the next run creates the symlink and stays green.
|
|
559
|
+
*/
|
|
560
|
+
function realDirectorySymlinkConflictMessage(providerRoot, entryPath, target) {
|
|
561
|
+
const names = readTopLevelEntryNames(join(providerRoot, entryPath));
|
|
562
|
+
const contents = names.length > 0 ? names.join(", ") : "(empty)";
|
|
563
|
+
return (`${entryPath}/ is a real directory containing [${contents}]. ` +
|
|
564
|
+
`The APIFuse agent-asset layout requires ${entryPath} to be a symlink to ${target}. ` +
|
|
565
|
+
`Move or remove its contents (e.g. into ${target}/) and re-run \`apifuse sync-assets .\`.`);
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Regenerate the full managed asset set for the installed SDK version in an
|
|
569
|
+
* existing provider root. Deletes legacy managed paths first (top-level
|
|
570
|
+
* skills/**, plus manifest-listed paths that left the set — restricted to
|
|
571
|
+
* managed namespaces with no symlinked ancestors), writes files, replaces
|
|
572
|
+
* symlinks, and writes the manifest last. Idempotent.
|
|
573
|
+
*/
|
|
574
|
+
export function syncPromptAssets(providerRoot) {
|
|
575
|
+
const entries = buildPromptAssetPlanEntriesSync();
|
|
576
|
+
const manifestContent = buildPromptAssetManifest(entries, packageJson.version);
|
|
577
|
+
const manifestAbsPath = join(providerRoot, PROMPT_ASSET_MANIFEST_PATH);
|
|
578
|
+
const expectedPaths = new Set([
|
|
579
|
+
...entries.map((entry) => entry.path),
|
|
580
|
+
PROMPT_ASSET_MANIFEST_PATH,
|
|
581
|
+
]);
|
|
582
|
+
const removed = [];
|
|
583
|
+
const wroteFiles = [];
|
|
584
|
+
const createdSymlinks = [];
|
|
585
|
+
// 0. Normalize the ENTIRE managed directory ancestor chain into REAL
|
|
586
|
+
// directories BEFORE any relocation, comparison, or managed-file write. If
|
|
587
|
+
// ANY level (`.agents`, `.agents/skills`, `.agents/skills/upstream-notes`, a
|
|
588
|
+
// managed skill dir) is a symlink or regular file, a later identity/collision
|
|
589
|
+
// check or write would resolve THROUGH it to an outside location — e.g. the
|
|
590
|
+
// legacy upstream-note duplicate check could read an outside file, match
|
|
591
|
+
// bytes, and drop the note as "redundant"; the link is then replaced with a
|
|
592
|
+
// real dir and legacy skills/ is removed → silent data loss. Iterating
|
|
593
|
+
// parent-before-child replaces a symlinked parent before its children are
|
|
594
|
+
// created. lstat only: a symlink/file is unlinked, never followed, so the
|
|
595
|
+
// outside target and everything outside the provider root are untouched.
|
|
596
|
+
for (const managedDir of MANAGED_DIRECTORY_ANCESTORS) {
|
|
597
|
+
const absDir = join(providerRoot, managedDir);
|
|
598
|
+
const stat = lstatSafe(absDir);
|
|
599
|
+
if (stat && !stat.isDirectory()) {
|
|
600
|
+
rmSync(absDir, { recursive: true, force: true });
|
|
601
|
+
}
|
|
602
|
+
mkdirSync(absDir, { recursive: true });
|
|
603
|
+
}
|
|
604
|
+
// 1. Legacy top-level skills/ from the pre-.agents layout. Contributor-
|
|
605
|
+
// authored upstream-notes files are relocated into the managed
|
|
606
|
+
// .agents/skills/upstream-notes/ zone FIRST (never destroyed); only then is
|
|
607
|
+
// the legacy tree removed. Relocation runs only when `skills` is a real
|
|
608
|
+
// directory — a `skills` symlink is unlinked without following it.
|
|
609
|
+
const legacySkillsAbsPath = join(providerRoot, LEGACY_TOP_LEVEL_SKILLS_DIR);
|
|
610
|
+
const legacySkillsStat = lstatSafe(legacySkillsAbsPath);
|
|
611
|
+
if (legacySkillsStat) {
|
|
612
|
+
if (legacySkillsStat.isDirectory()) {
|
|
613
|
+
wroteFiles.push(...relocateLegacyUpstreamNotes(providerRoot));
|
|
614
|
+
}
|
|
615
|
+
rmSync(legacySkillsAbsPath, { recursive: true, force: true });
|
|
616
|
+
removed.push(`${LEGACY_TOP_LEVEL_SKILLS_DIR}/`);
|
|
617
|
+
}
|
|
618
|
+
// 2. Paths a pre-existing manifest managed that are no longer in the set.
|
|
619
|
+
// The manifest is untrusted repository content: deletion is restricted to
|
|
620
|
+
// SDK-managed namespaces, and paths resolving through a symlinked
|
|
621
|
+
// directory are skipped entirely (rmSync follows intermediate symlinks, so
|
|
622
|
+
// they could otherwise delete files outside the provider root).
|
|
623
|
+
const previousManifestRaw = findSymlinkAncestor(providerRoot, PROMPT_ASSET_MANIFEST_PATH) === undefined
|
|
624
|
+
? readManifestRaw(manifestAbsPath)
|
|
625
|
+
: undefined;
|
|
626
|
+
if (previousManifestRaw !== undefined) {
|
|
627
|
+
for (const previousPath of parseManifest(previousManifestRaw).paths) {
|
|
628
|
+
if (expectedPaths.has(previousPath) ||
|
|
629
|
+
!isSafeRelativeAssetPath(previousPath) ||
|
|
630
|
+
!isManagedNamespacePath(previousPath) ||
|
|
631
|
+
isContributorOwnedUpstreamNotesPath(previousPath) ||
|
|
632
|
+
findSymlinkAncestor(providerRoot, previousPath) !== undefined) {
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
const absPath = join(providerRoot, previousPath);
|
|
636
|
+
const orphanStat = lstatSafe(absPath);
|
|
637
|
+
if (!orphanStat) {
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
// Never recursively delete a managed-namespace DIRECTORY named by the
|
|
641
|
+
// (untrusted) manifest: a hostile or stale entry like `.agents/skills`
|
|
642
|
+
// would otherwise sweep away contributor-authored upstream-notes files
|
|
643
|
+
// nested inside it. Directories are removed only when already empty;
|
|
644
|
+
// recursive removal is limited to regular files (and a symlink AT the
|
|
645
|
+
// path, which rmSync unlinks without following). Individual authored
|
|
646
|
+
// notes are additionally guarded by isContributorOwnedUpstreamNotesPath.
|
|
647
|
+
if (orphanStat.isDirectory()) {
|
|
648
|
+
try {
|
|
649
|
+
rmdirSync(absPath); // succeeds only when the directory is empty
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
removed.push(previousPath);
|
|
655
|
+
removeEmptyParentDirectories(providerRoot, dirname(absPath));
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
rmSync(absPath, { recursive: true, force: true });
|
|
659
|
+
removed.push(previousPath);
|
|
660
|
+
removeEmptyParentDirectories(providerRoot, dirname(absPath));
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
// 3. Regular files (byte-identical files are left untouched). When an
|
|
664
|
+
// ancestor directory is a symlink (e.g. `.agents -> /elsewhere`), the
|
|
665
|
+
// bytes visible through the link never count as in-sync: the entry is
|
|
666
|
+
// rewritten and ensureParentDirectory replaces the offending link with a
|
|
667
|
+
// real directory. The final path is never rmSync'd through such a link.
|
|
668
|
+
for (const entry of entries) {
|
|
669
|
+
if (entry.kind !== "file") {
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
const absPath = join(providerRoot, entry.path);
|
|
673
|
+
const symlinkAncestor = findSymlinkAncestor(providerRoot, entry.path);
|
|
674
|
+
const stat = symlinkAncestor === undefined ? lstatSafe(absPath) : undefined;
|
|
675
|
+
if (stat?.isFile() && readFileSync(absPath, "utf8") === entry.content) {
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
if (stat) {
|
|
679
|
+
rmSync(absPath, { recursive: true, force: true });
|
|
680
|
+
}
|
|
681
|
+
ensureParentDirectory(providerRoot, entry.path);
|
|
682
|
+
writeFileSync(absPath, entry.content);
|
|
683
|
+
wroteFiles.push(entry.path);
|
|
684
|
+
}
|
|
685
|
+
// 4. Symlinks — replace whatever occupies the path. A wrong-target symlink
|
|
686
|
+
// or a regular file carries no user tree and is replaced. But a REAL
|
|
687
|
+
// DIRECTORY here is pre-existing user agent config (a hand-managed
|
|
688
|
+
// `.claude/` or `.codex/` with commands/, settings.json, hooks): never
|
|
689
|
+
// merge or delete it. Fail loudly and idempotently so the user reconciles
|
|
690
|
+
// it by hand — no data is touched. Correct links are left untouched.
|
|
691
|
+
for (const entry of entries) {
|
|
692
|
+
if (entry.kind !== "symlink") {
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
const absPath = join(providerRoot, entry.path);
|
|
696
|
+
const symlinkAncestor = findSymlinkAncestor(providerRoot, entry.path);
|
|
697
|
+
const stat = symlinkAncestor === undefined ? lstatSafe(absPath) : undefined;
|
|
698
|
+
if (stat?.isSymbolicLink() && readlinkSync(absPath) === entry.content) {
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
if (stat?.isDirectory()) {
|
|
702
|
+
throw new Error(realDirectorySymlinkConflictMessage(providerRoot, entry.path, entry.content));
|
|
703
|
+
}
|
|
704
|
+
if (stat) {
|
|
705
|
+
rmSync(absPath, { recursive: true, force: true });
|
|
706
|
+
}
|
|
707
|
+
ensureParentDirectory(providerRoot, entry.path);
|
|
708
|
+
symlinkSync(entry.content, absPath);
|
|
709
|
+
createdSymlinks.push(`${entry.path} -> ${entry.content}`);
|
|
710
|
+
}
|
|
711
|
+
// INVARIANT: sync-assets only writes/repairs the managed asset set + the
|
|
712
|
+
// managed symlinks and migrates known legacy paths (top-level skills/** with
|
|
713
|
+
// upstream-notes relocation). It NEVER deletes unrecognized user/tool
|
|
714
|
+
// content. In particular there is deliberately no sweep of `.agents/`: the
|
|
715
|
+
// `.claude`/`.codex` symlinks point at `.agents`, so agent CLIs write live
|
|
716
|
+
// project config there (settings.json, config.toml, commands/, hooks/, …).
|
|
717
|
+
// Unauthorized injected skills and symlinks under `.agents/skills/` are
|
|
718
|
+
// surfaced by verifyPromptAssets (the freshness gate) for a human to remove
|
|
719
|
+
// — they are flagged, never deleted here.
|
|
720
|
+
// 5. Manifest last so a crash mid-sync never records a fresh manifest.
|
|
721
|
+
let manifestChanged = false;
|
|
722
|
+
const manifestStat = findSymlinkAncestor(providerRoot, PROMPT_ASSET_MANIFEST_PATH) === undefined
|
|
723
|
+
? lstatSafe(manifestAbsPath)
|
|
724
|
+
: undefined;
|
|
725
|
+
if (!(manifestStat?.isFile() && readFileSync(manifestAbsPath, "utf8") === manifestContent)) {
|
|
726
|
+
if (manifestStat) {
|
|
727
|
+
rmSync(manifestAbsPath, { recursive: true, force: true });
|
|
728
|
+
}
|
|
729
|
+
ensureParentDirectory(providerRoot, PROMPT_ASSET_MANIFEST_PATH);
|
|
730
|
+
writeFileSync(manifestAbsPath, manifestContent);
|
|
731
|
+
manifestChanged = true;
|
|
732
|
+
}
|
|
733
|
+
return {
|
|
734
|
+
changed: manifestChanged ||
|
|
735
|
+
removed.length > 0 ||
|
|
736
|
+
wroteFiles.length > 0 ||
|
|
737
|
+
createdSymlinks.length > 0,
|
|
738
|
+
removed,
|
|
739
|
+
wroteFiles,
|
|
740
|
+
createdSymlinks,
|
|
741
|
+
manifestPath: PROMPT_ASSET_MANIFEST_PATH,
|
|
742
|
+
};
|
|
743
|
+
}
|