@klhapp/skillmux 1.11.2 → 1.13.0

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 (104) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +20 -10
  3. package/bin/skillmux.js +89 -0
  4. package/docs/README.md +4 -4
  5. package/docs/cli.md +15 -5
  6. package/docs/concepts.md +4 -3
  7. package/docs/configuration.md +22 -5
  8. package/docs/deployment.md +3 -3
  9. package/docs/getting-started.md +32 -7
  10. package/docs/releasing.md +17 -2
  11. package/docs/sdd/cli-remote-target-parity/schema-bucket-b.yaml +297 -0
  12. package/docs/sdd/cli-remote-target-parity/spec-bucket-b.md +95 -0
  13. package/docs/sdd/cli-remote-target-parity/spec.md +111 -0
  14. package/docs/sdd/cli-remote-target-parity/think.md +183 -0
  15. package/docs/sdd/cli-surface-consistency/think.md +149 -0
  16. package/docs/sdd/config-authority-rerank-resilience/plan.md +338 -0
  17. package/docs/sdd/config-authority-rerank-resilience/review.md +124 -0
  18. package/docs/sdd/config-authority-rerank-resilience/spec.md +78 -0
  19. package/docs/sdd/feat-packaging-deployment/review.md +95 -0
  20. package/docs/sdd/fetch-outcome-flywheel/review.md +103 -0
  21. package/docs/sdd/fetch-outcome-flywheel/schema.json +347 -0
  22. package/docs/sdd/fetch-outcome-flywheel/spec.md +185 -0
  23. package/docs/sdd/npm-native-binaries/review.md +103 -0
  24. package/docs/sdd/npm-native-binaries/spec.md +148 -0
  25. package/docs/sdd/ranked-shortlist-2/pr3-calibration-removal.md +56 -0
  26. package/docs/sdd/refactor-builtin-target-paths/spec.md +41 -0
  27. package/docs/sdd/runtime-resource-hardening/review.md +86 -0
  28. package/docs/sdd/runtime-resource-hardening/spec.md +43 -0
  29. package/docs/sdd/security-hardening-phase/think.md +129 -0
  30. package/docs/sdd/security-observability/review.md +96 -0
  31. package/docs/sdd/security-observability/spec.md +55 -0
  32. package/docs/sdd/skill-provenance-update/review.md +152 -0
  33. package/docs/sdd/skill-provenance-update/schema.json +193 -0
  34. package/docs/sdd/skill-provenance-update/spec.md +134 -0
  35. package/docs/sdd/supply-chain-hardening/review.md +50 -0
  36. package/docs/sdd/supply-chain-hardening/spec.md +42 -0
  37. package/docs/sdd/target-marker-rehome/spec.md +33 -0
  38. package/docs/skill-management.md +1 -1
  39. package/docs/troubleshooting.md +18 -2
  40. package/package.json +15 -8
  41. package/src/adapters.ts +0 -438
  42. package/src/audit.ts +0 -21
  43. package/src/cli.ts +0 -928
  44. package/src/clients.ts +0 -368
  45. package/src/commands/audit.ts +0 -85
  46. package/src/commands/config.ts +0 -224
  47. package/src/commands/context.ts +0 -104
  48. package/src/commands/core.ts +0 -56
  49. package/src/commands/doctor.ts +0 -97
  50. package/src/commands/eval.ts +0 -88
  51. package/src/commands/init.ts +0 -672
  52. package/src/commands/install.ts +0 -155
  53. package/src/commands/local-vault.ts +0 -60
  54. package/src/commands/models.ts +0 -10
  55. package/src/commands/outdated.ts +0 -115
  56. package/src/commands/project.ts +0 -580
  57. package/src/commands/report.ts +0 -66
  58. package/src/commands/scan.ts +0 -71
  59. package/src/commands/shared.ts +0 -38
  60. package/src/commands/skill.ts +0 -33
  61. package/src/commands/sync.ts +0 -233
  62. package/src/commands/target.ts +0 -225
  63. package/src/commands/update.ts +0 -277
  64. package/src/completions.ts +0 -196
  65. package/src/concurrency-limiter.ts +0 -61
  66. package/src/config-service.ts +0 -398
  67. package/src/config-watcher.ts +0 -253
  68. package/src/config.ts +0 -583
  69. package/src/context.ts +0 -188
  70. package/src/db-audit.ts +0 -286
  71. package/src/db-index.ts +0 -238
  72. package/src/db.ts +0 -3
  73. package/src/deployment.ts +0 -39
  74. package/src/doctor.ts +0 -205
  75. package/src/eval.ts +0 -302
  76. package/src/global-flags.ts +0 -46
  77. package/src/init-agents.ts +0 -329
  78. package/src/init-instructions.ts +0 -192
  79. package/src/init.ts +0 -389
  80. package/src/install.ts +0 -232
  81. package/src/lifecycle.ts +0 -51
  82. package/src/logger.ts +0 -26
  83. package/src/manifest.ts +0 -352
  84. package/src/mcp-registration.ts +0 -89
  85. package/src/metrics.ts +0 -121
  86. package/src/models.ts +0 -20
  87. package/src/output.ts +0 -214
  88. package/src/project-setup.ts +0 -36
  89. package/src/prompts.ts +0 -124
  90. package/src/provenance.ts +0 -99
  91. package/src/rate-limiter.ts +0 -137
  92. package/src/readiness.ts +0 -30
  93. package/src/redact.ts +0 -52
  94. package/src/router-core.ts +0 -666
  95. package/src/rrf.ts +0 -31
  96. package/src/scan.ts +0 -300
  97. package/src/server.ts +0 -930
  98. package/src/setup.ts +0 -145
  99. package/src/snapshot.ts +0 -135
  100. package/src/stats.ts +0 -340
  101. package/src/sync.ts +0 -482
  102. package/src/toml-writer.ts +0 -51
  103. package/src/types.ts +0 -212
  104. package/src/vault.ts +0 -207
