@compilr-dev/sdk 0.29.1 → 0.29.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,8 @@
7
7
  * File structure:
8
8
  * { "mcpServers": { "name": { command?, url?, ... } } }
9
9
  */
10
+ import { type ConfigValue } from './mcp/secrets.js';
11
+ export type { ConfigValue };
10
12
  /**
11
13
  * Single MCP server entry in the config file.
12
14
  * If `command` is present → stdio transport.
@@ -15,12 +17,28 @@
15
17
  export interface MCPServerEntry {
16
18
  command?: string;
17
19
  args?: string[];
18
- env?: Record<string, string>;
20
+ /**
21
+ * Passed to the process on start.
22
+ *
23
+ * ⚠️ A VALUE MAY BE A REFERENCE, NOT A STRING. `{ secret: 'mcp:github:GITHUB_TOKEN' }` means the
24
+ * real value lives in the host's encrypted store and never in this file — see `src/mcp/secrets`.
25
+ * Resolve with {@link resolveServerEntry} before handing anything to a server.
26
+ */
27
+ env?: Record<string, ConfigValue>;
19
28
  cwd?: string;
20
29
  url?: string;
21
- headers?: Record<string, string>;
30
+ /** Same reference rule as {@link MCPServerEntry.env}. */
31
+ headers?: Record<string, ConfigValue>;
22
32
  disabled?: boolean;
23
33
  timeout?: number;
34
+ /**
35
+ * Tools NOT described to the model, by the server's own name for them.
36
+ *
37
+ * ⚠️ AN OPT-OUT LIST, SO A SERVER THAT GAINS A TOOL HAS IT ON. An opt-in list would silently hide
38
+ * every tool added by a later release. Absent means all tools are on. The tools stay on the
39
+ * server either way — this only decides what reaches the model's context.
40
+ */
41
+ disabledTools?: string[];
24
42
  }
25
43
  /**
26
44
  * Shape of the mcp.json config file.
@@ -41,11 +59,52 @@ export interface ResolvedMCPServer {
41
59
  url?: string;
42
60
  headers?: Record<string, string>;
43
61
  timeout?: number;
62
+ /**
63
+ * Secret references that could not be resolved. Non-empty means DO NOT CONNECT — see
64
+ * {@link canConnect}. The variables are named so a host can say which one and where it lives.
65
+ */
66
+ missingSecrets?: {
67
+ variable: string;
68
+ ref: string;
69
+ }[];
44
70
  }
71
+ /**
72
+ * The outcome of reading an mcp.json, with "absent" kept distinct from "unreadable".
73
+ *
74
+ * ⚠️ THOSE TWO CANNOT SHARE AN ANSWER. Collapsing them to `{}` is what made adding a server to a
75
+ * hand-edited file DELETE the servers already in it — see {@link assertWritable}.
76
+ */
77
+ export interface MCPConfigRead {
78
+ servers: Record<string, MCPServerEntry>;
79
+ /** Present when the file exists but could not be parsed. The servers are then unknown, not none. */
80
+ error: string | null;
81
+ exists: boolean;
82
+ }
83
+ /**
84
+ * Read an mcp.json, saying which of the three things happened: absent, readable, or broken.
85
+ *
86
+ * Prefer this over {@link readMCPConfigFile} anywhere the result could lead to a WRITE.
87
+ */
88
+ export declare function readMCPConfig(filePath: string): MCPConfigRead;
45
89
  /**
46
90
  * Read and parse an mcp.json file. Returns empty record on any error.
91
+ *
92
+ * ⚠️ LENIENT, AND ONLY SAFE FOR READING. It cannot tell an absent file from a broken one. Use
93
+ * {@link readMCPConfig} when the answer might be written back.
47
94
  */
48
95
  export declare function readMCPConfigFile(filePath: string): Record<string, MCPServerEntry>;
