@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.
Files changed (45) hide show
  1. package/AUTHORING.md +53 -0
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +5 -1
  4. package/SUBMISSION.md +1 -1
  5. package/bin/apifuse-check.ts +26 -1
  6. package/bin/apifuse-pack-check.ts +14 -0
  7. package/bin/apifuse-submit-check.ts +193 -2
  8. package/bin/apifuse-sync-assets.ts +117 -0
  9. package/dist/cli/commands.d.ts +1 -1
  10. package/dist/cli/commands.js +8 -0
  11. package/dist/cli/create.d.ts +3 -0
  12. package/dist/cli/create.js +34 -35
  13. package/dist/cli/prompt-assets.d.ts +80 -0
  14. package/dist/cli/prompt-assets.js +743 -0
  15. package/dist/cli/templates/provider/AGENTS.md.tpl +17 -8
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +1 -0
  18. package/dist/runtime/executor.js +7 -0
  19. package/dist/runtime/secrets.d.ts +27 -0
  20. package/dist/runtime/secrets.js +51 -0
  21. package/dist/server/serve.d.ts +5 -0
  22. package/dist/server/serve.js +39 -0
  23. package/package.json +1 -1
  24. package/src/cli/commands.ts +10 -0
  25. package/src/cli/create.ts +42 -35
  26. package/src/cli/prompt-assets.ts +865 -0
  27. package/src/cli/templates/provider/AGENTS.md.tpl +17 -8
  28. package/src/index.ts +5 -0
  29. package/src/runtime/executor.ts +8 -0
  30. package/src/runtime/secrets.ts +64 -0
  31. package/src/server/serve.ts +53 -0
  32. package/dist/cli/templates/provider/CLAUDE.md.tpl +0 -1
  33. package/src/cli/templates/provider/CLAUDE.md.tpl +0 -1
  34. /package/dist/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  35. /package/dist/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  36. /package/dist/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  37. /package/dist/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  38. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  39. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
  40. /package/src/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  41. /package/src/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  42. /package/src/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  43. /package/src/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  44. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  45. /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
+ }