@klhapp/skillmux 0.2.1 → 0.4.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.
package/src/output.ts ADDED
@@ -0,0 +1,151 @@
1
+ import type { ResolvedTarget } from "./context";
2
+
3
+ export interface JsonEnvelope<T = any> {
4
+ schema_version: 1;
5
+ ok: boolean;
6
+ target: string | { name: string; server: string };
7
+ data: T | null;
8
+ error: { code: string; message: string; details?: any } | null;
9
+ }
10
+
11
+ export function formatJsonEnvelope<T>(opts: {
12
+ ok: boolean;
13
+ target: ResolvedTarget | string | { name: string; server: string };
14
+ data?: T;
15
+ error?: { code: string; message: string; details?: any } | null;
16
+ }): JsonEnvelope<T> {
17
+ let targetVal: string | { name: string; server: string };
18
+ if (typeof opts.target === "string" || (typeof opts.target === "object" && "server" in opts.target && !("type" in opts.target))) {
19
+ targetVal = opts.target as any;
20
+ } else if (typeof opts.target === "object" && "type" in opts.target) {
21
+ if (opts.target.type === "local") {
22
+ targetVal = "local";
23
+ } else {
24
+ targetVal = { name: opts.target.name, server: opts.target.server };
25
+ }
26
+ } else {
27
+ targetVal = "local";
28
+ }
29
+
30
+ return {
31
+ schema_version: 1,
32
+ ok: opts.ok,
33
+ target: targetVal,
34
+ data: opts.data ?? null,
35
+ error: opts.error ?? null,
36
+ };
37
+ }
38
+
39
+ export function mapExitCode(err: unknown): number {
40
+ if (!err) return 0;
41
+ const msg = err instanceof Error ? err.message : String(err);
42
+ const lower = msg.toLowerCase();
43
+
44
+ if (
45
+ lower.includes("conflict") ||
46
+ lower.includes("revision") ||
47
+ lower.includes("externally managed") ||
48
+ lower.includes("config_revision_conflict") ||
49
+ lower.includes("config_externally_managed")
50
+ ) {
51
+ return 4;
52
+ }
53
+
54
+ if (
55
+ lower.includes("unreachable") ||
56
+ lower.includes("failed to reach") ||
57
+ lower.includes("unauthorized") ||
58
+ lower.includes("unauthenticated") ||
59
+ lower.includes("401") ||
60
+ lower.includes("403") ||
61
+ lower.includes("connection refused")
62
+ ) {
63
+ return 3;
64
+ }
65
+
66
+ return 2;
67
+ }
68
+
69
+ export function levenshteinDistance(a: string, b: string): number {
70
+ const m = a.length;
71
+ const n = b.length;
72
+ const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
73
+
74
+ for (let i = 0; i <= m; i++) {
75
+ if (!dp[i]) dp[i] = [];
76
+ dp[i]![0] = i;
77
+ }
78
+ for (let j = 0; j <= n; j++) {
79
+ if (!dp[0]) dp[0] = [];
80
+ dp[0]![j] = j;
81
+ }
82
+
83
+ for (let i = 1; i <= m; i++) {
84
+ for (let j = 1; j <= n; j++) {
85
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
86
+ const prevRow = dp[i - 1]!;
87
+ const curRow = dp[i]!;
88
+ curRow[j] = Math.min(
89
+ prevRow[j]! + 1,
90
+ curRow[j - 1]! + 1,
91
+ prevRow[j - 1]! + cost
92
+ );
93
+ }
94
+ }
95
+ return dp[m]![n]!;
96
+ }
97
+
98
+ export function suggestCorrection(input: string, candidates: string[]): string | null {
99
+ let minDistance = Infinity;
100
+ let bestMatch: string | null = null;
101
+
102
+ for (const candidate of candidates) {
103
+ const dist = levenshteinDistance(input, candidate);
104
+ if (dist < minDistance && dist <= 2) {
105
+ minDistance = dist;
106
+ bestMatch = candidate;
107
+ }
108
+ }
109
+
110
+ return bestMatch;
111
+ }
112
+
113
+ export function isInteractive(): boolean {
114
+ return (
115
+ process.stdout.isTTY === true &&
116
+ !process.env.NO_COLOR &&
117
+ process.env.TERM !== "dumb"
118
+ );
119
+ }
120
+
121
+ export function renderTargetBanner(target: ResolvedTarget): void {
122
+ if (!isInteractive()) return;
123
+ if (target.type === "local") {
124
+ console.log(`Target: local`);
125
+ } else {
126
+ console.log(`Target: remote (${target.name} -> ${target.server})`);
127
+ }
128
+ }
129
+
130
+ export function renderTable(columns: { key: string; header: string }[], rows: Record<string, any>[]): void {
131
+ if (rows.length === 0) {
132
+ console.log("(no entries)");
133
+ return;
134
+ }
135
+
136
+ const widths = new Map<string, number>();
137
+ for (const col of columns) {
138
+ const maxLen = Math.max(col.header.length, ...rows.map((r) => String(r[col.key] ?? "").length));
139
+ widths.set(col.key, maxLen);
140
+ }
141
+
142
+ const headerLine = columns.map((col) => col.header.padEnd(widths.get(col.key) ?? 0)).join(" ");
143
+ const sepLine = columns.map((col) => "-".repeat(widths.get(col.key) ?? 0)).join(" ");
144
+
145
+ console.log(headerLine);
146
+ console.log(sepLine);
147
+ for (const row of rows) {
148
+ const line = columns.map((col) => String(row[col.key] ?? "").padEnd(widths.get(col.key) ?? 0)).join(" ");
149
+ console.log(line);
150
+ }
151
+ }
@@ -40,11 +40,16 @@ import {
40
40
  listSupportingFiles,
41
41
  parseSkillMd,
42
42
  readSkill,
43
- scanVault,
43
+ scanVaults,
44
+ vaultResolutionOrder,
44
45
  sha256Hex,
45
46
  SKILL_ID_PATTERN,
46
47
  } from "./vault";
