skillwiki 0.10.111 → 0.10.113

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 (44) hide show
  1. package/dist/{chunk-I6IRJCUJ.js → chunk-3TKNAOJH.js} +2 -2
  2. package/dist/chunk-5D5HFQ76.js +111 -0
  3. package/dist/{chunk-OJJBZDPR.js → chunk-5RHR4FEK.js} +183 -211
  4. package/dist/{chunk-GQWCTJTU.js → chunk-6VUK2FTX.js} +1 -1
  5. package/dist/{chunk-JQV5KNPT.js → chunk-CBT35FAW.js} +3 -3
  6. package/dist/{chunk-HBJLFPQP.js → chunk-FQERKMD7.js} +458 -18
  7. package/dist/{chunk-PYPPG7RF.js → chunk-IJKJGQKL.js} +1 -1
  8. package/dist/{chunk-MRPJ7X7W.js → chunk-IJWZDQDH.js} +3 -3
  9. package/dist/{chunk-X5GL2FCU.js → chunk-JL2SCLXT.js} +1 -1
  10. package/dist/chunk-K3M6OSUW.js +77 -0
  11. package/dist/{chunk-5IBLXGLY.js → chunk-K3YZESND.js} +2 -2
  12. package/dist/chunk-OGEAFXON.js +60 -0
  13. package/dist/{chunk-6OQLBNKR.js → chunk-OWWWO2XS.js} +29 -4
  14. package/dist/{chunk-KWEYA3OE.js → chunk-SEY4N4CT.js} +1 -1
  15. package/dist/{chunk-UIEQIJXP.js → chunk-VNSZCK5V.js} +47 -44
  16. package/dist/{chunk-TDBUJUYH.js → chunk-XJ5VEIH7.js} +42 -18
  17. package/dist/cli.js +586 -122
  18. package/dist/{index-projection-2K672CBO.js → index-projection-RQSOEDXM.js} +5 -5
  19. package/dist/{journal-QU5IDXQF.js → journal-V5XHBEI5.js} +3 -3
  20. package/dist/{managed-write-preflight-GRSHQYKC.js → managed-write-preflight-2BMHZWVJ.js} +7 -7
  21. package/dist/mcp-auth-env-QONKW4EO.js +25 -0
  22. package/dist/{migrate-PIB4QA54.js → migrate-SRRCXI2K.js} +6 -5
  23. package/dist/mirror-H5BTBAON.js +151 -0
  24. package/dist/runtime-LQZIG3XN.js +20 -0
  25. package/dist/skillwiki-mcp.js +14 -12
  26. package/dist/sources-E6KHDRDE.js +10 -0
  27. package/dist/{watcher-MHG46J3R.js → watcher-VJ3TOU6J.js} +20 -110
  28. package/package.json +1 -1
  29. package/skills/.claude-plugin/plugin.json +1 -1
  30. package/skills/.codex-plugin/plugin.json +1 -1
  31. package/skills/.cursor-plugin/plugin.json +1 -1
  32. package/skills/package.json +1 -1
  33. package/skills/skills/using-skillwiki/SKILL.md +1 -1
  34. package/skills/skills/wiki-ingest/SKILL.md +47 -0
  35. package/skills/skills/wiki-init/SKILL.md +3 -0
  36. package/skills/skills/wiki-lint/SKILL.md +7 -0
  37. package/skills/using-skillwiki/SKILL.md +1 -1
  38. package/skills/wiki-ingest/SKILL.md +47 -0
  39. package/skills/wiki-init/SKILL.md +3 -0
  40. package/skills/wiki-lint/SKILL.md +7 -0
  41. package/templates/launchd/com.karlchow.skillwiki-mirror-sse.plist +20 -0
  42. package/dist/chunk-I2J3IJB7.js +0 -54
  43. package/dist/runtime-GLMI5OCT.js +0 -15
  44. package/dist/sources-D2MGP246.js +0 -10
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/clip-outbox/files.ts
4
+ import { constants } from "fs";
5
+ import { lstat, mkdir, open, realpath } from "fs/promises";
6
+ import { dirname, isAbsolute, relative, resolve, sep } from "path";
7
+ async function readRegular(root, path, maxBytes) {
8
+ const rel = relative(root, path);
9
+ if (!rel || rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) throw new Error("unsafe input path");
10
+ let current = resolve(root);
11
+ for (const part of ["", ...rel.split(sep)]) {
12
+ current = resolve(current, part);
13
+ const st = await lstat(current);
14
+ if (st.isSymbolicLink()) throw new Error("symlink input refused");
15
+ if (current !== path && !st.isDirectory()) throw new Error("invalid input parent");
16
+ }
17
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
18
+ try {
19
+ const before = await handle.stat();
20
+ if (!before.isFile() || before.size > maxBytes) throw new Error("input type or size refused");
21
+ const bytes = Buffer.alloc(before.size + 1);
22
+ let bytesRead = 0;
23
+ while (bytesRead < bytes.length) {
24
+ const chunk = await handle.read(bytes, bytesRead, bytes.length - bytesRead, bytesRead);
25
+ if (chunk.bytesRead === 0) break;
26
+ bytesRead += chunk.bytesRead;
27
+ }
28
+ const after = await handle.stat();
29
+ if (await realpath(path) !== path) throw new Error("input path changed during read");
30
+ const leaf = await lstat(path);
31
+ if (leaf.ino !== before.ino || leaf.dev !== before.dev) throw new Error("input identity changed during read");
32
+ if (bytesRead !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs) throw new Error("input changed during read");
33
+ return bytes.subarray(0, bytesRead);
34
+ } finally {
35
+ await handle.close();
36
+ }
37
+ }
38
+ async function writeRegularExclusive(root, rel, body) {
39
+ const dest = resolve(root, ...rel.split("/"));
40
+ const checked = relative(root, dest);
41
+ if (!checked || checked.startsWith(`..${sep}`) || checked === ".." || isAbsolute(checked)) throw new Error("unsafe dest path");
42
+ let current = resolve(root);
43
+ const parts = checked.split(sep);
44
+ for (let i = 0; i < parts.length - 1; i++) {
45
+ current = resolve(current, parts[i]);
46
+ try {
47
+ const st = await lstat(current);
48
+ if (st.isSymbolicLink()) throw new Error("symlink dest refused");
49
+ if (!st.isDirectory()) throw new Error("invalid dest parent");
50
+ } catch (error) {
51
+ if (error.code !== "ENOENT") throw error;
52
+ await mkdir(current);
53
+ }
54
+ }
55
+ const leaf = resolve(current, parts[parts.length - 1]);
56
+ if (dirname(leaf) !== current) throw new Error("unsafe dest path");
57
+ try {
58
+ const handle = await open(leaf, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 420);
59
+ try {
60
+ await handle.writeFile(body);
61
+ } finally {
62
+ await handle.close();
63
+ }
64
+ return "created";
65
+ } catch (error) {
66
+ if (error.code !== "EEXIST") throw error;
67
+ const st = await lstat(leaf);
68
+ if (st.isSymbolicLink() || !st.isFile() || st.size !== body.length) return "conflict";
69
+ const existing = await readRegular(root, leaf, body.length);
70
+ return existing.equals(body) ? "idempotent" : "conflict";
71
+ }
72
+ }
73
+
74
+ export {
75
+ readRegular,
76
+ writeRegularExclusive
77
+ };
@@ -4,11 +4,11 @@ import {
4
4
  readPage,
5
5
  scanSensitiveContent,
6
6
  splitFrontmatter
7
- } from "./chunk-X5GL2FCU.js";
7
+ } from "./chunk-JL2SCLXT.js";
8
8
  import {
9
9
  err,
10
10
  ok
11
- } from "./chunk-UIEQIJXP.js";
11
+ } from "./chunk-VNSZCK5V.js";
12
12
 