96
+ /**
97
+ * Throw unless this file can be safely rewritten.
98
+ *
99
+ * ⚠️ THE DATA-LOSS BUG THIS EXISTS FOR, MEASURED. `readMCPConfigFile` returns `{}` for a file it
100
+ * cannot parse. `saveMCPServerEntry` then wrote `{}` plus the one new server — so a user whose
101
+ * mcp.json had a comment in it saw no servers in the app, added one, and **lost the other two**.
102
+ * Silence, then deletion.
103
+ *
104
+ * A file we cannot read is a file we must not overwrite. The host shows the reason and the user
105
+ * fixes their file; we do not guess at its contents.
106
+ */
107
+ export declare function assertWritable(filePath: string): Record<string, MCPServerEntry>;
49
108
  /**
50
109
  * Write servers to an mcp.json file (creates directory if needed).
51
110
  */
@@ -53,8 +112,19 @@ export declare function writeMCPConfigFile(filePath: string, servers: Record<str
53
112
  /**
54
113
  * Convert an MCPServerEntry to a ResolvedMCPServer.
55
114
  * Returns null if the entry is invalid (neither command nor url) or disabled.
115
+ *
116
+ * ⚠️ SECRET REFERENCES NEED `lookup`. Without one, a referenced value cannot be resolved and is
117
+ * reported in `missingSecrets` — it is never substituted with an empty string, because that turns
118
+ * a configuration problem into a 401 three layers away that nobody can trace back.
119
+ */
120
+ export declare function resolveServerEntry(name: string, entry: MCPServerEntry, lookup?: (ref: string) => string | null): ResolvedMCPServer | null;
121
+ /**
122
+ * Can this server be started without handing it a credential we could not find?
123
+ *
124
+ * Hosts check this before connecting. Connecting anyway produces an authentication failure that
125
+ * looks like a bad token rather than a missing one.
56
126
  */
57
- export declare function resolveServerEntry(name: string, entry: MCPServerEntry): ResolvedMCPServer | null;
127
+ export declare function canConnect(server: ResolvedMCPServer): boolean;
58
128
  /**
59
129
  * Load and resolve MCP servers from one or more config file paths.
60
130
  * Later paths override earlier ones (by server name).
@@ -9,26 +9,76 @@
9
9
  */
10
10
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
11
11
  import { dirname } from 'path';
12
- // =============================================================================
13
- // File I/O
14
- // =============================================================================
12
+ import { resolveValues } from './mcp/secrets.js';
15
13
  /**
16
- * Read and parse an mcp.json file. Returns empty record on any error.
14
+ * Read an mcp.json, saying which of the three things happened: absent, readable, or broken.
15
+ *
16
+ * Prefer this over {@link readMCPConfigFile} anywhere the result could lead to a WRITE.
17
17
  */
18
- export function readMCPConfigFile(filePath) {
18
+ export function readMCPConfig(filePath) {
19
+ if (!existsSync(filePath))
20
+ return { servers: {}, error: null, exists: false };
21
+ let data;
19
22
  try {
20
- if (!existsSync(filePath))
21
- return {};
22
- const data = readFileSync(filePath, 'utf-8');
23
- const parsed = JSON.parse(data);
24
- if (parsed.mcpServers && typeof parsed.mcpServers === 'object') {
25
- return parsed.mcpServers;
26
- }
27
- return {};
23
+ data = readFileSync(filePath, 'utf-8');
24
+ }
25
+ catch (err) {
26
+ return { servers: {}, exists: true, error: `could not be read: ${String(err)}` };
28
27
  }
29
- catch {
30
- return {};
28
+ /*
29
+ An empty or whitespace-only file is a normal state — an editor that saved nothing, or a file
30
+ someone cleared — and treating it as corrupt would block every future write.
31
+ */
32
+ if (data.trim() === '')
33
+ return { servers: {}, error: null, exists: true };
34
+ let parsed;
35
+ try {
36
+ parsed = JSON.parse(data);
37
+ }
38
+ catch (err) {
39
+ const detail = err instanceof Error ? err.message : String(err);
40
+ /*
41
+ Comments are the common cause and worth naming: mcp.json looks like a config file people may
42
+ annotate, and every editor that speaks JSONC encourages it. Strict JSON is what the format is,
43
+ so we refuse clearly instead of stripping them — stripping would drop them on the next write.
44
+ */
45
+ const hint = /^\s*\/\/|\n\s*\/\/|\/\*/.test(data)
46
+ ? ' It looks like it contains comments; mcp.json must be strict JSON.'
47
+ : '';
48
+ return { servers: {}, exists: true, error: `is not valid JSON (${detail}).${hint}` };
31
49
  }
50
+ if (!parsed.mcpServers || typeof parsed.mcpServers !== 'object') {
51
+ // A valid JSON document with no `mcpServers` key is empty, not broken.
52
+ return { servers: {}, error: null, exists: true };
53
+ }
54
+ return { servers: parsed.mcpServers, error: null, exists: true };
55
+ }
56
+ /**
57
+ * Read and parse an mcp.json file. Returns empty record on any error.
58
+ *
59
+ * ⚠️ LENIENT, AND ONLY SAFE FOR READING. It cannot tell an absent file from a broken one. Use
60
+ * {@link readMCPConfig} when the answer might be written back.
61
+ */
62
+ export function readMCPConfigFile(filePath) {
63
+ return readMCPConfig(filePath).servers;
64
+ }
65
+ /**
66
+ * Throw unless this file can be safely rewritten.
67
+ *
68
+ * ⚠️ THE DATA-LOSS BUG THIS EXISTS FOR, MEASURED. `readMCPConfigFile` returns `{}` for a file it
69
+ * cannot parse. `saveMCPServerEntry` then wrote `{}` plus the one new server — so a user whose
70
+ * mcp.json had a comment in it saw no servers in the app, added one, and **lost the other two**.
71
+ * Silence, then deletion.
72
+ *
73
+ * A file we cannot read is a file we must not overwrite. The host shows the reason and the user
74
+ * fixes their file; we do not guess at its contents.
75
+ */
76
+ export function assertWritable(filePath) {
77
+ const read = readMCPConfig(filePath);
78
+ if (read.error !== null) {
79
+ throw new Error(`${filePath} ${read.error} Refusing to write, because saving would replace servers that are still in the file. Fix the file and try again.`);
80
+ }
81
+ return read.servers;
32
82
  }
33
83
  /**
34
84
  * Write servers to an mcp.json file (creates directory if needed).
@@ -47,32 +97,49 @@ export function writeMCPConfigFile(filePath, servers) {
47
97
  /**
48
98
  * Convert an MCPServerEntry to a ResolvedMCPServer.
49
99
  * Returns null if the entry is invalid (neither command nor url) or disabled.
100
+ *
101
+ * ⚠️ SECRET REFERENCES NEED `lookup`. Without one, a referenced value cannot be resolved and is
102
+ * reported in `missingSecrets` — it is never substituted with an empty string, because that turns
103
+ * a configuration problem into a 401 three layers away that nobody can trace back.
50
104
  */
51
- export function resolveServerEntry(name, entry) {
105
+ export function resolveServerEntry(name, entry, lookup = () => null) {
52
106
  if (entry.disabled)
53
107
  return null;
54
108
  if (entry.command) {
109
+ const env = resolveValues(entry.env, lookup);
55
110
  return {
56
111
  name,
57
112
  transport: 'stdio',
58
113
  command: entry.command,
59
114
  args: entry.args,
60
- env: entry.env,
115
+ env: Object.keys(env.values).length > 0 ? env.values : undefined,
61
116
  cwd: entry.cwd,
62
117
  timeout: entry.timeout,
118
+ missingSecrets: env.missing,
63
119
  };
64
120
  }
65
121
  if (entry.url) {
122
+ const headers = resolveValues(entry.headers, lookup);
66
123
  return {
67
124
  name,
68
125
  transport: 'http',
69
126
  url: entry.url,
70
- headers: entry.headers,
127
+ headers: Object.keys(headers.values).length > 0 ? headers.values : undefined,
71
128
  timeout: entry.timeout,
129
+ missingSecrets: headers.missing,
72
130
  };
73
131
  }
74
132
  return null;
75
133
  }
134
+ /**
135
+ * Can this server be started without handing it a credential we could not find?
136
+ *
137
+ * Hosts check this before connecting. Connecting anyway produces an authentication failure that
138
+ * looks like a bad token rather than a missing one.
139
+ */
140
+ export function canConnect(server) {
141
+ return (server.missingSecrets ?? []).length === 0;
142
+ }
76
143
  // =============================================================================
77
144
  // High-level helpers
78
145
  // =============================================================================
@@ -98,7 +165,7 @@ export function loadMCPServers(...configPaths) {
98
165
  * Save a single MCP server entry to a config file (add or overwrite by name).
99
166
  */
100
167
  export function saveMCPServerEntry(filePath, name, entry) {
101
- const servers = readMCPConfigFile(filePath);
168
+ const servers = assertWritable(filePath);
102
169
  servers[name] = entry;
103
170
  writeMCPConfigFile(filePath, servers);
104
171
  }
@@ -106,7 +173,7 @@ export function saveMCPServerEntry(filePath, name, entry) {
106
173
  * Delete a single MCP server entry from a config file.
107
174
  */
108
175
  export function deleteMCPServerEntry(filePath, name) {
109
- const servers = readMCPConfigFile(filePath);
176
+ const servers = assertWritable(filePath);
110
177
  const { [name]: _, ...rest } = servers;
111
178
  writeMCPConfigFile(filePath, rest);
112
179
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Platform Skills — barrel export
3
3
  */
4
- export type { CustomSkill, CompilrSkillExtension, ForkedFromMarker } from './types.js';
4
+ export type { CustomSkill, CompilrSkillExtension, ForkedFromMarker, InstalledFromMarker, } from './types.js';
5
5
  export { RESERVED_MACRO_NAMES, isReservedMacroName } from './types.js';
6
6
  export { parseSkillMarkdown, loadSkillsFromDir, loadInstalledSkills } from './loader.js';
7
7
  export type { SkillEligibilityContext } from './resolver.js';
@@ -26,4 +26,6 @@ export type { SkillReachability } from './reachability.js';
26
26
  export { patchSkillFrontmatter } from './patch.js';
27
27
  export type { FrontmatterPatch } from './patch.js';
28
28
  export { readSkillFolder, unreadSkillFiles } from './folder.js';
29
+ export { findInstallableSkills, normaliseSkillName, resolveInstallName, planInstall, describeInstallPlan, buildInstalledFromPatch, } from './install.js';
30
+ export type { InstallCandidate, InstallPlanItem } from './install.js';
29
31
  export type { SkillFolderEntry } from './folder.js';
@@ -12,3 +12,4 @@ export { buildMacroInvocation } from './macro-invocation.js';
12
12
  export { skillReachability, isUnreachable } from './reachability.js';
13
13
  export { patchSkillFrontmatter } from './patch.js';
14
14
  export { readSkillFolder, unreadSkillFiles } from './folder.js';
15
+ export { findInstallableSkills, normaliseSkillName, resolveInstallName, planInstall, describeInstallPlan, buildInstalledFromPatch, } from './install.js';
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Installing a skill someone else wrote.
3
+ *
4
+ * ⚠️ COPY, NEVER LINK. Skills load from exactly two directories, so *where the folder sits is its
5
+ * scope*. A reference would have no scope and would break the moment the source moved. A copy also
6
+ * means the edits are the user's own — which is the whole point of installing rather than reading.
7
+ *
8
+ * ⚠️ NOTHING AUTO-UPDATES, and that is deliberate. A copy has no link home. We record where it came
9
+ * from so a host can *tell* the user the source changed; we never reach back and change their file.
10
+ *
11
+ * Everything here is planned before a single byte is written. A source may hold dozens of skills
12
+ * (anthropics/skills does), two of them may want the same name, and a name may already be taken in
13
+ * the destination — the user sees every one of those resolutions *in advance*, as a name they can
14
+ * read, with the option to cancel. Silent resolution is the failure this module exists to avoid.
15
+ */
16
+ import type { FrontmatterPatch } from './patch.js';
17
+ import type { InstalledFromMarker } from './types.js';
18
+ export interface InstallCandidate {
19
+ /** Folder name at the source. Not necessarily a legal skill name. */
20
+ folderName: string;
21
+ /** Absolute path of the folder holding SKILL.md. */
22
+ sourceDir: string;
23
+ /** From the file, so the selection list reads like the catalogue it will join. */
24
+ name: string;
25
+ description: string;
26
+ bodyLines: number;
27
+ /** Files beyond SKILL.md that will be copied along. */
28
+ extraFiles: number;
29
+ /**
30
+ * ⚠️ WE READ FRONTMATTER AND BODY ONLY. A skill that points at `references/` half-works and says
31
+ * nothing about it, so the count travels with the candidate and the host can warn before the copy
32
+ * rather than leaving it to a confused agent.
33
+ */
34
+ unreadFiles: number;
35
+ }
36
+ /**
37
+ * Every installable skill under `root`, depth-first to `maxDepth`.
38
+ *
39
+ * A folder holding SKILL.md IS a skill, so we never descend into one — its `references/` cannot
40
+ * contain skills, and a nested SKILL.md there would be part of the parent, not a sibling.
41
+ *
42
+ * Unreadable or unparseable folders are skipped rather than thrown: a source directory is someone
43
+ * else's, and one bad file must not make the other forty uninstallable.
44
+ */
45
+ export declare function findInstallableSkills(root: string, maxDepth?: number): InstallCandidate[];
46
+ /**
47
+ * A folder name turned into a legal skill name: lowercase, digits and hyphens, starting with a
48
+ * letter. Returns null when nothing legal survives, because inventing a name for someone else's
49
+ * skill is worse than refusing it.
50
+ */
51
+ export declare function normaliseSkillName(folderName: string): string | null;
52
+ /**
53
+ * The name this skill will actually land as, given what is already there.
54
+ *
55
+ * `pdf` when free, otherwise `pdf-2`, `pdf-3`… The suffix is plain and always appended, so the
56
+ * result is predictable from the inputs — and it is shown to the user before anything is written,
57
+ * which is what makes an ugly-but-honest name better than a clever one.
58
+ */
59
+ export declare function resolveInstallName(desired: string, taken: Iterable<string>): string;
60
+ export interface InstallPlanItem {
61
+ candidate: InstallCandidate;
62
+ /** The folder name it will be written as. */
63
+ finalName: string;
64
+ /** Set when the source folder name was not a legal skill name. */
65
+ renamedFrom: string | null;
66
+ /** Set when `finalName` differs from the wanted name because something already holds it. */
67
+ collidedWith: string | null;
68
+ /** Why it cannot be installed at all. When set, nothing about this item is written. */
69
+ blocked: string | null;
70
+ /** The built-in name it would have shadowed, which is why it was renamed. */
71
+ shadowedBuiltin: string | null;
72
+ }
73
+ /**
74
+ * What installing this selection would do, resolved in full before anything is written.
75
+ *
76
+ * ⚠️ COLLISIONS ACCUMULATE WITHIN THE BATCH. Two sources both called `pdf` collide with each other,
77
+ * not just with the destination — so each resolved name joins the taken set as it is decided.
78
+ * Getting this wrong means the second copy silently overwrites the first.
79
+ */
80
+ export declare function planInstall(candidates: InstallCandidate[], existingNames: Iterable<string>): InstallPlanItem[];
81
+ /**
82
+ * One sentence per resolution, for showing the user before they commit.
83
+ *
84
+ * Returns [] when nothing needs saying — which is the common case, and must not render as an empty
85
+ * warning box.
86
+ */
87
+ export declare function describeInstallPlan(plan: InstallPlanItem[]): string[];
88
+ /**
89
+ * The frontmatter edit that records where a skill came from.
90
+ *
91
+ * ⚠️ A PATCH, NOT A REWRITE (D-2). This runs on a file someone else wrote — their comments, key
92
+ * order and fields we have never modelled all have to survive being installed, or we have silently
93
+ * edited a stranger's skill on its way in.
94
+ */
95
+ export declare function buildInstalledFromPatch(marker: InstalledFromMarker): FrontmatterPatch;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Installing a skill someone else wrote.
3
+ *
4
+ * ⚠️ COPY, NEVER LINK. Skills load from exactly two directories, so *where the folder sits is its
5
+ * scope*. A reference would have no scope and would break the moment the source moved. A copy also
6
+ * means the edits are the user's own — which is the whole point of installing rather than reading.
7
+ *
8
+ * ⚠️ NOTHING AUTO-UPDATES, and that is deliberate. A copy has no link home. We record where it came
9
+ * from so a host can *tell* the user the source changed; we never reach back and change their file.
10
+ *
11
+ * Everything here is planned before a single byte is written. A source may hold dozens of skills
12
+ * (anthropics/skills does), two of them may want the same name, and a name may already be taken in
13
+ * the destination — the user sees every one of those resolutions *in advance*, as a name they can
14
+ * read, with the option to cancel. Silent resolution is the failure this module exists to avoid.
15
+ */
16
+ import { readdirSync, statSync, readFileSync } from 'node:fs';
17
+ import { join, basename } from 'node:path';
18
+ import { isValidSkillName } from './paths.js';
19
+ import { isReservedMacroName } from './types.js';
20
+ import { parseSkillMarkdown } from './loader.js';
21
+ import { readSkillFolder } from './folder.js';
22
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.github', 'dist', 'build', '__pycache__']);
23
+ /**
24
+ * Every installable skill under `root`, depth-first to `maxDepth`.
25
+ *
26
+ * A folder holding SKILL.md IS a skill, so we never descend into one — its `references/` cannot
27
+ * contain skills, and a nested SKILL.md there would be part of the parent, not a sibling.
28
+ *
29
+ * Unreadable or unparseable folders are skipped rather than thrown: a source directory is someone
30
+ * else's, and one bad file must not make the other forty uninstallable.
31
+ */
32
+ export function findInstallableSkills(root, maxDepth = 3) {
33
+ const out = [];
34
+ const visit = (dir, depth) => {
35
+ let entries;
36
+ try {
37
+ entries = readdirSync(dir);
38
+ }
39
+ catch {
40
+ return;
41
+ }
42
+ if (entries.includes('SKILL.md')) {
43
+ const candidate = describeCandidate(dir);
44
+ if (candidate)
45
+ out.push(candidate);
46
+ return; // a skill is a leaf
47
+ }
48
+ if (depth >= maxDepth)
49
+ return;
50
+ for (const name of entries.sort()) {
51
+ if (SKIP_DIRS.has(name) || name.startsWith('.'))
52
+ continue;
53
+ try {
54
+ if (statSync(join(dir, name)).isDirectory())
55
+ visit(join(dir, name), depth + 1);
56
+ }
57
+ catch {
58
+ /* unreadable entry — skip it, not the whole source */
59
+ }
60
+ }
61
+ };
62
+ visit(root, 0);
63
+ return out;
64
+ }
65
+ function describeCandidate(dir) {
66
+ let content;
67
+ try {
68
+ content = readFileSync(join(dir, 'SKILL.md'), 'utf-8');
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ const parsed = parseSkillMarkdown(content, dir);
74
+ if (!parsed)
75
+ return null;
76
+ const body = parsed.prompt.trim();
77
+ return {
78
+ folderName: basename(dir),
79
+ sourceDir: dir,
80
+ name: parsed.name,
81
+ description: parsed.description,
82
+ bodyLines: body === '' ? 0 : body.split('\n').length,
83
+ extraFiles: readSkillFolder(dir).length,
84
+ unreadFiles: readSkillFolder(dir).filter((f) => f.kind === 'reference' || f.kind === 'script')
85
+ .length,
86
+ };
87
+ }
88
+ /**
89
+ * A folder name turned into a legal skill name: lowercase, digits and hyphens, starting with a
90
+ * letter. Returns null when nothing legal survives, because inventing a name for someone else's
91
+ * skill is worse than refusing it.
92
+ */
93
+ export function normaliseSkillName(folderName) {
94
+ const slug = folderName
95
+ .toLowerCase()
96
+ .replace(/[^a-z0-9]+/g, '-')
97
+ .replace(/^-+|-+$/g, '')
98
+ .slice(0, 64)
99
+ .replace(/-+$/, '');
100
+ return isValidSkillName(slug) ? slug : null;
101
+ }
102
+ /**
103
+ * The name this skill will actually land as, given what is already there.
104
+ *
105
+ * `pdf` when free, otherwise `pdf-2`, `pdf-3`… The suffix is plain and always appended, so the
106
+ * result is predictable from the inputs — and it is shown to the user before anything is written,
107
+ * which is what makes an ugly-but-honest name better than a clever one.
108
+ */
109
+ export function resolveInstallName(desired, taken) {
110
+ const used = new Set(taken);
111
+ if (!used.has(desired))
112
+ return desired;
113
+ for (let n = 2; n < 1000; n++) {
114
+ const next = `${desired}-${String(n)}`;
115
+ if (!used.has(next))
116
+ return next;
117
+ }
118
+ return `${desired}-${String(Date.now())}`;
119
+ }
120
+ /**
121
+ * What installing this selection would do, resolved in full before anything is written.
122
+ *
123
+ * ⚠️ COLLISIONS ACCUMULATE WITHIN THE BATCH. Two sources both called `pdf` collide with each other,
124
+ * not just with the destination — so each resolved name joins the taken set as it is decided.
125
+ * Getting this wrong means the second copy silently overwrites the first.
126
+ */
127
+ export function planInstall(candidates, existingNames) {
128
+ const taken = new Set(existingNames);
129
+ return candidates.map((candidate) => {
130
+ const wanted = normaliseSkillName(candidate.folderName);
131
+ if (wanted === null) {
132
+ return {
133
+ candidate,
134
+ finalName: candidate.folderName,
135
+ renamedFrom: null,
136
+ collidedWith: null,
137
+ blocked: `"${candidate.folderName}" cannot become a skill name — rename the folder and try again.`,
138
+ shadowedBuiltin: null,
139
+ };
140
+ }
141
+ /*
142
+ ⚠️ A RESERVED NAME IS NOT A FREE NAME. Project and user scope outrank `sdk` in
143
+ resolveSkillPrompt, so installing someone's `design` would silently become /design in both
144
+ hosts and the collision detector would stay quiet — the same defect `skills:create` already
145
+ refuses. Renaming rather than blocking: the user still gets the skill, it just cannot
146
+ impersonate a built-in.
147
+ */
148
+ if (isReservedMacroName(wanted)) {
149
+ const renamed = resolveInstallName(`${wanted}-installed`, taken);
150
+ taken.add(renamed);
151
+ return {
152
+ candidate,
153
+ finalName: renamed,
154
+ renamedFrom: candidate.folderName === renamed ? null : candidate.folderName,
155
+ collidedWith: wanted,
156
+ blocked: null,
157
+ shadowedBuiltin: wanted,
158
+ };
159
+ }
160
+ const finalName = resolveInstallName(wanted, taken);
161
+ taken.add(finalName);
162
+ return {
163
+ candidate,
164
+ finalName,
165
+ renamedFrom: wanted === candidate.folderName ? null : candidate.folderName,
166
+ collidedWith: finalName === wanted ? null : wanted,
167
+ blocked: null,
168
+ shadowedBuiltin: null,
169
+ };
170
+ });
171
+ }
172
+ /**
173
+ * One sentence per resolution, for showing the user before they commit.
174
+ *
175
+ * Returns [] when nothing needs saying — which is the common case, and must not render as an empty
176
+ * warning box.
177
+ */
178
+ export function describeInstallPlan(plan) {
179
+ const out = [];
180
+ for (const item of plan) {
181
+ if (item.blocked) {
182
+ out.push(item.blocked);
183
+ }
184
+ else if (item.shadowedBuiltin) {
185
+ out.push(`${item.shadowedBuiltin} is a built-in — installing it under that name would silently replace it everywhere, so this will land as ${item.finalName}.`);
186
+ }
187
+ else if (item.collidedWith && item.renamedFrom) {
188
+ out.push(`"${item.renamedFrom}" is not a legal skill name and ${item.collidedWith} is already here — it will land as ${item.finalName}.`);
189
+ }
190
+ else if (item.collidedWith) {
191
+ out.push(`A skill called ${item.collidedWith} already exists here — this will land as ${item.finalName}.`);
192
+ }
193
+ else if (item.renamedFrom) {
194
+ out.push(`"${item.renamedFrom}" is not a legal skill name — it will land as ${item.finalName}.`);
195
+ }
196
+ }
197
+ return out;
198
+ }
199
+ /**
200
+ * The frontmatter edit that records where a skill came from.
201
+ *
202
+ * ⚠️ A PATCH, NOT A REWRITE (D-2). This runs on a file someone else wrote — their comments, key
203
+ * order and fields we have never modelled all have to survive being installed, or we have silently
204
+ * edited a stranger's skill on its way in.
205
+ */
206
+ export function buildInstalledFromPatch(marker) {
207
+ return {
208
+ installedFrom: {
209
+ source: marker.source,
210
+ skill: marker.skill,
211
+ installedAt: marker.installedAt,
212
+ },
213
+ };
214
+ }
@@ -93,6 +93,9 @@ export function parseSkillMarkdown(content, sourcePath, source) {
93
93
  if (meta['forkedFrom'] && typeof meta['forkedFrom'] === 'object') {
94
94
  skill.forkedFrom = meta['forkedFrom'];
95
95
  }
96
+ if (meta['installedFrom'] && typeof meta['installedFrom'] === 'object') {
97
+ skill.installedFrom = meta['installedFrom'];
98
+ }
96
99
  return skill;
97
100
  }
98
101
  /**
@@ -14,8 +14,19 @@
14
14
  *
15
15
  * Decision D-2: project-docs/.../compilr-dev-skills/implementation-plan.md
16
16
  */
17
- /** `undefined` leaves a key alone; `null` removes it; anything else sets it. */
18
- export type FrontmatterPatch = Record<string, string | number | boolean | null | undefined>;
17
+ /**
18
+ * `undefined` leaves a key alone; `null` removes it; anything else sets it.
19
+ *
20
+ * A plain object becomes a nested block — the shape `forkedFrom` already has on disk:
21
+ *
22
+ * installedFrom:
23
+ * source: "/home/me/skills"
24
+ * skill: "pdf"
25
+ *
26
+ * ⚠️ NOT A ONE-LINE FLOW MAPPING. `{ a: 1 }` passed as a string would sail past the 80-character
27
+ * threshold below, come back out as a block scalar, and parse as a STRING that reads like a map.
28
+ */
29
+ export type FrontmatterPatch = Record<string, string | number | boolean | Record<string, string> | null | undefined>;
19
30
  /**
20
31
  * Apply `patch` to `content`'s frontmatter, leaving everything else byte-identical.
21
32
  *
@@ -51,7 +51,17 @@ function keyBlockEnd(lines, start) {
51
51
  end--;
52
52
  return end;
53
53
  }
54
+ /** YAML double-quoted scalar. Quoted always, so a value starting with `/` or `{` stays a string. */
55
+ function quote(value) {
56
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
57
+ }
54
58
  function render(key, value) {
59
+ if (typeof value === 'object') {
60
+ const body = Object.entries(value)
61
+ .map(([k, v]) => ` ${k}: ${quote(v)}`)
62
+ .join('\n');
63
+ return body === '' ? `${key}: {}` : `${key}:\n${body}`;
64
+ }
55
65
  if (typeof value === 'string' && (value.includes('\n') || value.length > 80)) {
56
66
  const body = value
57
67
  .split('\n')