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