13
13
  // src/utils/vault-path-safety.ts
14
14
  import { lstatSync, realpathSync } from "fs";
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ parseDotenvFile,
4
+ resolveSkillwikiHome
5
+ } from "./chunk-IJKJGQKL.js";
6
+
7
+ // src/utils/mcp-auth-env.ts
8
+ import { join } from "path";
9
+ var MCP_URL_ENV = "SKILLWIKI_MCP_URL";
10
+ var MCP_TOKEN_ENV = "SKILLWIKI_MCP_TOKEN";
11
+ var MCP_HOST_ID_ENV = "SKILLWIKI_HOST_ID";
12
+ function nonEmptyEnvValue(value) {
13
+ if (typeof value !== "string") return void 0;
14
+ const trimmed = value.trim();
15
+ return trimmed.length > 0 ? trimmed : void 0;
16
+ }
17
+ function mcpAuthFromEnv(env) {
18
+ return {
19
+ url: nonEmptyEnvValue(env[MCP_URL_ENV]),
20
+ token: nonEmptyEnvValue(env[MCP_TOKEN_ENV]),
21
+ hostId: nonEmptyEnvValue(env[MCP_HOST_ID_ENV])
22
+ };
23
+ }
24
+ async function resolveMcpAuthEnv(input) {
25
+ const processAuth = mcpAuthFromEnv(input.env ?? process.env);
26
+ const dotenv = await parseDotenvFile(join(resolveSkillwikiHome(input.home, input.env), ".skillwiki", ".env"));
27
+ const fileAuth = mcpAuthFromEnv(dotenv);
28
+ return {
29
+ url: processAuth.url ?? fileAuth.url,
30
+ token: processAuth.token ?? fileAuth.token,
31
+ hostId: processAuth.hostId ?? fileAuth.hostId
32
+ };
33
+ }
34
+ async function mergeMcpAuthIntoEnv(home, env) {
35
+ const auth = await resolveMcpAuthEnv({ home, env });
36
+ const merged = { ...env };
37
+ if (auth.url && !nonEmptyEnvValue(env[MCP_URL_ENV])) merged[MCP_URL_ENV] = auth.url;
38
+ if (auth.token && !nonEmptyEnvValue(env[MCP_TOKEN_ENV])) merged[MCP_TOKEN_ENV] = auth.token;
39
+ if (auth.hostId && !nonEmptyEnvValue(env[MCP_HOST_ID_ENV])) merged[MCP_HOST_ID_ENV] = auth.hostId;
40
+ return merged;
41
+ }
42
+ function redactMcpSecret(text, secret) {
43
+ if (!secret || secret.length === 0 || !text.includes(secret)) return text;
44
+ return text.split(secret).join("[REDACTED]");
45
+ }
46
+ function isMcpOnlyLeaf(input) {
47
+ return !input.resolvedPath && Boolean(input.token) && !input.vaultSyncInstalled;
48
+ }
49
+
50
+ export {
51
+ MCP_URL_ENV,
52
+ MCP_TOKEN_ENV,
53
+ MCP_HOST_ID_ENV,
54
+ nonEmptyEnvValue,
55
+ mcpAuthFromEnv,
56
+ resolveMcpAuthEnv,
57
+ mergeMcpAuthIntoEnv,
58
+ redactMcpSecret,
59
+ isMcpOnlyLeaf
60
+ };
@@ -1,17 +1,22 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ defaultLookup
4
+ } from "./chunk-5D5HFQ76.js";
2
5
  import {
3
6
  parseDotenvFile,
4
7
  resolveSkillwikiHome
5
- } from "./chunk-PYPPG7RF.js";
8
+ } from "./chunk-IJKJGQKL.js";
6
9
  import {
7
10
  CLIP_OUTBOX_DEFAULTS,
11
+ ipv4InCidr,
8
12
  isClipOutboxKey,
9
13
  parseClipOutboxKeyValue
10
- } from "./chunk-UIEQIJXP.js";
14
+ } from "./chunk-VNSZCK5V.js";
11
15
 
