@compilr-dev/sdk 0.29.1 → 0.29.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -67,7 +67,7 @@ export type { GuideEntry, ContentTopic, ContentSection, GuideToolConfig } from '
67
67
  export { createPlatformTools, createProjectTools, createWorkItemTools, createDocumentTools, createPlanTools, createBacklogTools, createAnchorTools, createArtifactTools, createEpisodeTools, createCanvasTools, createImageTools, ProjectAnchorStore, FileArtifactService, } from './platform/index.js';
68
68
  export type { ProjectAnchorStoreConfig, FileArtifactServiceConfig, ImageToolsConfig, ImageResizer, } from './platform/index.js';
69
69
  export { STEP_ORDER, GUIDED_STEP_CRITERIA, getNextStep, isValidTransition, getStepCriteria, formatStepDisplay, getStepNumber, } from './platform/index.js';
70
- export type { CustomSkill, CompilrSkillExtension, ForkedFromMarker, SkillEligibilityContext, SkillCollision, SkillDiffLine, SkillValidationIssue, ScopeConfig, SkillResolution, } from './skills/index.js';
70
+ export type { CustomSkill, CompilrSkillExtension, ForkedFromMarker, InstalledFromMarker, SkillEligibilityContext, SkillCollision, SkillDiffLine, SkillValidationIssue, ScopeConfig, SkillResolution, } from './skills/index.js';
71
71
  export { RESERVED_MACRO_NAMES, isReservedMacroName, parseSkillMarkdown, loadSkillsFromDir, loadInstalledSkills, resolveLayeredSkills, resolveSkillsForAgent, resolveSkillsForTeamAgent, detectCollisions, formatCollisionWarnings, diffForkVsUpstream, buildForkContent, buildNewSkillContent, validateSkill as validateSkillQuality, getSkillsDir, getSkillFolder, getSkillFile, ensureSkillsDir, isValidSkillName, getScopeConfigPath, readSkillScopeConfig, readSkillScopeConfigSync, writeSkillScopeConfig, getSkillBindings, resolveSkillBinding, } from './skills/index.js';
72
72
  export type { SkillScope, SkillPromptResolution, AvailableSkillEntry, SkillSources, } from './skills/index.js';
73
73
  export { resolveSkillPrompt, getAllAvailableSkills } from './skills/index.js';
@@ -77,6 +77,8 @@ export { skillReachability, isUnreachable } from './skills/index.js';
77
77
  export { patchSkillFrontmatter } from './skills/index.js';
78
78
  export { isPlaceholderDescription } from './skills/index.js';
79
79
  export { readSkillFolder, unreadSkillFiles } from './skills/index.js';
80
+ export { findInstallableSkills, normaliseSkillName, resolveInstallName, planInstall, describeInstallPlan, buildInstalledFromPatch, } from './skills/index.js';
81
+ export type { InstallCandidate, InstallPlanItem } from './skills/index.js';
80
82
  export type { SkillFolderEntry } from './skills/index.js';
81
83
  export type { FrontmatterPatch } from './skills/index.js';
82
84
  export type { SkillReachability } from './skills/index.js';
package/dist/index.js CHANGED
@@ -160,6 +160,7 @@ export { skillReachability, isUnreachable } from './skills/index.js';
160
160
  export { patchSkillFrontmatter } from './skills/index.js';
161
161
  export { isPlaceholderDescription } from './skills/index.js';
162
162
  export { readSkillFolder, unreadSkillFiles } from './skills/index.js';
163
+ export { findInstallableSkills, normaliseSkillName, resolveInstallName, planInstall, describeInstallPlan, buildInstalledFromPatch, } from './skills/index.js';
163
164
  export { generateSkillCatalog, toCatalogEntry, createLoadSkillTool } from './skills/index.js';
164
165
  export { platformMacros, designMacro, sketchMacro, prdMacro, refineMacro, refineItemMacro, architectureMacro, sessionNotesMacro, buildMacro, scaffoldMacro, outlineMacro, literatureReviewMacro, draftSectionMacro, peerReviewMacro, researchScaffoldMacro, businessVisionMacro, marketAnalysisMacro, competitorAnalysisMacro, financialModelMacro, pitchOutlineMacro, businessReviewMacro, brandSetupMacro, contentStrategyMacro, contentCalendarMacro, createContentMacro, contentReviewMacro, curriculumDesignMacro, lessonPlanMacro, assessmentDesignMacro, courseReviewMacro, bookOutlineMacro, characterDesignMacro, plotThreadsMacro, sceneBreakdownMacro, bookReviewMacro, } from './skills/index.js';
165
166
  // =============================================================================
@@ -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')
@@ -45,6 +45,17 @@ export interface ForkedFromMarker {
45
45
  version: string;
46
46
  forkedAt: string;
47
47
  }
48
+ /**
49
+ * Where an installed skill came from. Recorded on the way in, never used to fetch: nothing
50
+ * auto-updates, so this exists so a host can TELL the user the source moved on.
51
+ */
52
+ export interface InstalledFromMarker {
53
+ /** Verbatim: the folder path or repo URL the user gave. */
54
+ source: string;
55
+ /** The folder name at the source, which differs from the installed name after a collision. */
56
+ skill: string;
57
+ installedAt: string;
58
+ }
48
59
  /**
49
60
  * Custom skill — matches Anthropic spec + compilr extensions.
50
61
  *
@@ -96,6 +107,8 @@ export interface CustomSkill {
96
107
  compilr?: CompilrSkillExtension;
97
108
  /** Set when this skill was created via /skill fork. */
98
109
  forkedFrom?: ForkedFromMarker;
110
+ /** Present only on skills installed from someone else's folder or repo. */
111
+ installedFrom?: InstalledFromMarker;
99
112
  /** Absolute path to the skill directory (containing SKILL.md). */
100
113
  sourcePath?: string;
101
114
  /** Where this skill came from. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compilr-dev/sdk",
3
- "version": "0.29.1",
3
+ "version": "0.29.2",
4
4
  "description": "Universal agent runtime for building AI-powered applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",