@codyswann/lisa 2.23.2 → 2.25.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 (150) hide show
  1. package/.claude-plugin/marketplace.json +6 -0
  2. package/package.json +1 -1
  3. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  4. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  5. package/plugins/lisa/commands/setup/github.md +7 -0
  6. package/plugins/lisa/commands/setup/linear.md +7 -0
  7. package/plugins/lisa/skills/setup-github/SKILL.md +199 -0
  8. package/plugins/lisa/skills/setup-linear/SKILL.md +217 -0
  9. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  10. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  11. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  13. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  15. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  17. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  19. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  21. package/plugins/lisa-wiki/.claude-plugin/plugin.json +8 -0
  22. package/plugins/lisa-wiki/.codex-plugin/plugin.json +32 -0
  23. package/plugins/lisa-wiki/ci/lisa-wiki-validate.yml +32 -0
  24. package/plugins/lisa-wiki/commands/add-ingest.md +6 -0
  25. package/plugins/lisa-wiki/commands/add-role.md +6 -0
  26. package/plugins/lisa-wiki/commands/doctor.md +6 -0
  27. package/plugins/lisa-wiki/commands/ingest.md +6 -0
  28. package/plugins/lisa-wiki/commands/lint.md +6 -0
  29. package/plugins/lisa-wiki/commands/migrate.md +6 -0
  30. package/plugins/lisa-wiki/commands/onboard-me.md +6 -0
  31. package/plugins/lisa-wiki/commands/query.md +6 -0
  32. package/plugins/lisa-wiki/commands/setup.md +6 -0
  33. package/plugins/lisa-wiki/schema/lisa-wiki-config.schema.json +118 -0
  34. package/plugins/lisa-wiki/schema/wiki-structure.schema.json +51 -0
  35. package/plugins/lisa-wiki/scripts/_wiki-lib.mjs +185 -0
  36. package/plugins/lisa-wiki/scripts/diff-guard.mjs +116 -0
  37. package/plugins/lisa-wiki/scripts/ingest-git.mjs +189 -0
  38. package/plugins/lisa-wiki/scripts/ingest-memory.mjs +130 -0
  39. package/plugins/lisa-wiki/scripts/ingest-roles.mjs +85 -0
  40. package/plugins/lisa-wiki/scripts/ingest_slack_channel.py +329 -0
  41. package/plugins/lisa-wiki/scripts/lint-wiki.mjs +320 -0
  42. package/plugins/lisa-wiki/scripts/mcp-doctor.mjs +72 -0
  43. package/plugins/lisa-wiki/scripts/render-contract.mjs +107 -0
  44. package/plugins/lisa-wiki/scripts/rewrite-refs.mjs +144 -0
  45. package/plugins/lisa-wiki/scripts/slack_oauth_user.py +179 -0
  46. package/plugins/lisa-wiki/scripts/validate-config.mjs +232 -0
  47. package/plugins/lisa-wiki/scripts/verify-migration.mjs +199 -0
  48. package/plugins/lisa-wiki/skills/lisa-wiki-add-ingest/SKILL.md +34 -0
  49. package/plugins/lisa-wiki/skills/lisa-wiki-add-role/SKILL.md +30 -0
  50. package/plugins/lisa-wiki/skills/lisa-wiki-connector-confluence/SKILL.md +25 -0
  51. package/plugins/lisa-wiki/skills/lisa-wiki-connector-docs/SKILL.md +30 -0
  52. package/plugins/lisa-wiki/skills/lisa-wiki-connector-git/SKILL.md +25 -0
  53. package/plugins/lisa-wiki/skills/lisa-wiki-connector-jira/SKILL.md +28 -0
  54. package/plugins/lisa-wiki/skills/lisa-wiki-connector-memory/SKILL.md +28 -0
  55. package/plugins/lisa-wiki/skills/lisa-wiki-connector-notion/SKILL.md +25 -0
  56. package/plugins/lisa-wiki/skills/lisa-wiki-connector-roles/SKILL.md +22 -0
  57. package/plugins/lisa-wiki/skills/lisa-wiki-connector-slack/SKILL.md +30 -0
  58. package/plugins/lisa-wiki/skills/lisa-wiki-connector-web/SKILL.md +23 -0
  59. package/plugins/lisa-wiki/skills/lisa-wiki-doctor/SKILL.md +47 -0
  60. package/plugins/lisa-wiki/skills/lisa-wiki-ingest/SKILL.md +43 -0
  61. package/plugins/lisa-wiki/skills/lisa-wiki-lint/SKILL.md +32 -0
  62. package/plugins/lisa-wiki/skills/lisa-wiki-migrate/SKILL.md +43 -0
  63. package/plugins/lisa-wiki/skills/lisa-wiki-onboard-me/SKILL.md +33 -0
  64. package/plugins/lisa-wiki/skills/lisa-wiki-query/SKILL.md +30 -0
  65. package/plugins/lisa-wiki/skills/lisa-wiki-setup/SKILL.md +45 -0
  66. package/plugins/lisa-wiki/skills/lisa-wiki-usage/SKILL.md +50 -0
  67. package/plugins/lisa-wiki/templates/agents/role-agent.claude.md +16 -0
  68. package/plugins/lisa-wiki/templates/agents/role-agent.codex.toml +15 -0
  69. package/plugins/lisa-wiki/templates/index.md +17 -0
  70. package/plugins/lisa-wiki/templates/llm-wiki-contract.md +60 -0
  71. package/plugins/lisa-wiki/templates/log.md +8 -0
  72. package/plugins/lisa-wiki/templates/page-types/architecture.md +18 -0
  73. package/plugins/lisa-wiki/templates/page-types/concept.md +18 -0
  74. package/plugins/lisa-wiki/templates/page-types/decision.md +18 -0
  75. package/plugins/lisa-wiki/templates/page-types/entity.md +19 -0
  76. package/plugins/lisa-wiki/templates/page-types/open-question.md +18 -0
  77. package/plugins/lisa-wiki/templates/page-types/playbook.md +18 -0
  78. package/plugins/lisa-wiki/templates/page-types/project.md +19 -0
  79. package/plugins/lisa-wiki/templates/page-types/requirement.md +19 -0
  80. package/plugins/lisa-wiki/templates/page-types/staff.md +26 -0
  81. package/plugins/lisa-wiki/templates/start-here.md +24 -0
  82. package/plugins/lisa-wiki/templates/state-readme.md +20 -0
  83. package/plugins/src/base/commands/setup/github.md +7 -0
  84. package/plugins/src/base/commands/setup/linear.md +7 -0
  85. package/plugins/src/base/skills/setup-github/SKILL.md +199 -0
  86. package/plugins/src/base/skills/setup-linear/SKILL.md +217 -0
  87. package/plugins/src/wiki/.claude-plugin/plugin.json +6 -0
  88. package/plugins/src/wiki/ci/lisa-wiki-validate.yml +32 -0
  89. package/plugins/src/wiki/commands/add-ingest.md +6 -0
  90. package/plugins/src/wiki/commands/add-role.md +6 -0
  91. package/plugins/src/wiki/commands/doctor.md +6 -0
  92. package/plugins/src/wiki/commands/ingest.md +6 -0
  93. package/plugins/src/wiki/commands/lint.md +6 -0
  94. package/plugins/src/wiki/commands/migrate.md +6 -0
  95. package/plugins/src/wiki/commands/onboard-me.md +6 -0
  96. package/plugins/src/wiki/commands/query.md +6 -0
  97. package/plugins/src/wiki/commands/setup.md +6 -0
  98. package/plugins/src/wiki/schema/lisa-wiki-config.schema.json +118 -0
  99. package/plugins/src/wiki/schema/wiki-structure.schema.json +51 -0
  100. package/plugins/src/wiki/scripts/_wiki-lib.mjs +185 -0
  101. package/plugins/src/wiki/scripts/diff-guard.mjs +116 -0
  102. package/plugins/src/wiki/scripts/ingest-git.mjs +189 -0
  103. package/plugins/src/wiki/scripts/ingest-memory.mjs +130 -0
  104. package/plugins/src/wiki/scripts/ingest-roles.mjs +85 -0
  105. package/plugins/src/wiki/scripts/ingest_slack_channel.py +329 -0
  106. package/plugins/src/wiki/scripts/lint-wiki.mjs +320 -0
  107. package/plugins/src/wiki/scripts/mcp-doctor.mjs +72 -0
  108. package/plugins/src/wiki/scripts/render-contract.mjs +107 -0
  109. package/plugins/src/wiki/scripts/rewrite-refs.mjs +144 -0
  110. package/plugins/src/wiki/scripts/slack_oauth_user.py +179 -0
  111. package/plugins/src/wiki/scripts/validate-config.mjs +232 -0
  112. package/plugins/src/wiki/scripts/verify-migration.mjs +199 -0
  113. package/plugins/src/wiki/skills/lisa-wiki-add-ingest/SKILL.md +34 -0
  114. package/plugins/src/wiki/skills/lisa-wiki-add-role/SKILL.md +30 -0
  115. package/plugins/src/wiki/skills/lisa-wiki-connector-confluence/SKILL.md +25 -0
  116. package/plugins/src/wiki/skills/lisa-wiki-connector-docs/SKILL.md +30 -0
  117. package/plugins/src/wiki/skills/lisa-wiki-connector-git/SKILL.md +25 -0
  118. package/plugins/src/wiki/skills/lisa-wiki-connector-jira/SKILL.md +28 -0
  119. package/plugins/src/wiki/skills/lisa-wiki-connector-memory/SKILL.md +28 -0
  120. package/plugins/src/wiki/skills/lisa-wiki-connector-notion/SKILL.md +25 -0
  121. package/plugins/src/wiki/skills/lisa-wiki-connector-roles/SKILL.md +22 -0
  122. package/plugins/src/wiki/skills/lisa-wiki-connector-slack/SKILL.md +30 -0
  123. package/plugins/src/wiki/skills/lisa-wiki-connector-web/SKILL.md +23 -0
  124. package/plugins/src/wiki/skills/lisa-wiki-doctor/SKILL.md +47 -0
  125. package/plugins/src/wiki/skills/lisa-wiki-ingest/SKILL.md +43 -0
  126. package/plugins/src/wiki/skills/lisa-wiki-lint/SKILL.md +32 -0
  127. package/plugins/src/wiki/skills/lisa-wiki-migrate/SKILL.md +43 -0
  128. package/plugins/src/wiki/skills/lisa-wiki-onboard-me/SKILL.md +33 -0
  129. package/plugins/src/wiki/skills/lisa-wiki-query/SKILL.md +30 -0
  130. package/plugins/src/wiki/skills/lisa-wiki-setup/SKILL.md +45 -0
  131. package/plugins/src/wiki/skills/lisa-wiki-usage/SKILL.md +50 -0
  132. package/plugins/src/wiki/templates/agents/role-agent.claude.md +16 -0
  133. package/plugins/src/wiki/templates/agents/role-agent.codex.toml +15 -0
  134. package/plugins/src/wiki/templates/index.md +17 -0
  135. package/plugins/src/wiki/templates/llm-wiki-contract.md +60 -0
  136. package/plugins/src/wiki/templates/log.md +8 -0
  137. package/plugins/src/wiki/templates/page-types/architecture.md +18 -0
  138. package/plugins/src/wiki/templates/page-types/concept.md +18 -0
  139. package/plugins/src/wiki/templates/page-types/decision.md +18 -0
  140. package/plugins/src/wiki/templates/page-types/entity.md +19 -0
  141. package/plugins/src/wiki/templates/page-types/open-question.md +18 -0
  142. package/plugins/src/wiki/templates/page-types/playbook.md +18 -0
  143. package/plugins/src/wiki/templates/page-types/project.md +19 -0
  144. package/plugins/src/wiki/templates/page-types/requirement.md +19 -0
  145. package/plugins/src/wiki/templates/page-types/staff.md +26 -0
  146. package/plugins/src/wiki/templates/start-here.md +24 -0
  147. package/plugins/src/wiki/templates/state-readme.md +20 -0
  148. package/scripts/build-plugins.sh +29 -21
  149. package/scripts/check-plugins-sync.sh +1 -1
  150. package/scripts/generate-codex-plugin-artifacts.mjs +22 -0
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mcp-doctor.mjs — inspect-and-report MCP wiring for the wiki's MCP connectors.
4
+ * Dependency-free. Print-only: it never edits .mcp.json / .codex/config.toml or any
5
+ * auth file — it reports PASS/MISSING and prints setup snippets for the user to apply.
6
+ *
7
+ * Usage: node mcp-doctor.mjs [--config <p>] [--repo <dir>]
8
+ * Exit 0 always (advisory). Used by /setup --doctor and /doctor group D.
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { loadConfig } from "./_wiki-lib.mjs";
13
+
14
+ const argv = process.argv.slice(2);
15
+ const opt = (n, d) => {
16
+ const i = argv.indexOf(n);
17
+ return i !== -1 ? argv[i + 1] : d;
18
+ };
19
+ const repo = path.resolve(opt("--repo", "."));
20
+ const { config } = loadConfig(opt("--config"));
21
+
22
+ // Connectors that require an MCP server, and the server name to look for.
23
+ const MCP_CONNECTORS = {
24
+ jira: "atlassian",
25
+ confluence: "atlassian",
26
+ notion: "notion",
27
+ };
28
+
29
+ const mcpJsonPath = path.join(repo, ".mcp.json");
30
+ const codexTomlPath = path.join(repo, ".codex", "config.toml");
31
+ const mcpJson = fs.existsSync(mcpJsonPath)
32
+ ? fs.readFileSync(mcpJsonPath, "utf8")
33
+ : "";
34
+ const codexToml = fs.existsSync(codexTomlPath)
35
+ ? fs.readFileSync(codexTomlPath, "utf8")
36
+ : "";
37
+
38
+ const enabled = Object.entries(config?.connectors ?? {}).filter(
39
+ ([name, c]) => c?.enabled && name in MCP_CONNECTORS
40
+ );
41
+
42
+ if (enabled.length === 0) {
43
+ console.log(
44
+ "mcp-doctor: no MCP-backed connectors enabled — nothing to check."
45
+ );
46
+ process.exit(0);
47
+ }
48
+
49
+ let missing = 0;
50
+ for (const [name] of enabled) {
51
+ const server = MCP_CONNECTORS[name];
52
+ const inClaude = mcpJson.includes(`"${server}"`);
53
+ const inCodex = codexToml.includes(`[mcp_servers.${server}]`);
54
+ const status = inClaude || inCodex ? "PASS" : "MISSING";
55
+ if (status === "MISSING") missing += 1;
56
+ console.log(
57
+ `${status === "PASS" ? "✓" : "⚠"} ${name} → MCP server "${server}": Claude=${inClaude ? "yes" : "no"} Codex=${inCodex ? "yes" : "no"}`
58
+ );
59
+ if (status === "MISSING") {
60
+ console.log(
61
+ ` Add to .mcp.json: { "mcpServers": { "${server}": { ... } } }`
62
+ );
63
+ console.log(` Add to .codex/config.toml: [mcp_servers.${server}]`);
64
+ console.log(
65
+ ` Then authenticate per the ${server} MCP instructions (project-owned auth).`
66
+ );
67
+ }
68
+ }
69
+
70
+ console.log(
71
+ `\nmcp-doctor: ${enabled.length - missing}/${enabled.length} MCP connector(s) wired${missing ? ` — ${missing} MISSING (connector disabled until wired)` : ""}.`
72
+ );
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * render-contract.mjs — render the repo-local contract snapshot from the plugin
4
+ * template + the project's config. Dependency-free.
5
+ *
6
+ * The plugin owns the canonical contract template (templates/llm-wiki-contract.md);
7
+ * this script materializes wiki/schema/llm-wiki-contract.md so the wiki stays
8
+ * readable/maintainable even without the plugin installed. The kernelVersion is
9
+ * read from the built plugin manifest and stamped into the snapshot.
10
+ *
11
+ * Usage: node render-contract.mjs [path-to-config] [--out <path>]
12
+ * default config: wiki/lisa-wiki.config.json (relative to cwd)
13
+ * default out: <wikiRoot>/schema/llm-wiki-contract.md
14
+ * Exit code 0 = rendered, 1 = error.
15
+ */
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ function fail(msg) {
21
+ console.error(`✗ ${msg}`);
22
+ process.exit(1);
23
+ }
24
+
25
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
26
+ const pluginRoot = path.dirname(scriptDir);
27
+ const templatePath = path.join(pluginRoot, "templates", "llm-wiki-contract.md");
28
+ const manifestPath = path.join(pluginRoot, ".claude-plugin", "plugin.json");
29
+
30
+ const argv = process.argv.slice(2);
31
+ const outFlagIdx = argv.indexOf("--out");
32
+ let outOverride;
33
+ if (outFlagIdx !== -1) {
34
+ outOverride = argv[outFlagIdx + 1];
35
+ if (!outOverride || outOverride.startsWith("--"))
36
+ fail("--out requires a path argument");
37
+ }
38
+ const configArg = argv.find(
39
+ (a, i) => !a.startsWith("--") && i !== outFlagIdx + 1
40
+ );
41
+ const configPath = path.resolve(configArg ?? "wiki/lisa-wiki.config.json");
42
+
43
+ if (!fs.existsSync(templatePath))
44
+ fail(`contract template missing: ${templatePath}`);
45
+ if (!fs.existsSync(configPath)) fail(`config not found: ${configPath}`);
46
+
47
+ let config;
48
+ try {
49
+ config = JSON.parse(fs.readFileSync(configPath, "utf8"));
50
+ } catch (e) {
51
+ fail(`config is not valid JSON: ${e.message}`);
52
+ }
53
+
54
+ let kernelVersion = "0.0.0";
55
+ try {
56
+ kernelVersion =
57
+ JSON.parse(fs.readFileSync(manifestPath, "utf8")).version ?? kernelVersion;
58
+ } catch {
59
+ // manifest may be absent in the src tree before build; leave default.
60
+ }
61
+
62
+ const enabledConnectors = Object.entries(config.connectors ?? {})
63
+ .filter(([, c]) => c && c.enabled)
64
+ .map(([name]) => name);
65
+
66
+ const subs = {
67
+ org: config.org ?? "",
68
+ displayName: config.displayName ?? config.org ?? "LLM Wiki",
69
+ purpose: config.purpose ?? "(purpose not set — run /setup to define it)",
70
+ mode: config.mode ?? "",
71
+ wikiRoot: config.wikiRoot ?? "wiki",
72
+ kernelVersion,
73
+ schemaVersion: config.schemaVersion ?? "",
74
+ categories: (config.categories ?? []).join(", "),
75
+ connectors: enabledConnectors.length
76
+ ? enabledConnectors.join(", ")
77
+ : "(none enabled)",
78
+ sourceLayout: config.sources?.layout ?? "by-system",
79
+ sourceRetention: config.sourceRetention ?? "sanitized-note-only",
80
+ sensitivityDefault: config.sensitivity?.default ?? "internal",
81
+ prPolicy: config.git?.prPerIngestion
82
+ ? `PR per ingestion to ${config.git?.targetBranch ?? "main"}${config.git?.autoMerge ? " (auto-merge)" : ""}`
83
+ : "no PR per ingestion",
84
+ readmeMode: config.readme?.mode ?? "rich",
85
+ generatedDate: new Date().toISOString().slice(0, 10),
86
+ };
87
+
88
+ let rendered = fs.readFileSync(templatePath, "utf8");
89
+ rendered = rendered.replace(/\{\{(\w+)\}\}/g, (match, key) =>
90
+ key in subs ? String(subs[key]) : match
91
+ );
92
+
93
+ const leftover = [
94
+ ...new Set([...rendered.matchAll(/\{\{(\w+)\}\}/g)].map(m => m[1])),
95
+ ];
96
+ if (leftover.length) {
97
+ fail(`unresolved template tokens in contract: ${leftover.join(", ")}`);
98
+ }
99
+
100
+ const outPath = path.resolve(
101
+ outOverride ?? path.join(subs.wikiRoot, "schema", "llm-wiki-contract.md")
102
+ );
103
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
104
+ fs.writeFileSync(outPath, rendered);
105
+ console.log(
106
+ `✓ rendered contract → ${path.relative(process.cwd(), outPath)} (kernelVersion ${kernelVersion})`
107
+ );
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * rewrite-refs.mjs — deterministically fix wiki references after files have moved.
4
+ * Dependency-free. Assumes the files are ALREADY moved (e.g. by absorb-docs/migrate
5
+ * via `git mv`); this script only repairs references and verifies no dangling links.
6
+ *
7
+ * It correctly handles both directions:
8
+ * - a moved file's relative links to unmoved targets (re-based to its new location),
9
+ * - unmoved files' relative links to moved targets,
10
+ * - moved → moved,
11
+ * - plain-text `Source: <path>` citations (wiki-root-relative).
12
+ *
13
+ * Usage:
14
+ * node rewrite-refs.mjs --wiki <root> --move <oldPath>:<newPath> [--move ...] [--check]
15
+ * paths are resolved relative to CWD; --check verifies only (no writes).
16
+ * Exit 0 = rewritten/clean, 1 = dangling links remain (or bad args).
17
+ */
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { walkFiles, extractMarkdownLinks } from "./_wiki-lib.mjs";
21
+
22
+ const argv = process.argv.slice(2);
23
+ const check = argv.includes("--check");
24
+ const wikiArgIdx = argv.indexOf("--wiki");
25
+ if (
26
+ wikiArgIdx !== -1 &&
27
+ (!argv[wikiArgIdx + 1] || argv[wikiArgIdx + 1].startsWith("--"))
28
+ ) {
29
+ console.error("✗ --wiki requires a path argument");
30
+ process.exit(1);
31
+ }
32
+ const wikiRoot = path.resolve(
33
+ wikiArgIdx !== -1 ? argv[wikiArgIdx + 1] : "wiki"
34
+ );
35
+ const moves = [];
36
+ for (let i = 0; i < argv.length; i += 1) {
37
+ if (argv[i] === "--move") {
38
+ const spec = argv[i + 1] ?? "";
39
+ const sep = spec.lastIndexOf(":");
40
+ if (sep === -1) {
41
+ console.error(`✗ bad --move "${spec}" (expected old:new)`);
42
+ process.exit(1);
43
+ }
44
+ moves.push([
45
+ path.resolve(spec.slice(0, sep)),
46
+ path.resolve(spec.slice(sep + 1)),
47
+ ]);
48
+ i += 1;
49
+ }
50
+ }
51
+
52
+ const moveMap = new Map(moves); // oldAbs -> newAbs
53
+ const newToOld = new Map(moves.map(([o, n]) => [n, o])); // newAbs -> oldAbs
54
+ const currentLocation = abs => moveMap.get(abs) ?? abs;
55
+ const toPosix = p => p.split(path.sep).join("/");
56
+
57
+ const isExternal = t =>
58
+ !t ||
59
+ t.startsWith("#") ||
60
+ /^[a-z][a-z0-9+.-]*:\/\//i.test(t) ||
61
+ t.startsWith("mailto:") ||
62
+ t.includes("{{") ||
63
+ t.includes("<") ||
64
+ t.includes(">");
65
+
66
+ let rewriteCount = 0;
67
+ const mdFiles = walkFiles(wikiRoot, { ext: ".md" });
68
+
69
+ for (const file of mdFiles) {
70
+ const oldOfFile = newToOld.get(file) ?? file; // where this file's links were authored relative to
71
+ const authorBase = path.dirname(oldOfFile);
72
+ const curDir = path.dirname(file);
73
+ const original = fs.readFileSync(file, "utf8");
74
+
75
+ // 1) markdown links
76
+ let updated = original.replace(
77
+ /(\]\()([^)]+)(\))/g,
78
+ (whole, open, target, close) => {
79
+ // split off an optional markdown link title: (url "title") — preserve it
80
+ const tm = target.match(/^(\S+)(\s[\s\S]*)?$/);
81
+ if (!tm) return whole;
82
+ const urlAnchor = tm[1];
83
+ const title = tm[2] ?? "";
84
+ if (isExternal(urlAnchor)) return whole;
85
+ const [pathPart, anchor] = urlAnchor.split("#");
86
+ const resolvedFromAuthor = path.resolve(authorBase, pathPart);
87
+ const correctAbs = currentLocation(resolvedFromAuthor);
88
+ let nextRel = toPosix(path.relative(curDir, correctAbs));
89
+ if (!nextRel.startsWith(".")) nextRel = `./${nextRel}`;
90
+ const nextUrl = anchor !== undefined ? `${nextRel}#${anchor}` : nextRel;
91
+ const nextTarget = `${nextUrl}${title}`;
92
+ return nextTarget === target ? whole : `${open}${nextTarget}${close}`;
93
+ }
94
+ );
95
+
96
+ // 2) plain-text citations: Source: <wiki-root-relative path>
97
+ updated = updated.replace(
98
+ /(Source:\s*)([^\s)]+\.md)/g,
99
+ (whole, pre, cite) => {
100
+ if (cite.includes("{{") || cite.includes("<")) return whole;
101
+ // citations are wiki-root-relative (or repo-relative); map via moveMap if they point to a moved file
102
+ const candidates = [
103
+ path.resolve(wikiRoot, cite),
104
+ path.resolve(wikiRoot, "..", cite),
105
+ ];
106
+ const hit = candidates.find(c => moveMap.has(c));
107
+ if (!hit) return whole;
108
+ const nextAbs = moveMap.get(hit);
109
+ const nextCite = toPosix(path.relative(wikiRoot, nextAbs));
110
+ return `${pre}${nextCite}`;
111
+ }
112
+ );
113
+
114
+ if (updated !== original) {
115
+ rewriteCount += 1;
116
+ if (!check) fs.writeFileSync(file, updated);
117
+ }
118
+ }
119
+
120
+ // verification: no dangling internal .md links remain (at current locations)
121
+ const dangling = [];
122
+ for (const file of mdFiles) {
123
+ const text = fs.readFileSync(file, "utf8");
124
+ for (const target of extractMarkdownLinks(text)) {
125
+ const resolved = path.resolve(path.dirname(file), target);
126
+ if (resolved.endsWith(".md") && !fs.existsSync(resolved)) {
127
+ dangling.push(`${toPosix(path.relative(wikiRoot, file))} → ${target}`);
128
+ }
129
+ }
130
+ }
131
+
132
+ if (dangling.length > 0) {
133
+ console.error(
134
+ `✗ ${dangling.length} dangling internal link(s) ${check ? "found" : "remain after rewrite"}:`
135
+ );
136
+ for (const d of dangling) console.error(` - ${d}`);
137
+ process.exit(1);
138
+ }
139
+
140
+ console.log(
141
+ check
142
+ ? `✓ no dangling internal links (${mdFiles.length} files checked).`
143
+ : `✓ rewrote refs in ${rewriteCount} file(s); no dangling internal links remain.`
144
+ );
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env python3
2
+ """Run a local Slack OAuth flow for a user token.
3
+
4
+ This helper intentionally requests user scopes, not bot scopes. It stores the
5
+ OAuth response in an ignored local file by default; do not commit that file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import base64
12
+ import json
13
+ import os
14
+ import secrets
15
+ import stat
16
+ import sys
17
+ import urllib.parse
18
+ import urllib.request
19
+ import webbrowser
20
+ from http.server import BaseHTTPRequestHandler, HTTPServer
21
+ from pathlib import Path
22
+
23
+
24
+ DEFAULT_SCOPES = ",".join(
25
+ [
26
+ "channels:read",
27
+ "channels:history",
28
+ "groups:read",
29
+ "groups:history",
30
+ "users:read",
31
+ "files:read",
32
+ ]
33
+ )
34
+
35
+
36
+ class OAuthHandler(BaseHTTPRequestHandler):
37
+ server: "OAuthServer"
38
+
39
+ def log_message(self, fmt: str, *args: object) -> None:
40
+ return
41
+
42
+ def do_GET(self) -> None: # noqa: N802
43
+ parsed = urllib.parse.urlparse(self.path)
44
+ params = urllib.parse.parse_qs(parsed.query)
45
+ state = params.get("state", [""])[0]
46
+ code = params.get("code", [""])[0]
47
+ error = params.get("error", [""])[0]
48
+
49
+ if error:
50
+ self.server.error = error
51
+ self._respond(400, f"Slack OAuth failed: {error}")
52
+ return
53
+
54
+ if state != self.server.expected_state:
55
+ self.server.error = "state_mismatch"
56
+ self._respond(400, "Slack OAuth failed: state mismatch.")
57
+ return
58
+
59
+ if not code:
60
+ self.server.error = "missing_code"
61
+ self._respond(400, "Slack OAuth failed: missing code.")
62
+ return
63
+
64
+ self.server.code = code
65
+ self._respond(200, "Slack OAuth complete. You can return to the terminal.")
66
+
67
+ def _respond(self, status: int, body: str) -> None:
68
+ data = body.encode("utf-8")
69
+ self.send_response(status)
70
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
71
+ self.send_header("Content-Length", str(len(data)))
72
+ self.end_headers()
73
+ self.wfile.write(data)
74
+
75
+
76
+ class OAuthServer(HTTPServer):
77
+ expected_state: str
78
+ code: str | None
79
+ error: str | None
80
+
81
+
82
+ def exchange_code(
83
+ *,
84
+ client_id: str,
85
+ client_secret: str,
86
+ code: str,
87
+ redirect_uri: str,
88
+ ) -> dict:
89
+ body = urllib.parse.urlencode(
90
+ {
91
+ "grant_type": "authorization_code",
92
+ "code": code,
93
+ "redirect_uri": redirect_uri,
94
+ }
95
+ ).encode("utf-8")
96
+ basic = base64.b64encode(f"{client_id}:{client_secret}".encode("utf-8")).decode("ascii")
97
+ request = urllib.request.Request(
98
+ "https://slack.com/api/oauth.v2.access",
99
+ data=body,
100
+ headers={
101
+ "Authorization": f"Basic {basic}",
102
+ "Content-Type": "application/x-www-form-urlencoded",
103
+ "Accept": "application/json",
104
+ },
105
+ method="POST",
106
+ )
107
+ with urllib.request.urlopen(request, timeout=30) as response:
108
+ payload = json.loads(response.read().decode("utf-8"))
109
+ if not payload.get("ok"):
110
+ raise RuntimeError(f"Slack token exchange failed: {payload}")
111
+ return payload
112
+
113
+
114
+ def main() -> int:
115
+ parser = argparse.ArgumentParser(description=__doc__)
116
+ parser.add_argument("--client-id", default=os.environ.get("SLACK_CLIENT_ID"))
117
+ parser.add_argument("--client-secret", default=os.environ.get("SLACK_CLIENT_SECRET"))
118
+ parser.add_argument("--redirect-uri", default="http://localhost:8765/slack/oauth/callback")
119
+ parser.add_argument("--scopes", default=os.environ.get("SLACK_USER_SCOPES", DEFAULT_SCOPES))
120
+ parser.add_argument("--output", default=".secrets/slack-user-token.json")
121
+ parser.add_argument("--no-open", action="store_true", help="Print the URL without opening a browser.")
122
+ args = parser.parse_args()
123
+
124
+ if not args.client_id or not args.client_secret:
125
+ parser.error("Provide --client-id/--client-secret or SLACK_CLIENT_ID/SLACK_CLIENT_SECRET.")
126
+
127
+ redirect = urllib.parse.urlparse(args.redirect_uri)
128
+ if redirect.hostname not in {"localhost", "127.0.0.1"}:
129
+ parser.error("This helper only starts a localhost callback server.")
130
+
131
+ state = secrets.token_urlsafe(32)
132
+ query = urllib.parse.urlencode(
133
+ {
134
+ "client_id": args.client_id,
135
+ "user_scope": args.scopes,
136
+ "redirect_uri": args.redirect_uri,
137
+ "state": state,
138
+ }
139
+ )
140
+ authorize_url = f"https://slack.com/oauth/v2/authorize?{query}"
141
+
142
+ server = OAuthServer((redirect.hostname or "localhost", redirect.port or 80), OAuthHandler)
143
+ server.expected_state = state
144
+ server.code = None
145
+ server.error = None
146
+
147
+ print("Open this URL to authorize Slack user-token access:")
148
+ print(authorize_url)
149
+ if not args.no_open:
150
+ webbrowser.open(authorize_url)
151
+
152
+ while server.code is None and server.error is None:
153
+ server.handle_request()
154
+
155
+ if server.error:
156
+ print(f"OAuth failed: {server.error}", file=sys.stderr)
157
+ return 1
158
+
159
+ payload = exchange_code(
160
+ client_id=args.client_id,
161
+ client_secret=args.client_secret,
162
+ code=server.code or "",
163
+ redirect_uri=args.redirect_uri,
164
+ )
165
+
166
+ output = Path(args.output)
167
+ output.parent.mkdir(parents=True, exist_ok=True)
168
+ output.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
169
+ output.chmod(stat.S_IRUSR | stat.S_IWUSR)
170
+
171
+ team = payload.get("team") or {}
172
+ print(f"Saved Slack OAuth response to {output}")
173
+ print(f"Team: {team.get('name') or team.get('id') or 'unknown'}")
174
+ print("Use it with: python3 scripts/ingest_slack_channel.py --token-file " + str(output) + " --channel '#channel-name'")
175
+ return 0
176
+
177
+
178
+ if __name__ == "__main__":
179
+ raise SystemExit(main())