12
16
  // src/clip-outbox/runtime.ts
13
17
  import { existsSync as existsSync2 } from "fs";
14
18
  import { lstat, readFile } from "fs/promises";
19
+ import { isIP } from "net";
15
20
  import { join as join2 } from "path";
16
21
  import { execFileSync } from "child_process";
17
22
  import { z } from "zod";
@@ -22,6 +27,7 @@ import { homedir } from "os";
22
27
  import { isAbsolute, join, resolve, sep } from "path";
23
28
  var CLIP_NOT_CONFIGURED = "clip channel not configured";
24
29
  var PRODUCTION_WATCHER_LABEL = CLIP_OUTBOX_DEFAULTS.watcher_label;
30
+ var PRODUCTION_MIRROR_LABEL = "com.karlchow.skillwiki-mirror-sse";
25
31
  var RETIRED_WATCHER_LABELS = [
26
32
  "com.karlchow.wiki-dev-outbox",
27
33
  "com.karlchow.wiki-dev-sse",
@@ -188,6 +194,23 @@ async function loadClipOutboxConfig(home) {
188
194
 
189
195
  // src/clip-outbox/runtime.ts
190
196
  var s3Schema = z.object({ endpoint: z.string().url(), region: z.string().default("us-east-1"), accessKeyId: z.string().min(1), secretAccessKey: z.string().min(1), sessionToken: z.string().optional() }).strict();
197
+ function isRfc1918OrLoopback(address) {
198
+ const version = isIP(address);
199
+ if (version === 4) {
200
+ return ipv4InCidr(address, "10.0.0.0", 8) || ipv4InCidr(address, "172.16.0.0", 12) || ipv4InCidr(address, "192.168.0.0", 16) || ipv4InCidr(address, "127.0.0.0", 8);
201
+ }
202
+ return version === 6 && address.toLowerCase() === "::1";
203
+ }
204
+ async function assertClipS3Endpoint(endpoint, lookup = defaultLookup) {
205
+ const url = new URL(endpoint);
206
+ if (url.username || url.password || url.search || url.hash) throw new Error("invalid clip S3 endpoint");
207
+ if (url.protocol === "https:") return;
208
+ if (url.protocol !== "http:") throw new Error("invalid clip S3 endpoint");
209
+ const addresses = isIP(url.hostname) ? [url.hostname] : await lookup(url.hostname);
210
+ if (addresses.length === 0 || !addresses.every(isRfc1918OrLoopback)) {
211
+ throw new Error("http clip S3 endpoint must resolve only to RFC1918 or loopback");
212
+ }
213
+ }
191
214
  async function loadClipCredentials(home, config, env = process.env) {
192
215
  let providers = {};
193
216
  const path = join2(home, ".skillwiki", "clip-outbox-credentials.json");
@@ -201,8 +224,7 @@ async function loadClipCredentials(home, config, env = process.env) {
201
224
  try {
202
225
  const rawS3 = env[config.s3CredentialRef] ?? providers[config.s3CredentialRef];
203
226
  const s3 = s3Schema.parse(typeof rawS3 === "string" ? JSON.parse(rawS3) : rawS3);
204
- const endpoint = new URL(s3.endpoint);
205
- if (endpoint.protocol !== "https:" || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) throw new Error();
227
+ await assertClipS3Endpoint(s3.endpoint);
206
228
  const hostConfig = await parseDotenvFile(join2(home, ".skillwiki", ".env"));
207
229
  const readyToken = env[config.readyCredentialRef] ?? providers[config.readyCredentialRef];
208
230
  if (typeof readyToken !== "string" || !readyToken || config.readyCredentialRef === "SKILLWIKI_MCP_TOKEN" || readyToken === env.SKILLWIKI_MCP_TOKEN || readyToken === hostConfig.SKILLWIKI_MCP_TOKEN || config.readyCredentialRef === config.s3CredentialRef) throw new Error();
@@ -238,11 +260,14 @@ async function validateRuntime(home, config, endpoint) {
238
260
  export {
239
261
  CLIP_NOT_CONFIGURED,
240
262
  PRODUCTION_WATCHER_LABEL,
263
+ PRODUCTION_MIRROR_LABEL,
241
264
  RETIRED_WATCHER_LABELS,
242
265
  expandUserPath,
243
266
  nestedOrEqual,
244
267
  resolveClipOutboxConfig,
245
268
  loadClipOutboxConfig,
269
+ isRfc1918OrLoopback,
270
+ assertClipS3Endpoint,
246
271
  loadClipCredentials,
247
272
  evidencePath,
248
273
  conditionalCreateProven,
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  atomicWriteText
4
- } from "./chunk-GQWCTJTU.js";
4
+ } from "./chunk-6VUK2FTX.js";
5
5
 
6
6
  // src/clip-outbox/journal.ts
7
7
  import { mkdir, readFile, open } from "fs/promises";
@@ -62,7 +62,8 @@ var ExitCode = {
62
62
  APPROVAL_MISMATCH: 57,
63
63
  TARGET_DRIFT: 58,
64
64
  RECOVERY_EVIDENCE_MISSING: 59,
65
- SYNC_PARTIAL_DECLINE: 60
65
+ SYNC_PARTIAL_DECLINE: 60,
66
+ HTML_RENDER_NOT_OPTED_IN: 61
66
67
  };
67
68
 
68
69
  // ../shared/src/json-output.ts
@@ -73,6 +74,48 @@ function err(error, detail) {
73
74
  return detail === void 0 ? { ok: false, error } : { ok: false, error, detail };
74
75
  }
75
76
 
77
+ // ../shared/src/blocked-hosts.ts
78
+ var METADATA_HOSTS = /* @__PURE__ */ new Set([
79
+ "metadata.google.internal",
80
+ "metadata"
81
+ ]);
82
+ var METADATA_IPS = /* @__PURE__ */ new Set(["169.254.169.254"]);
83
+ function ipv4ToInt(ip) {
84
+ const parts = ip.split(".");
85
+ if (parts.length !== 4) return null;
86
+ let n = 0;
87
+ for (const p of parts) {
88
+ const v = Number(p);
89
+ if (!Number.isInteger(v) || v < 0 || v > 255) return null;
90
+ n = (n << 8) + v;
91
+ }
92
+ return n >>> 0;
93
+ }
94
+ function inRange(ip, baseStr, prefix) {
95
+ const ipN = ipv4ToInt(ip);
96
+ const baseN = ipv4ToInt(baseStr);
97
+ if (ipN === null || baseN === null) return false;
98
+ const mask = prefix === 0 ? 0 : ~0 << 32 - prefix >>> 0;
99
+ return (ipN & mask) === (baseN & mask);
100
+ }
101
+ function ipv4InCidr(ip, base, prefix) {
102
+ return inRange(ip, base, prefix);
103
+ }
104
+ function isBlockedHost(host) {
105
+ const lower = host.toLowerCase();
106
+ if (METADATA_HOSTS.has(lower)) return true;
107
+ if (METADATA_IPS.has(host)) return true;
108
+ if (lower === "::1") return true;
109
+ if (lower.startsWith("fe80:")) return true;
110
+ if (ipv4ToInt(host) === null) return false;
111
+ if (inRange(host, "10.0.0.0", 8)) return true;
112
+ if (inRange(host, "172.16.0.0", 12)) return true;
113
+ if (inRange(host, "192.168.0.0", 16)) return true;
114
+ if (inRange(host, "169.254.0.0", 16)) return true;
115
+ if (inRange(host, "127.0.0.0", 8)) return true;
116
+ return false;
117
+ }
118
+
76
119
  // ../shared/src/session-kind.ts
77
120
  function resolveSessionKind(input = {}) {
78
121
  const env = input.env ?? {};
@@ -154,7 +197,9 @@ var TypedKnowledgeSchema = z.object({
154
197
  provenance: z.enum(["research", "project", "mixed"]).optional(),
155
198
  provenance_projects: z.array(wikilink).optional(),
156
199
  work_items: z.array(wikilink).optional(),
157
- stale_ttl: z.number().int().positive().optional()
200
+ stale_ttl: z.number().int().positive().optional(),
201
+ output_standard: z.literal("v2").optional(),
202
+ html_render: z.boolean().optional()
158
203
  }).superRefine((v, ctx) => {
159
204
  if (v.provenance && v.provenance !== "research" && (!v.provenance_projects || v.provenance_projects.length === 0)) {
160
205
  ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["provenance_projects"], message: "required when provenance != research" });
@@ -531,48 +576,6 @@ function detectSchema(fm) {
531
576
  return { schema: null };
532
577
  }
533
578
 
534
- // ../shared/src/blocked-hosts.ts
535
- var METADATA_HOSTS = /* @__PURE__ */ new Set([
536
- "metadata.google.internal",
537
- "metadata"
538
- ]);
539
- var METADATA_IPS = /* @__PURE__ */ new Set(["169.254.169.254"]);
540
- function ipv4ToInt(ip) {
541
- const parts = ip.split(".");
542
- if (parts.length !== 4) return null;
543
- let n = 0;
544
- for (const p of parts) {
545
- const v = Number(p);
546
- if (!Number.isInteger(v) || v < 0 || v > 255) return null;
547
- n = (n << 8) + v;
548
- }
549
- return n >>> 0;
550
- }
551
- function inRange(ip, baseStr, prefix) {
552
- const ipN = ipv4ToInt(ip);
553
- const baseN = ipv4ToInt(baseStr);
554
- if (ipN === null || baseN === null) return false;
555
- const mask = prefix === 0 ? 0 : ~0 << 32 - prefix >>> 0;
556
- return (ipN & mask) === (baseN & mask);
557
- }
558
- function ipv4InCidr(ip, base, prefix) {
559
- return inRange(ip, base, prefix);
560
- }
561
- function isBlockedHost(host) {
562
- const lower = host.toLowerCase();
563
- if (METADATA_HOSTS.has(lower)) return true;
564
- if (METADATA_IPS.has(host)) return true;
565
- if (lower === "::1") return true;
566
- if (lower.startsWith("fe80:")) return true;
567
- if (ipv4ToInt(host) === null) return false;
568
- if (inRange(host, "10.0.0.0", 8)) return true;
569
- if (inRange(host, "172.16.0.0", 12)) return true;
570
- if (inRange(host, "192.168.0.0", 16)) return true;
571
- if (inRange(host, "169.254.0.0", 16)) return true;
572
- if (inRange(host, "127.0.0.0", 8)) return true;
573
- return false;
574
- }
575
-
576
579
  // ../shared/src/error-message.ts
577
580
  function getErrorMessage(e) {
578
581
  return e instanceof Error ? e.message : String(e);
@@ -1,5 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ atomicWriteText
4
+ } from "./chunk-6VUK2FTX.js";
5
+ import {
6
+ PRODUCTION_MIRROR_LABEL,
3
7
  PRODUCTION_WATCHER_LABEL,
4
8
  evidencePath,
5
9
  expandUserPath,
@@ -7,25 +11,22 @@ import {
7
11
  nestedOrEqual,
8
12
  resolveClipOutboxConfig,
9
13
  validateRuntime
10
- } from "./chunk-6OQLBNKR.js";
14
+ } from "./chunk-OWWWO2XS.js";
11
15
  import {
12
16
  parseDotenvFile
13
- } from "./chunk-PYPPG7RF.js";
14
- import {
15
- atomicWriteText
16
- } from "./chunk-GQWCTJTU.js";
17
+ } from "./chunk-IJKJGQKL.js";
17
18
 
18
19
  // src/clip-outbox/migrate.ts
19
20
  import { access, mkdir, readFile } from "fs/promises";
20
21
  import { constants } from "fs";
21
22
  import { basename, dirname, isAbsolute, join } from "path";
22
23
  import { execFileSync } from "child_process";
23
- var CLIP_MIGRATION_HINT = "Clip channel requires attended setup: run skillwiki clip-outbox install-or-migrate, then skillwiki doctor. Installation/update never sets clip_outbox.enabled=true or loads launchd. Prove deployed SeaweedFS If-None-Match:* create-only before operator enablement.";
24
+ var CLIP_MIGRATION_HINT = "Clip channel requires attended setup: run skillwiki clip-outbox install-or-migrate, then skillwiki doctor. Installation/update never sets clip_outbox.enabled=true or loads launchd. Prove deployed SeaweedFS If-None-Match:* create-only before --load. Inbound SSE uses a separate --load-mirror and never watches ~/wiki as clip input.";
24
25
  var xml = (s) => s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
25
- async function packagedPlist() {
26
+ async function packagedPlist(label = PRODUCTION_WATCHER_LABEL) {
26
27
  for (const rel of ["../templates/launchd/", "../../templates/launchd/"]) {
27
28
  try {
28
- return await readFile(new URL(`${rel}${PRODUCTION_WATCHER_LABEL}.plist`, import.meta.url), "utf8");
29
+ return await readFile(new URL(`${rel}${label}.plist`, import.meta.url), "utf8");
29
30
  } catch {
30
31
  }
31
32
  }
@@ -48,6 +49,7 @@ async function migrateClipOutbox(input) {
48
49
  const resolved = resolveClipOutboxConfig(map, input.home);
49
50
  if (!resolved.ok) throw new Error(resolved.message);
50
51
  if (input.load && (!resolved.enabled || !input.conditionalCreateProven)) throw new Error("--load requires existing clip_outbox.enabled=true and --conditional-create-proven after the operator's deployed create-only race test");
52
+ if (input.loadMirror && !resolved.enabled) throw new Error("--load-mirror requires existing clip_outbox.enabled=true; upgrades never auto-load the inbound SSE listener");
51
53
  if ((input.platform ?? process.platform) !== "darwin") throw new Error("clip launchd migration requires macOS");
52
54
  const rawRoot = map["clip_outbox.root"] ?? "~/Obsidian/clip-outbox";
53
55
  const root = resolved.enabled ? resolved.config.root : expandUserPath(rawRoot, input.home);
@@ -58,9 +60,16 @@ async function migrateClipOutbox(input) {
58
60
  if (!isAbsolute(binary) || !isAbsolute(input.nodePath ?? process.execPath)) throw new Error("absolute executable paths required");
59
61
  const logRoot = join(input.home, "Library", "Logs", "SkillWiki");
60
62
  const plistPath = join(input.home, "Library", "LaunchAgents", `${PRODUCTION_WATCHER_LABEL}.plist`);
61
- const template = await packagedPlist();
62
- let plist = template;
63
- for (const [key, value] of Object.entries({ NODE_BINARY: input.nodePath ?? process.execPath, CLI_BINARY: binary, OUTBOX_ROOT: root, HOME: input.home, LOG_ROOT: logRoot })) plist = plist.replaceAll(`@${key}@`, xml(value));
63
+ const mirrorPlistPath = join(input.home, "Library", "LaunchAgents", `${PRODUCTION_MIRROR_LABEL}.plist`);
64
+ const replacements = { NODE_BINARY: input.nodePath ?? process.execPath, CLI_BINARY: binary, OUTBOX_ROOT: root, HOME: input.home, LOG_ROOT: logRoot };
65
+ const render = async (label) => {
66
+ let plist = await packagedPlist(label);
67
+ for (const [key, value] of Object.entries(replacements)) plist = plist.replaceAll(`@${key}@`, xml(value));
68
+ if (label === PRODUCTION_MIRROR_LABEL && plist.includes("<key>WatchPaths</key>")) throw new Error("inbound SSE plist must not watch the frozen mirror");
69
+ return plist;
70
+ };
71
+ const watcherPlist = await render(PRODUCTION_WATCHER_LABEL);
72
+ const mirrorPlist = await render(PRODUCTION_MIRROR_LABEL);
64
73
  if (input.load) {
65
74
  if (!resolved.enabled) throw new Error("--load requires existing clip_outbox.enabled=true and --conditional-create-proven after the operator's deployed create-only race test");
66
75
  const creds = await loadClipCredentials(input.home, resolved.config);
@@ -71,22 +80,37 @@ async function migrateClipOutbox(input) {
71
80
  }
72
81
  await mkdir(dirname(plistPath), { recursive: true });
73
82
  await mkdir(logRoot, { recursive: true });
74
- const result = await atomicWriteText(plistPath, plist);
83
+ const result = await atomicWriteText(plistPath, watcherPlist);
75
84
  if (!result.ok) throw new Error("cannot refresh clip plist");
76
- if (input.load) {
85
+ const mirrorResult = await atomicWriteText(mirrorPlistPath, mirrorPlist);
86
+ if (!mirrorResult.ok) throw new Error("cannot refresh inbound SSE plist");
87
+ const bootstrap = (label, path) => {
77
88
  if (typeof process.getuid !== "function") throw new Error("clip launchd load requires macOS");
78
89
  const domain = `gui/${process.getuid()}`;
79
90
  const launch = input.launch ?? ((args) => {
80
91
  execFileSync("launchctl", args, { stdio: "ignore", timeout: 5e3 });
81
92
  });
82
93
  try {
83
- launch(["bootout", `${domain}/${PRODUCTION_WATCHER_LABEL}`]);
94
+ launch(["bootout", `${domain}/${label}`]);
84
95
  } catch {
85
96
  }
86
- launch(["enable", `${domain}/${PRODUCTION_WATCHER_LABEL}`]);
87
- launch(["bootstrap", domain, plistPath]);
88
- }
89
- return { plistPath, loaded: !!input.load, enabled: resolved.enabled, humanHint: `${result.data.changed ? "Refreshed" : "Current"} clip launchd plist: ${plistPath}. ${input.load ? "Operator-gated launchd bootstrap requested." : CLIP_MIGRATION_HINT}` };
97
+ launch(["enable", `${domain}/${label}`]);
98
+ launch(["bootstrap", domain, path]);
99
+ };
100
+ if (input.load) bootstrap(PRODUCTION_WATCHER_LABEL, plistPath);
101
+ if (input.loadMirror) bootstrap(PRODUCTION_MIRROR_LABEL, mirrorPlistPath);
102
+ const loadedBits = [
103
+ input.load ? "Operator-gated clip watcher bootstrap requested." : void 0,
104
+ input.loadMirror ? "Operator-gated inbound SSE bootstrap requested." : void 0
105
+ ].filter(Boolean).join(" ");
106
+ return {
107
+ plistPath,
108
+ mirrorPlistPath,
109
+ loaded: !!input.load,
110
+ mirrorLoaded: !!input.loadMirror,
111
+ enabled: resolved.enabled,
112
+ humanHint: `${result.data.changed ? "Refreshed" : "Current"} clip launchd plist: ${plistPath}. ${mirrorResult.data.changed ? "Refreshed" : "Current"} inbound SSE plist: ${mirrorPlistPath}. ${loadedBits || CLIP_MIGRATION_HINT}`
113
+ };
90
114
  }
91
115
  async function refreshInstalledClipPlist(home, opts) {
92
116
  const plistPath = join(home, "Library", "LaunchAgents", `${PRODUCTION_WATCHER_LABEL}.plist`);