47
48
 
49
+ function maxVaultMtime(vaultPath: string, localVaultPaths: string[]): number {
50
+ return Math.max(getVaultMaxMtime(vaultPath), ...localVaultPaths.map(getVaultMaxMtime));
51
+ }
52
+
48
53
  export { buildAuditRow } from "./audit";
49
54
  export { loadConfig } from "./config";
50
55
  export { decideResolveOutcome } from "./decision";
@@ -82,8 +87,9 @@ async function getEnv(): Promise<{ config: Config; db: Database }> {
82
87
  const db = openIndex(expandHome(config.state_dir));
83
88
  if (skillCount(db) === 0) {
84
89
  const vaultPath = expandHome(config.vault_path);
85
- ingestVault(db, await scanVault(vaultPath));
86
- setIndexMeta(db, "last_indexed_mtime", String(getVaultMaxMtime(vaultPath)));
90
+ const localVaultPaths = config.local_vault_paths.map(expandHome);
91
+ ingestVault(db, await scanVaults(vaultPath, localVaultPaths));
92
+ setIndexMeta(db, "last_indexed_mtime", String(maxVaultMtime(vaultPath, localVaultPaths)));
87
93
  }
88
94
  env = { config, db };
89
95
  return env;
@@ -110,16 +116,45 @@ export function closeRuntime(): void {
110
116
  */
111
117
  async function deliverSkill(db: Database, config: Config, skillId: string): Promise<FetchSkillResult> {
112
118
  const vaultPath = expandHome(config.vault_path);
113
- const file = Bun.file(join(vaultPath, skillId, "SKILL.md"));
114
- if (!(await file.exists())) {
119
+ const localVaultPaths = config.local_vault_paths.map(expandHome);
120
+ const candidates = vaultResolutionOrder(vaultPath, localVaultPaths);
121
+
122
+ // Existence alone (resolveSkillRoot's check) isn't enough here: an unparseable
123
+ // local_vault_paths override would otherwise shadow a perfectly valid vault_path
124
+ // copy of the same skill_id, since it's checked first. Skip a broken override and
125
+ // fall through to the next root — mirroring scanVault/rebuildIndex's tolerance for
126
+ // unparseable content. The last candidate (always vault_path) is never skipped on
127
+ // parse failure, matching the pre-existing single-root behavior where a broken
128
+ // vault_path copy propagates its parse error rather than being silently swallowed.
129
+ let root: string | null = null;
130
+ let bytes: Uint8Array | null = null;
131
+ let raw = "";
132
+ for (let i = 0; i < candidates.length; i++) {
133
+ const candidate = candidates[i]!;
134
+ const file = Bun.file(join(candidate, skillId, "SKILL.md"));
135
+ if (!(await file.exists())) continue;
136
+ const candidateBytes = await file.bytes();
137
+ const candidateRaw = decodeUtf8Strict(candidateBytes);
138
+ if (i < candidates.length - 1) {
139
+ try {
140
+ parseSkillMd(skillId, candidateRaw);
141
+ } catch {
142
+ continue;
143
+ }
144
+ }
145
+ root = candidate;
146
+ bytes = candidateBytes;
147
+ raw = candidateRaw;
148
+ break;
149
+ }
150
+
151
+ if (root === null || bytes === null) {
115
152
  // Deleted on disk but the watcher hasn't caught up: drop the stale row and
116
153
  // surface the schema's error code rather than a raw ENOENT.
117
154
  deleteSkill(db, skillId);
118
155
  throw new Error(`SKILL_NOT_FOUND: skill '${skillId}' no longer exists in the vault`);
119
156
  }
120
- const bytes = await file.bytes();
121
157
  const contentSha256 = sha256Hex(bytes);
122
- const raw = decodeUtf8Strict(bytes);
123
158
  let row = getSkillRow(db, skillId);
124
159
  if (row === null || row.content_sha256 !== contentSha256) {
125
160
  const fresh = parseSkillMd(skillId, raw);
@@ -131,7 +166,7 @@ async function deliverSkill(db: Database, config: Config, skillId: string): Prom
131
166
  title: row.title,
132
167
  content_sha256: contentSha256,
133
168
  body: raw,
134
- files: listSupportingFiles(vaultPath, skillId),
169
+ files: listSupportingFiles(root, skillId),
135
170
  };
136
171
  }
137
172
 
@@ -153,15 +188,23 @@ export async function rebuildIndex(
153
188
  ): Promise<RebuildReport> {
154
189
  const { config, db } = await getEnv();
155
190
  const vaultPath = expandHome(config.vault_path);
156
- const currentMtime = getVaultMaxMtime(vaultPath);
191
+ const localVaultPaths = config.local_vault_paths.map(expandHome);
192
+ const currentMtime = maxVaultMtime(vaultPath, localVaultPaths);
157
193
  const invalidIds: string[] = [];
158
- const skills = await scanVault(vaultPath, (skillId, error) => {
194
+ const skills = await scanVaults(vaultPath, localVaultPaths, (skillId, error) => {
159
195
  invalidIds.push(skillId);
160
196
  onInvalid?.(skillId, error);
161
197
  });
162
198
  const rows = skills.map(toSkillRow);
199
+ // A skill_id invalid in one root (e.g. a local_vault_paths entry being edited) can
200
+ // still be valid in another (e.g. vault_path) — scanVaults already resolved that
201
+ // in `skills`. Only retain the previous row for ids that came back invalid
202
+ // everywhere; otherwise this duplicates the skill_id and violates the
203
+ // skills.skill_id PRIMARY KEY on replaceSkills's plain INSERT.
204
+ const validIds = new Set(skills.map((s) => s.skill_id));
163
205
  const retained: string[] = [];
164
206
  for (const skillId of invalidIds) {
207
+ if (validIds.has(skillId)) continue;
165
208
  const previous = getSkillRow(db, skillId);
166
209
  if (previous) {
167
210
  rows.push(previous);
@@ -181,17 +224,22 @@ export async function rebuildIndex(
181
224
  export async function syncVaultIfNeeded(): Promise<void> {
182
225
  const { config, db } = await getEnv();
183
226
  const vaultPath = expandHome(config.vault_path);
184
- const currentMtime = getVaultMaxMtime(vaultPath);
227
+ const localVaultPaths = config.local_vault_paths.map(expandHome);
228
+ const currentMtime = maxVaultMtime(vaultPath, localVaultPaths);
185
229
  const lastIndexed = getIndexMeta(db, "last_indexed_mtime");
186
230
 
187
231
  if (lastIndexed === null || currentMtime > Number(lastIndexed)) {
188
232
  const invalidIds: string[] = [];
189
- const skills = await scanVault(vaultPath, (skillId, error) => {
233
+ const skills = await scanVaults(vaultPath, localVaultPaths, (skillId, error) => {
190
234
  invalidIds.push(skillId);
191
235
  console.error(`warning: keeping previous index entry for ${skillId}: ${error}`);
192
236
  });
193
237
  const rows = skills.map(toSkillRow);
238
+ // See rebuildIndex: a skill_id invalid in one root can still be valid in
239
+ // another that scanVaults already resolved — don't re-add its previous row.
240
+ const validIds = new Set(skills.map((s) => s.skill_id));
194
241
  for (const skillId of invalidIds) {
242
+ if (validIds.has(skillId)) continue;
195
243
  const previous = getSkillRow(db, skillId);
196
244
  if (previous) {
197
245
  rows.push(previous);
package/src/server.ts CHANGED
@@ -13,6 +13,15 @@ import { MetricsRegistry } from "./metrics";
13
13
  import { ReadinessState } from "./readiness";
14
14
  import { initializeRuntime } from "./lifecycle";
15
15
  import type { Clients, Config } from "./types";
16
+ import {
17
+ computeHash,
18
+ getEffectiveConfig,
19
+ getLocalConfigStatus,
20
+ setDottedKey,
21
+ RELOADABLE_KEYS,
22
+ RESTART_REQUIRED_KEYS,
23
+ } from "./config-service";
24
+ import { applyCalibrationRun, getCalibrationRun, listCalibrationRuns } from "./calibrate";
16
25
 
17
26
  export const metricsRegistry = new MetricsRegistry();
18
27
  export const readinessState = new ReadinessState();
@@ -254,6 +263,116 @@ export async function startServer(opts?: {
254
263
  return new Response(JSON.stringify(getStats(db, since)), { status: 200, headers });
255
264
  }
256
265
 
266
+ // Admin HTTP API (/admin/v1/*)
267
+ if (url.pathname.startsWith("/admin/v1/")) {
268
+ if (!serverConfig.admin?.enabled) {
269
+ return new Response("Admin endpoints disabled", { status: 403 });
270
+ }
271
+
272
+ const adminTokenEnv = serverConfig.admin.token_env || "SKILLMUX_ADMIN_TOKEN";
273
+ const expectedAdminToken = process.env[adminTokenEnv] || "";
274
+ const authHeader = req.headers.get("authorization") || "";
275
+ const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader;
276
+
277
+ if (!expectedAdminToken || !token || !safeTokenEquals(token, expectedAdminToken)) {
278
+ return new Response("Unauthorized", { status: 401 });
279
+ }
280
+
281
+ const headers = new Headers({ "Content-Type": "application/json" });
282
+ if (allowOriginHeader) headers.set("Access-Control-Allow-Origin", allowOriginHeader);
283
+
284
+ if (req.method === "GET" && url.pathname === "/admin/v1/capabilities") {
285
+ const isExternallyManaged = process.env.SKILLMUX_CONFIG_READONLY === "true";
286
+ return new Response(
287
+ JSON.stringify({
288
+ config_read: true,
289
+ config_write: !isExternallyManaged,
290
+ calibration: true,
291
+ persistence: isExternallyManaged ? "externally_managed" : "writable",
292
+ reloadable_keys: RELOADABLE_KEYS,
293
+ restart_required_keys: RESTART_REQUIRED_KEYS,
294
+ }),
295
+ { status: 200, headers }
296
+ );
297
+ }
298
+
299
+ if (req.method === "GET" && url.pathname === "/admin/v1/config") {
300
+ const { effective, sources } = await getEffectiveConfig();
301
+ const hash = computeHash(effective);
302
+ const status = await getLocalConfigStatus();
303
+ headers.set("ETag", `"${hash}"`);
304
+ return new Response(
305
+ JSON.stringify({
306
+ desired: effective,
307
+ effective,
308
+ sources,
309
+ active_revision: hash,
310
+ runtime: status,
311
+ }),
312
+ { status: 200, headers }
313
+ );
314
+ }
315
+
316
+ if (req.method === "PATCH" && url.pathname === "/admin/v1/config") {
317
+ if (process.env.SKILLMUX_CONFIG_READONLY === "true") {
318
+ return new Response(
319
+ JSON.stringify({ error: "CONFIG_EXTERNALLY_MANAGED", message: "Configuration is externally managed" }),
320
+ { status: 409, headers }
321
+ );
322
+ }
323
+
324
+ const ifMatch = req.headers.get("if-match") || "";
325
+ const cleanIfMatch = ifMatch.replace(/^"|"$/g, "");
326
+ const { effective } = await getEffectiveConfig();
327
+ const currentHash = computeHash(effective);
328
+
329
+ if (!ifMatch || cleanIfMatch !== currentHash) {
330
+ return new Response(
331
+ JSON.stringify({ error: "CONFIG_REVISION_CONFLICT", message: "Revision conflict" }),
332
+ { status: 409, headers }
333
+ );
334
+ }
335
+
336
+ const body = (await req.json()) as { changes: Record<string, string | number | boolean> };
337
+ let lastResult: any = null;
338
+ for (const [k, v] of Object.entries(body.changes ?? {})) {
339
+ lastResult = await setDottedKey(k, String(v), { targetName: "remote" });
340
+ }
341
+
342
+ return new Response(JSON.stringify(lastResult ?? { ok: true }), { status: 200, headers });
343
+ }
344
+
345
+ if (url.pathname.startsWith("/admin/v1/calibrations")) {
346
+ const { db } = await getRuntime();
347
+ if (req.method === "GET" && url.pathname === "/admin/v1/calibrations") {
348
+ const runs = listCalibrationRuns(db);
349
+ return new Response(JSON.stringify(runs), { status: 200, headers });
350
+ }
351
+ const runIdMatch = url.pathname.match(/^\/admin\/v1\/calibrations\/([^\/]+)$/);
352
+ if (req.method === "GET" && runIdMatch && runIdMatch[1]) {
353
+ const runId = runIdMatch[1];
354
+ const run = getCalibrationRun(db, runId);
355
+ if (!run) return new Response(JSON.stringify({ error: "Calibration run not found" }), { status: 404, headers });
356
+ return new Response(JSON.stringify(run), { status: 200, headers });
357
+ }
358
+ if (req.method === "POST" && url.pathname === "/admin/v1/calibrations") {
359
+ const runId = "run_" + Math.random().toString(36).slice(2, 10);
360
+ return new Response(JSON.stringify({ run_id: runId, status: "running" }), { status: 202, headers });
361
+ }
362
+ const applyMatch = url.pathname.match(/^\/admin\/v1\/calibrations\/([^\/]+)\/apply$/);
363
+ if (req.method === "POST" && applyMatch && applyMatch[1]) {
364
+ const runId = applyMatch[1];
365
+ const run = getCalibrationRun(db, runId);
366
+ if (!run) return new Response(JSON.stringify({ error: "Calibration run not found" }), { status: 404, headers });
367
+ const { DEFAULT_CONFIG_PATH, expandHome } = await import("./config");
368
+ await applyCalibrationRun(db, runId, expandHome(DEFAULT_CONFIG_PATH), {});
369
+ return new Response(JSON.stringify({ ok: true, run_id: runId }), { status: 200, headers });
370
+ }
371
+ }
372
+
373
+ return new Response("Not Found", { status: 404, headers });
374
+ }
375
+
257
376
  // Record request metrics
258
377
  let mcpMethod = "unknown";
259
378
  try {
@@ -0,0 +1,135 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { expandHome } from "./config";
3
+ import { openIndex } from "./db";
4
+ import type { Clients, Config } from "./types";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Immutable runtime snapshot (AC11)
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /**
11
+ * An immutable, reference-counted view of runtime resources.
12
+ * All fields are frozen at construction — callers can never mutate the snapshot.
13
+ * The underlying db handle is closed only after every holder calls release().
14
+ */
15
+ export interface RuntimeSnapshot {
16
+ readonly config: Config;
17
+ readonly clients: Clients;
18
+ readonly db: Database;
19
+ }
20
+
21
+ /**
22
+ * A handle returned by RuntimeSnapshotManager.acquire().
23
+ * Call release() exactly once when the request (or operation) is done.
24
+ */
25
+ export interface SnapshotHandle {
26
+ readonly snapshot: RuntimeSnapshot;
27
+ release(): void;
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Internal reference-counted slot
32
+ // ---------------------------------------------------------------------------
33
+
34
+ class SnapshotSlot {
35
+ private refCount = 0;
36
+ private closed = false;
37
+ readonly snapshot: RuntimeSnapshot;
38
+
39
+ constructor(config: Config, clients: Clients) {
40
+ const stateDir = expandHome(config.state_dir);
41
+ const db = openIndex(stateDir);
42
+ // Freeze the snapshot object so callers cannot mutate it
43
+ this.snapshot = Object.freeze({ config, clients, db });
44
+ }
45
+
46
+ acquire(): SnapshotHandle {
47
+ this.refCount++;
48
+ let released = false;
49
+ return {
50
+ snapshot: this.snapshot,
51
+ release: () => {
52
+ if (released) return; // idempotent
53
+ released = true;
54
+ this.refCount--;
55
+ this.maybeClose();
56
+ },
57
+ };
58
+ }
59
+
60
+ /** Signal that no new acquires will come from the manager for this slot. */
61
+ retire(): void {
62
+ this.closed = true;
63
+ this.maybeClose();
64
+ }
65
+
66
+ private maybeClose(): void {
67
+ if (this.closed && this.refCount === 0) {
68
+ try {
69
+ this.snapshot.db.close();
70
+ } catch {
71
+ // already closed — idempotent
72
+ }
73
+ }
74
+ }
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Public manager
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * RuntimeSnapshotManager holds one active SnapshotSlot at a time.
83
+ *
84
+ * - acquire() → returns a SnapshotHandle for the current slot; increments its refcount.
85
+ * - replace() → installs a new slot; retires the previous one (closed when all
86
+ * in-flight holders release, i.e. no handle leak).
87
+ * - dispose() → retires the current slot and blocks future acquires.
88
+ */
89
+ export class RuntimeSnapshotManager {
90
+ private current: SnapshotSlot;
91
+ private disposed = false;
92
+
93
+ private constructor(config: Config, clients: Clients) {
94
+ this.current = new SnapshotSlot(config, clients);
95
+ }
96
+
97
+ static create(config: Config, clients: Clients): RuntimeSnapshotManager {
98
+ return new RuntimeSnapshotManager(config, clients);
99
+ }
100
+
101
+ /**
102
+ * Acquire a handle to the current snapshot.
103
+ * The caller MUST call release() when done — even on error paths.
104
+ */
105
+ acquire(): SnapshotHandle {
106
+ if (this.disposed) {
107
+ throw new Error("RuntimeSnapshotManager has been disposed");
108
+ }
109
+ return this.current.acquire();
110
+ }
111
+
112
+ /**
113
+ * Swap in a new configuration and clients.
114
+ * The previous slot is retired and will close its db handle once all
115
+ * in-flight holders release.
116
+ */
117
+ replace(config: Config, clients: Clients): void {
118
+ if (this.disposed) {
119
+ throw new Error("RuntimeSnapshotManager has been disposed");
120
+ }
121
+ const outgoing = this.current;
122
+ this.current = new SnapshotSlot(config, clients);
123
+ outgoing.retire(); // closes when refCount reaches zero
124
+ }
125
+
126
+ /**
127
+ * Permanently shut down. Retires the current slot.
128
+ * Any future acquire() will throw.
129
+ */
130
+ dispose(): void {
131
+ if (this.disposed) return;
132
+ this.disposed = true;
133
+ this.current.retire();
134
+ }
135
+ }
package/src/sync.ts CHANGED
@@ -1,24 +1,31 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join, relative } from "node:path";
4
+ import { resolveSkillRoot } from "./vault";
4
5
 
5
6
  export const SKILLMUX_MARKER_FILENAME = ".skillmux";
6
7
  export const LEGACY_MARKER_FILENAME = ".skr";
7
8
 
8
9
  export interface SkillmuxMarker {
9
10
  managed_by: "skillmux" | "skr";
10
- target: string;
11
+ role: "target" | "local_vault";
12
+ target?: string;
13
+ vault_path?: string;
11
14
  created_at: string;
12
15
  }
13
16
 
17
+ function normalizeMarker(raw: Omit<SkillmuxMarker, "role"> & { role?: SkillmuxMarker["role"] }): SkillmuxMarker {
18
+ return { ...raw, role: raw.role ?? "target" };
19
+ }
20
+
14
21
  export function readSkillmuxMarker(dir: string): SkillmuxMarker | null {
15
22
  const newPath = join(dir, SKILLMUX_MARKER_FILENAME);
16
23
  if (existsSync(newPath)) {
17
- return JSON.parse(readFileSync(newPath, "utf-8")) as SkillmuxMarker;
24
+ return normalizeMarker(JSON.parse(readFileSync(newPath, "utf-8")));
18
25
  }
19
26
  const legacyPath = join(dir, LEGACY_MARKER_FILENAME);
20
27
  if (existsSync(legacyPath)) {
21
- return JSON.parse(readFileSync(legacyPath, "utf-8")) as SkillmuxMarker;
28
+ return normalizeMarker(JSON.parse(readFileSync(legacyPath, "utf-8")));
22
29
  }
23
30
  return null;
24
31
  }
@@ -26,17 +33,29 @@ export function readSkillmuxMarker(dir: string): SkillmuxMarker | null {
26
33
  function writeSkillmuxMarker(dir: string, targetName: string): void {
27
34
  const marker: SkillmuxMarker = {
28
35
  managed_by: "skillmux",
36
+ role: "target",
29
37
  target: targetName,
30
38
  created_at: new Date().toISOString(),
31
39
  };
32
40
  writeFileSync(join(dir, SKILLMUX_MARKER_FILENAME), JSON.stringify(marker, null, 2));
33
41
  }
34
42
 
43
+ export function writeLocalVaultMarker(dir: string, vaultPath: string): void {
44
+ const marker: SkillmuxMarker = {
45
+ managed_by: "skillmux",
46
+ role: "local_vault",
47
+ vault_path: vaultPath,
48
+ created_at: new Date().toISOString(),
49
+ };
50
+ writeFileSync(join(dir, SKILLMUX_MARKER_FILENAME), JSON.stringify(marker, null, 2));
51
+ }
52
+
35
53
  export interface SyncTargetParams {
36
54
  vaultPath: string;
37
55
  targetDir: string;
38
56
  targetName: string;
39
57
  coreSkillIds: string[];
58
+ localVaultPaths?: string[];
40
59
  }
41
60
 
42
61
  export interface SyncTargetResult {
@@ -49,14 +68,15 @@ export interface SyncTargetOptions {
49
68
  }
50
69
 
51
70
  export function syncTarget(params: SyncTargetParams, options: SyncTargetOptions = {}): SyncTargetResult {
52
- const { vaultPath, targetDir, targetName, coreSkillIds } = params;
71
+ const { vaultPath, targetDir, targetName, coreSkillIds, localVaultPaths = [] } = params;
53
72
  const { dryRun = false } = options;
73
+ const skillSource = (skillId: string) => resolveSkillRoot(skillId, vaultPath, localVaultPaths) ?? vaultPath;
54
74
 
55
75
  if (!existsSync(targetDir)) {
56
76
  if (dryRun) return { added: [...coreSkillIds], removed: [] };
57
77
  mkdirSync(targetDir, { recursive: true });
58
78
  for (const skillId of coreSkillIds) {
59
- symlinkSync(join(vaultPath, skillId), join(targetDir, skillId));
79
+ symlinkSync(join(skillSource(skillId), skillId), join(targetDir, skillId));
60
80
  }
61
81
  writeSkillmuxMarker(targetDir, targetName);
62
82
  return { added: [...coreSkillIds], removed: [] };
@@ -74,7 +94,7 @@ export function syncTarget(params: SyncTargetParams, options: SyncTargetOptions
74
94
  if (dryRun) return { added, removed };
75
95
 
76
96
  for (const name of removed) unlinkSync(join(targetDir, name));
77
- for (const skillId of added) symlinkSync(join(vaultPath, skillId), join(targetDir, skillId));
97
+ for (const skillId of added) symlinkSync(join(skillSource(skillId), skillId), join(targetDir, skillId));
78
98
 
79
99
  return { added, removed };
80
100
  }
@@ -129,6 +149,7 @@ export interface SyncProjectTargetsParams {
129
149
  targetDir: string;
130
150
  targetName: string;
131
151
  projectGroups: Record<string, ProjectGroupInput>;
152
+ localVaultPaths?: string[];
132
153
  }
133
154
 
134
155
  export interface ProjectPinSyncResult extends SyncTargetResult {
@@ -141,7 +162,7 @@ export function syncProjectTargets(
141
162
  params: SyncProjectTargetsParams,
142
163
  options: SyncTargetOptions = {},
143
164
  ): ProjectPinSyncResult[] {
144
- const { vaultPath, targetDir, targetName, projectGroups } = params;
165
+ const { vaultPath, targetDir, targetName, projectGroups, localVaultPaths = [] } = params;
145
166
  const results: ProjectPinSyncResult[] = [];
146
167
 
147
168
  for (const [group, projectGroup] of Object.entries(projectGroups)) {
@@ -149,7 +170,7 @@ export function syncProjectTargets(
149
170
  if (!existsSync(repo)) continue;
150
171
  const pinDir = resolveProjectPinDir(targetDir, repo);
151
172
  const result = syncTarget(
152
- { vaultPath, targetDir: pinDir, targetName, coreSkillIds: projectGroup.skills },
173
+ { vaultPath, targetDir: pinDir, targetName, coreSkillIds: projectGroup.skills, localVaultPaths },
153
174
  options,
154
175
  );
155
176
  results.push({ group, repo, pinDir, ...result });