package/src/install.ts DELETED
@@ -1,232 +0,0 @@
1
- import { cpSync, existsSync, lstatSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { basename, dirname, join, relative } from "node:path";
4
- import { type ScanFinding, readTextFileOrNull, scanContent } from "./scan";
5
- import { decodeUtf8Strict, listSupportingFiles, parseSkillMd } from "./vault";
6
-
7
- export interface RepoSource {
8
- url: string;
9
- skillPath?: string;
10
- }
11
-
12
- const GIT_URL_PREFIXES = ["http://", "https://", "git://", "ssh://", "file://"];
13
- const SCP_LIKE_URL_PATTERN = /^[^/\s]+@[^/\s]+:/;
14
-
15
- export function isGitUrl(repo: string): boolean {
16
- if (GIT_URL_PREFIXES.some((prefix) => repo.startsWith(prefix))) return true;
17
- // scp-like syntax (user@host:path) has no URL scheme, so whatever accepts it here
18
- // hands the raw string to `git clone`/`git ls-remote` as a bare positional argument.
19
- // If that string starts with `-`, git's own option parser reads it as a flag, not a
20
- // repository — verified against git 2.55: `--upload-pack=<cmd>@host:path` makes git
21
- // run `<cmd>` as a real local shell command instead of contacting a remote. Reject
22
- // it outright rather than let a crafted string reach that argv slot.
23
- return SCP_LIKE_URL_PATTERN.test(repo) && !repo.startsWith("-");
24
- }
25
-
26
- /** A `file://` source_url reaches the local filesystem directly, not just a network
27
- * remote. That's fine when the user typed it themselves at `skillmux install` time,
28
- * but a `.skillmux-origin` sidecar is vault content — readable and writable by
29
- * whatever populated the vault (a shared git-backed vault pulled in, or a hand-edit),
30
- * same threat model as every other vault-content read this codebase guards. `skillmux
31
- * outdated`/`update` must not blindly git-clone/ls-remote whatever local path a
32
- * forged sidecar names. */
33
- export function isLocalFileUrl(url: string): boolean {
34
- return url.startsWith("file://");
35
- }
36
-
37
- export function resolveRepoSource(repo: string): RepoSource {
38
- if (isGitUrl(repo)) return { url: repo };
39
-
40
- const [owner, name, ...rest] = repo.split("/");
41
- if (!owner || !name) {
42
- throw new Error(`invalid repo "${repo}": expected owner/repo, owner/repo/path, or a git URL`);
43
- }
44
- const url = `https://github.com/${owner}/${name}.git`;
45
- return rest.length > 0 ? { url, skillPath: rest.join("/") } : { url };
46
- }
47
-
48
- function extractHost(url: string): string {
49
- const scpMatch = url.match(/^[^/\s]+@([^/\s]+):/);
50
- if (scpMatch) return scpMatch[1]!;
51
- return new URL(url).hostname;
52
- }
53
-
54
- export function assertHostAllowed(url: string, allowedHosts: string[] | undefined): void {
55
- if (!allowedHosts || allowedHosts.length === 0) return;
56
- if (isLocalFileUrl(url)) return;
57
- const host = extractHost(url);
58
- if (!allowedHosts.map((h) => h.toLowerCase()).includes(host.toLowerCase())) {
59
- throw new Error(`refusing to fetch from host "${host}" — not in [egress] allowed_hosts`);
60
- }
61
- }
62
-
63
- export function deriveRepoName(url: string): string {
64
- const cleaned = url.replace(/\.git$/, "");
65
- const segment = cleaned.split(/[/:]/).filter(Boolean).pop();
66
- if (!segment) throw new Error(`could not derive a repo name from "${url}"`);
67
- return segment;
68
- }
69
-
70
- export async function cloneToTemp(url: string): Promise<string> {
71
- const dir = mkdtempSync(join(tmpdir(), "skillmux-install-"));
72
- const proc = Bun.spawn(["git", "clone", "--quiet", "--depth", "1", url, dir], {
73
- stdout: "pipe",
74
- stderr: "pipe",
75
- });
76
- const exitCode = await proc.exited;
77
- if (exitCode !== 0) {
78
- const stderr = await new Response(proc.stderr).text();
79
- rmSync(dir, { recursive: true, force: true });
80
- throw new Error(`git clone failed for ${url}: ${stderr.trim()}`);
81
- }
82
- return dir;
83
- }
84
-
85
- export function resolveCloneCommit(cloneDir: string): string {
86
- const proc = Bun.spawnSync(["git", "-C", cloneDir, "rev-parse", "HEAD"], { stdout: "pipe", stderr: "pipe" });
87
- if (proc.exitCode !== 0) {
88
- throw new Error(`git rev-parse HEAD failed in ${cloneDir}: ${proc.stderr.toString().trim()}`);
89
- }
90
- return proc.stdout.toString().trim();
91
- }
92
-
93
- export async function remoteHeadCommit(url: string, ref = "HEAD"): Promise<string> {
94
- const proc = Bun.spawn(["git", "ls-remote", url, ref], { stdout: "pipe", stderr: "pipe" });
95
- const exitCode = await proc.exited;
96
- const stdout = await new Response(proc.stdout).text();
97
- if (exitCode !== 0) {
98
- const stderr = await new Response(proc.stderr).text();
99
- throw new Error(`git ls-remote failed for ${url}: ${stderr.trim()}`);
100
- }
101
- const line = stdout.split("\n").find((l) => l.trim().length > 0);
102
- if (!line) throw new Error(`git ls-remote returned no ref "${ref}" for ${url}`);
103
- const sha = line.split("\t")[0]?.trim();
104
- if (!sha || !/^[0-9a-f]{40}$/.test(sha)) {
105
- throw new Error(`git ls-remote returned an unparseable SHA for ${url}: ${line}`);
106
- }
107
- return sha;
108
- }
109
-
110
- /** Recursively finds symlinks under `dir` (skipping `.git`), without following them.
111
- * A skill's content must be regular files only — a symlink here is how a malicious
112
- * skill smuggles an escape out of the vault once `skillmux sync` exposes it inside
113
- * an agent's native skill directory. `dir` itself is checked too: a `skill_path`
114
- * can point straight at a directory that git committed *as a symlink* (git supports
115
- * storing symlink blobs) — walking its descendants alone would silently resolve
116
- * through it and report the target's real files as clean. */
117
- export function findSymlinks(dir: string): string[] {
118
- const found: string[] = [];
119
- if (lstatSync(dir).isSymbolicLink()) {
120
- return ["(the skill directory itself is a symlink)"];
121
- }
122
- const walk = (current: string) => {
123
- for (const entry of readdirSync(current, { withFileTypes: true })) {
124
- if (entry.name === ".git") continue;
125
- const abs = join(current, entry.name);
126
- if (entry.isSymbolicLink()) {
127
- found.push(relative(dir, abs));
128
- } else if (entry.isDirectory()) {
129
- walk(abs);
130
- }
131
- }
132
- };
133
- walk(dir);
134
- return found.sort();
135
- }
136
-
137
- export interface ValidationResult {
138
- findings: ScanFinding[];
139
- }
140
-
141
- export async function validateSkillCandidate(skillId: string, dir: string): Promise<ValidationResult> {
142
- const symlinks = findSymlinks(dir);
143
- if (symlinks.length > 0) {
144
- throw new Error(
145
- `"${skillId}" contains symlink(s), which are not allowed in skill content: ${symlinks.join(", ")}`,
146
- );
147
- }
148
-
149
- const bytes = await Bun.file(join(dir, "SKILL.md")).bytes();
150
- const body = decodeUtf8Strict(bytes);
151
- parseSkillMd(skillId, body);
152
-
153
- const findings: ScanFinding[] = scanContent(body).map((match) => ({
154
- ...match,
155
- skill_id: skillId,
156
- file: "SKILL.md",
157
- }));
158
-
159
- const vaultPath = dirname(dir);
160
- const dirName = basename(dir);
161
- for (const rel of listSupportingFiles(vaultPath, dirName)) {
162
- const content = await readTextFileOrNull(join(dir, rel));
163
- if (content === null) continue;
164
- for (const match of scanContent(content)) {
165
- findings.push({ ...match, skill_id: skillId, file: rel });
166
- }
167
- }
168
-
169
- return { findings };
170
- }
171
-
172
- export function installIntoVault(vaultPath: string, skillId: string, sourceDir: string, force = false): string {
173
- const symlinks = findSymlinks(sourceDir);
174
- if (symlinks.length > 0) {
175
- throw new Error(
176
- `refusing to install "${skillId}": source contains symlink(s), which are not allowed in skill content: ${symlinks.join(", ")}`,
177
- );
178
- }
179
- const targetDir = join(vaultPath, skillId);
180
- if (existsSync(targetDir)) {
181
- if (!force) {
182
- throw new Error(`skill "${skillId}" already exists in the vault at ${targetDir} — pass --force to overwrite`);
183
- }
184
- rmSync(targetDir, { recursive: true, force: true });
185
- }
186
- cpSync(sourceDir, targetDir, { recursive: true, filter: (src) => basename(src) !== ".git" });
187
- return targetDir;
188
- }
189
-
190
- export interface ResolvedSkillDir {
191
- skillId: string;
192
- dir: string;
193
- }
194
-
195
- /** The returned skillId is joined straight into vaultPath by installIntoVault's callers
196
- * and fed to a real rmSync(recursive)+cpSync overwrite. Both branches below can produce
197
- * "." or ".." for a crafted-but-plausible input: `skill_path` of "." (e.g. `skillmux
198
- * install owner/repo/.`) survives the ".." segment check since "." isn't "..", and its
199
- * basename is "." too; `fallbackName` comes from deriveRepoName(url), which can return
200
- * ".." for a url whose last "/"- or ":"-delimited segment is literally "..". Verified
201
- * end-to-end against the real CLI binary: the former makes `install --force` wipe the
202
- * entire vault, the latter makes it wipe the vault's parent directory. Neither can ever
203
- * legitimately be a skill id, so reject both outright rather than let them reach a join. */
204
- function rejectTraversalSkillId(skillId: string): void {
205
- if (skillId === "." || skillId === "..") {
206
- throw new Error(`invalid skill id "${skillId}"`);
207
- }
208
- }
209
-
210
- export function resolveSkillDir(cloneDir: string, fallbackName: string, skillPath?: string): ResolvedSkillDir {
211
- if (skillPath) {
212
- if (skillPath.startsWith("/") || skillPath.split("/").includes("..")) {
213
- throw new Error(`invalid skill_path "${skillPath}": must be a relative path within the repo`);
214
- }
215
- const skillId = basename(skillPath);
216
- rejectTraversalSkillId(skillId);
217
- return { skillId, dir: join(cloneDir, skillPath) };
218
- }
219
- if (existsSync(join(cloneDir, "SKILL.md"))) {
220
- rejectTraversalSkillId(fallbackName);
221
- return { skillId: fallbackName, dir: cloneDir };
222
- }
223
- const discovered = readdirSync(cloneDir, { withFileTypes: true })
224
- .filter((entry) => entry.isDirectory() && existsSync(join(cloneDir, entry.name, "SKILL.md")))
225
- .map((entry) => entry.name)
226
- .sort();
227
- throw new Error(
228
- discovered.length > 0
229
- ? `no SKILL.md at repo root; found skill dirs: ${discovered.join(", ")} — pass a path to select one, e.g. owner/repo/${discovered[0]}`
230
- : "no SKILL.md at repo root and no skill dirs found under it",
231
- );
232
- }
package/src/lifecycle.ts DELETED
@@ -1,51 +0,0 @@
1
- import { expandHome } from "./config";
2
- import type { ReadinessState } from "./readiness";
3
- import { backfillEmbeddings, getRuntime, rebuildIndex } from "./router-core";
4
- import { getVaultMaxMtime } from "./vault";
5
-
6
- export async function initializeRuntime(state: ReadinessState): Promise<void> {
7
- try {
8
- const { config, db, clients } = await getRuntime();
9
- const vaultPath = expandHome(config.vault_path);
10
- const report = await rebuildIndex();
11
- let embedding: "ready" | "unavailable" = "ready";
12
- try {
13
- await backfillEmbeddings();
14
- await clients.embed(["skill router readiness probe"]);
15
- } catch {
16
- embedding = "unavailable";
17
- }
18
-
19
- let reranker: "not_configured" | "ready" | "unavailable" = "not_configured";
20
- if (clients.rerank) {
21
- try {
22
- await clients.rerank("skill router readiness probe", [
23
- { skill_id: "readiness", text: "Routes tasks to relevant skills." },
24
- ]);
25
- reranker = "ready";
26
- } catch {
27
- reranker = "unavailable";
28
- }
29
- }
30
-
31
- state.set({
32
- status: "ready",
33
- retrieval: reranker === "ready" ? "reranked" : embedding === "ready" ? "hybrid" : "lexical",
34
- skills: report.indexed,
35
- index_current: getVaultMaxMtime(vaultPath) >= 0 && db.query("SELECT COUNT(*) AS count FROM skills").get() !== null,
36
- embedding,
37
- reranker,
38
- });
39
- } catch (error) {
40
- state.set({
41
- status: "not_ready",
42
- retrieval: null,
43
- skills: 0,
44
- index_current: false,
45
- embedding: "unavailable",
46
- reranker: "not_configured",
47
- error: error instanceof Error ? error.message : String(error),
48
- });
49
- throw error;
50
- }
51
- }
package/src/logger.ts DELETED
@@ -1,26 +0,0 @@
1
- /**
2
- * Centralized logging helpers, all writing to stderr via console.error.
3
- * `log` emits structured JSON ({ level, stage, ...payload }) for machine-
4
- * readable signals like router-core's retrieval-degradation warnings.
5
- * `redactedErrorLog` builds a plain-text [prefix, message] pair for
6
- * operator-facing runtime errors that need secret redaction.
7
- */
8
-
9
- export const log = {
10
- warn(stage: string, payload: Record<string, unknown>): void {
11
- console.error(JSON.stringify({ level: "warn", stage, ...payload }));
12
- },
13
- error(stage: string, payload: Record<string, unknown>): void {
14
- console.error(JSON.stringify({ level: "error", stage, ...payload }));
15
- },
16
- };
17
-
18
- /** Pairs a fixed log prefix with a redacted error message, for console.error. */
19
- export function redactedErrorLog(
20
- prefix: string,
21
- err: unknown,
22
- redact: (text: string) => string,
23
- ): [string, string] {
24
- const msg = err instanceof Error ? err.message : String(err);
25
- return [prefix, redact(msg)];
26
- }
package/src/manifest.ts DELETED
@@ -1,352 +0,0 @@
1
- import { existsSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { z } from "zod";
4
- import { expandHome } from "./config";
5
- import { BUILT_IN_TARGET_NAMES, resolveBuiltInTarget } from "./init-agents";
6
- import { resolveSkillRoot, SKILL_ID_PATTERN } from "./vault";
7
-
8
- export const MANIFEST_FILENAME = "skillmux.toml";
9
- export const LEGACY_MANIFEST_FILENAME = "skr.toml";
10
-
11
- export function resolveManifestPath(vaultPath: string): string | null {
12
- const newPath = join(vaultPath, MANIFEST_FILENAME);
13
- if (existsSync(newPath)) return newPath;
14
- const legacyPath = join(vaultPath, LEGACY_MANIFEST_FILENAME);
15
- if (existsSync(legacyPath)) return legacyPath;
16
- return null;
17
- }
18
-
19
- const groupNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*$/).max(64);
20
- const skillIdSchema = z.string().regex(SKILL_ID_PATTERN);
21
-
22
- const projectGroupSchema = z.object({
23
- paths: z.array(z.string().min(1)),
24
- skills: z.array(skillIdSchema),
25
- }).strict();
26
-
27
- const targetSchema = z.object({
28
- dir: z.string().min(1).optional(),
29
- host: z.string().min(1).optional(),
30
- project_groups: z.array(groupNameSchema).default([]),
31
- }).strict();
32
-
33
- const manifestSchema = z.object({
34
- core: z.object({ skills: z.array(skillIdSchema) }).strict(),
35
- project: z.record(groupNameSchema, projectGroupSchema).optional(),
36
- targets: z.record(groupNameSchema, targetSchema).default({}),
37
- }).strict();
38
-
39
- export type ProjectGroup = z.infer<typeof projectGroupSchema>;
40
- export type Target = z.infer<typeof targetSchema>;
41
- export type Manifest = z.infer<typeof manifestSchema>;
42
-
43
- export function resolveTargetDir(
44
- name: string,
45
- target: Target,
46
- options: { home?: string; codexHome?: string } = {},
47
- ): string {
48
- if (BUILT_IN_TARGET_NAMES.has(name)) {
49
- return resolveBuiltInTarget(name, {
50
- ...options,
51
- codexHome: options.codexHome ?? (process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined),
52
- }).path;
53
- }
54
- if (!target.dir) throw new Error(`[targets.${name}] requires dir for a custom target`);
55
- return expandHome(target.dir);
56
- }
57
-
58
- export function parseManifest(toml: string): Manifest {
59
- const parsed = Bun.TOML.parse(toml) as Record<string, unknown>;
60
- try {
61
- const manifest = manifestSchema.parse(parsed);
62
- for (const [name, target] of Object.entries(manifest.targets)) {
63
- if (!BUILT_IN_TARGET_NAMES.has(name) && !target.dir) {
64
- throw new Error(`[targets.${name}] requires dir for a custom target`);
65
- }
66
- }
67
- return manifest;
68
- } catch (error) {
69
- if (error instanceof z.ZodError) {
70
- for (const issue of error.issues) {
71
- if (
72
- issue.code === "unrecognized_keys" &&
73
- issue.path[0] === "targets" &&
74
- issue.keys.includes("project")
75
- ) {
76
- throw new Error(
77
- `[targets.${String(issue.path[1])}] uses the removed field "project" (boolean) — replace it with "project_groups" (an array of [project.<group>] names).`,
78
- );
79
- }
80
- if (
81
- issue.code === "unrecognized_keys" &&
82
- issue.path[0] === "project" &&
83
- issue.keys.includes("repos")
84
- ) {
85
- throw new Error(
86
- `[project.${String(issue.path[1])}] uses the removed field "repos" — replace it with "paths".`,
87
- );
88
- }
89
- }
90
- }
91
- throw error;
92
- }
93
- }
94
-
95
- function tomlStringArray(values: string[]): string {
96
- return `[${values.map((v) => JSON.stringify(v)).join(", ")}]`;
97
- }
98
-
99
- /** Purpose-built serializer for this manifest's fixed shape — not a general TOML writer. */
100
- export function serializeManifest(manifest: Manifest): string {
101
- const sections: string[] = [`[core]\nskills = ${tomlStringArray(manifest.core.skills)}`];
102
-
103
- for (const [name, group] of Object.entries(manifest.project ?? {})) {
104
- sections.push(
105
- `[project.${name}]\npaths = ${tomlStringArray(group.paths)}\nskills = ${tomlStringArray(group.skills)}`,
106
- );
107
- }
108
-
109
- for (const [name, target] of Object.entries(manifest.targets)) {
110
- const dir = BUILT_IN_TARGET_NAMES.has(name) ? "" : `\ndir = ${JSON.stringify(target.dir)}`;
111
- const host = target.host ? `\nhost = ${JSON.stringify(target.host)}` : "";
112
- sections.push(
113
- `[targets.${name}]${dir}${host}\nproject_groups = ${tomlStringArray(target.project_groups)}`,
114
- );
115
- }
116
-
117
- return `${sections.join("\n\n")}\n`;
118
- }
119
-
120
- export function writeManifestAtomic(path: string, manifest: Manifest): void {
121
- const temporaryPath = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
122
- try {
123
- writeFileSync(temporaryPath, serializeManifest(manifest), "utf8");
124
- renameSync(temporaryPath, path);
125
- } finally {
126
- rmSync(temporaryPath, { force: true });
127
- }
128
- }
129
-
130
- function findExistingPin(manifest: Manifest, skillId: string): string | null {
131
- if (manifest.core.skills.includes(skillId)) return "[core]";
132
- for (const [groupName, group] of Object.entries(manifest.project ?? {})) {
133
- if (group.skills.includes(skillId)) return `[project.${groupName}]`;
134
- }
135
- return null;
136
- }
137
-
138
- export function pinCore(manifest: Manifest, skillId: string): Manifest {
139
- const existing = findExistingPin(manifest, skillId);
140
- if (existing) {
141
- throw new Error(`skill "${skillId}" already pinned in ${existing}`);
142
- }
143
- return { ...manifest, core: { skills: [...manifest.core.skills, skillId] } };
144
- }
145
-
146
- export function unpinCore(manifest: Manifest, skillId: string): Manifest {
147
- if (!manifest.core.skills.includes(skillId)) {
148
- throw new Error(`skill "${skillId}" is not pinned in [core]`);
149
- }
150
- return { ...manifest, core: { skills: manifest.core.skills.filter((id) => id !== skillId) } };
151
- }
152
-
153
- export function pinProject(manifest: Manifest, skillId: string, group: string, paths?: string[]): Manifest {
154
- if (!groupNameSchema.safeParse(group).success) {
155
- throw new Error(`invalid group name "${group}" — must match /^[a-z][a-z0-9_-]*$/ (max 64 chars)`);
156
- }
157
- const existingGroup = manifest.project?.[group];
158
-
159
- if (!existingGroup) {
160
- if (!paths || paths.length === 0) {
161
- throw new Error(`group "${group}" does not exist — pass --path <path> at least once to create it`);
162
- }
163
- const existing = findExistingPin(manifest, skillId);
164
- if (existing) {
165
- throw new Error(`skill "${skillId}" already pinned in ${existing}`);
166
- }
167
- return {
168
- ...manifest,
169
- project: { ...manifest.project, [group]: { paths, skills: [skillId] } },
170
- };
171
- }
172
-
173
- if (paths && paths.length > 0) {
174
- throw new Error(`group "${group}" already exists — --path is only used when creating a new group`);
175
- }
176
- const existing = findExistingPin(manifest, skillId);
177
- if (existing) {
178
- throw new Error(`skill "${skillId}" already pinned in ${existing}`);
179
- }
180
- return {
181
- ...manifest,
182
- project: { ...manifest.project, [group]: { ...existingGroup, skills: [...existingGroup.skills, skillId] } },
183
- };
184
- }
185
-
186
- export function unpinProject(manifest: Manifest, skillId: string, group: string): Manifest {
187
- const existingGroup = manifest.project?.[group];
188
- if (!existingGroup) {
189
- throw new Error(`[project.${group}] does not exist`);
190
- }
191
- if (!existingGroup.skills.includes(skillId)) {
192
- throw new Error(`skill "${skillId}" is not pinned in [project.${group}]`);
193
- }
194
- return {
195
- ...manifest,
196
- project: {
197
- ...manifest.project,
198
- [group]: { ...existingGroup, skills: existingGroup.skills.filter((id) => id !== skillId) },
199
- },
200
- };
201
- }
202
-
203
- export interface UpsertProjectOptions {
204
- name: string;
205
- paths: string[];
206
- skills: string[];
207
- targets: string[];
208
- }
209
-
210
- export function upsertProject(manifest: Manifest, options: UpsertProjectOptions): Manifest {
211
- if (!groupNameSchema.safeParse(options.name).success) {
212
- throw new Error(
213
- `invalid group name "${options.name}" — must match /^[a-z][a-z0-9_-]*$/ (max 64 chars)`,
214
- );
215
- }
216
-
217
- const existingGroup = manifest.project?.[options.name] ?? { paths: [], skills: [] };
218
- for (const skillId of options.skills) {
219
- if (!skillIdSchema.safeParse(skillId).success) {
220
- throw new Error(`invalid skill ID "${skillId}"`);
221
- }
222
- if (existingGroup.skills.includes(skillId)) continue;
223
- const existing = findExistingPin(manifest, skillId);
224
- if (existing) throw new Error(`skill "${skillId}" already pinned in ${existing}`);
225
- }
226
-
227
- const targets = { ...manifest.targets };
228
- for (const targetName of options.targets) {
229
- const target = targets[targetName];
230
- if (!target) throw new Error(`target "${targetName}" does not exist`);
231
- targets[targetName] = {
232
- ...target,
233
- project_groups: [...new Set([...target.project_groups, options.name])],
234
- };
235
- }
236
-
237
- return {
238
- ...manifest,
239
- project: {
240
- ...manifest.project,
241
- [options.name]: {
242
- paths: [...new Set([...existingGroup.paths, ...options.paths])],
243
- skills: [...new Set([...existingGroup.skills, ...options.skills])],
244
- },
245
- },
246
- targets,
247
- };
248
- }
249
-
250
- export function updateProjectPaths(
251
- manifest: Manifest,
252
- group: string,
253
- changes: { add?: string[]; remove?: string[] },
254
- ): Manifest {
255
- const existingGroup = manifest.project?.[group];
256
- if (!existingGroup) throw new Error(`[project.${group}] does not exist`);
257
- const removed = new Set(changes.remove ?? []);
258
- const paths = [...new Set([...existingGroup.paths, ...(changes.add ?? [])])]
259
- .filter((path) => !removed.has(path));
260
- return {
261
- ...manifest,
262
- project: {
263
- ...manifest.project,
264
- [group]: { ...existingGroup, paths },
265
- },
266
- };
267
- }
268
-
269
- export function updateProjectTargets(
270
- manifest: Manifest,
271
- group: string,
272
- changes: { attach?: string[]; detach?: string[] },
273
- ): Manifest {
274
- if (!manifest.project?.[group]) throw new Error(`[project.${group}] does not exist`);
275
- const attach = new Set(changes.attach ?? []);
276
- const detach = new Set(changes.detach ?? []);
277
- const requested = new Set([...attach, ...detach]);
278
- for (const target of requested) {
279
- if (!manifest.targets[target]) throw new Error(`target "${target}" does not exist`);
280
- }
281
- return {
282
- ...manifest,
283
- targets: Object.fromEntries(Object.entries(manifest.targets).map(([name, target]) => {
284
- let groups = target.project_groups;
285
- if (attach.has(name)) groups = [...new Set([...groups, group])];
286
- if (detach.has(name)) groups = groups.filter((item) => item !== group);
287
- return [name, { ...target, project_groups: groups }];
288
- })),
289
- };
290
- }
291
-
292
- export interface ManifestValidationResult {
293
- notes: string[];
294
- }
295
-
296
- export const CORE_SKILL_LIMIT = 25;
297
-
298
- function requireCoreVaultRoot(skillId: string, vaultPath: string, localVaultPaths: string[], location: string): void {
299
- const root = resolveSkillRoot(skillId, vaultPath, localVaultPaths);
300
- if (root === null) {
301
- throw new Error(`${location} skill "${skillId}" does not exist in the vault`);
302
- }
303
- if (root !== vaultPath) {
304
- throw new Error(
305
- `${location} skill "${skillId}" only exists in a local vault path (${root}) — pins in the shared ` +
306
- `manifest must be backed by the canonical vault_path (${vaultPath}) to stay portable across machines`,
307
- );
308
- }
309
- }
310
-
311
- export function validateManifest(
312
- manifest: Manifest,
313
- vaultPath: string,
314
- localVaultPaths: string[] = [],
315
- ): ManifestValidationResult {
316
- if (manifest.core.skills.length > CORE_SKILL_LIMIT) {
317
- throw new Error(
318
- `[core] has ${manifest.core.skills.length} skills, exceeding the limit of ${CORE_SKILL_LIMIT}`,
319
- );
320
- }
321
-
322
- const coreSet = new Set(manifest.core.skills);
323
- for (const skillId of manifest.core.skills) {
324
- requireCoreVaultRoot(skillId, vaultPath, localVaultPaths, "[core]");
325
- }
326
-
327
- const groupNames = new Set(Object.keys(manifest.project ?? {}));
328
- for (const [targetName, target] of Object.entries(manifest.targets)) {
329
- for (const groupName of target.project_groups) {
330
- if (!groupNames.has(groupName)) {
331
- throw new Error(`[targets.${targetName}] project_groups references undefined group "${groupName}"`);
332
- }
333
- }
334
- }
335
-
336
- const notes: string[] = [];
337
- for (const [groupName, group] of Object.entries(manifest.project ?? {})) {
338
- for (const skillId of group.skills) {
339
- requireCoreVaultRoot(skillId, vaultPath, localVaultPaths, `[project.${groupName}]`);
340
- if (coreSet.has(skillId)) {
341
- throw new Error(`skill "${skillId}" appears in both [core] and [project.${groupName}]`);
342
- }
343
- }
344
- for (const path of group.paths) {
345
- if (!existsSync(expandHome(path))) {
346
- notes.push(`[project.${groupName}] paths entry not found locally, skipped: ${path}`);
347
- }
348
- }
349
- }
350
-
351
- return { notes };
352
- }