@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.
- package/.claude-plugin/marketplace.json +6 -0
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/commands/setup/github.md +7 -0
- package/plugins/lisa/commands/setup/linear.md +7 -0
- package/plugins/lisa/skills/setup-github/SKILL.md +199 -0
- package/plugins/lisa/skills/setup-linear/SKILL.md +217 -0
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +8 -0
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +32 -0
- package/plugins/lisa-wiki/ci/lisa-wiki-validate.yml +32 -0
- package/plugins/lisa-wiki/commands/add-ingest.md +6 -0
- package/plugins/lisa-wiki/commands/add-role.md +6 -0
- package/plugins/lisa-wiki/commands/doctor.md +6 -0
- package/plugins/lisa-wiki/commands/ingest.md +6 -0
- package/plugins/lisa-wiki/commands/lint.md +6 -0
- package/plugins/lisa-wiki/commands/migrate.md +6 -0
- package/plugins/lisa-wiki/commands/onboard-me.md +6 -0
- package/plugins/lisa-wiki/commands/query.md +6 -0
- package/plugins/lisa-wiki/commands/setup.md +6 -0
- package/plugins/lisa-wiki/schema/lisa-wiki-config.schema.json +118 -0
- package/plugins/lisa-wiki/schema/wiki-structure.schema.json +51 -0
- package/plugins/lisa-wiki/scripts/_wiki-lib.mjs +185 -0
- package/plugins/lisa-wiki/scripts/diff-guard.mjs +116 -0
- package/plugins/lisa-wiki/scripts/ingest-git.mjs +189 -0
- package/plugins/lisa-wiki/scripts/ingest-memory.mjs +130 -0
- package/plugins/lisa-wiki/scripts/ingest-roles.mjs +85 -0
- package/plugins/lisa-wiki/scripts/ingest_slack_channel.py +329 -0
- package/plugins/lisa-wiki/scripts/lint-wiki.mjs +320 -0
- package/plugins/lisa-wiki/scripts/mcp-doctor.mjs +72 -0
- package/plugins/lisa-wiki/scripts/render-contract.mjs +107 -0
- package/plugins/lisa-wiki/scripts/rewrite-refs.mjs +144 -0
- package/plugins/lisa-wiki/scripts/slack_oauth_user.py +179 -0
- package/plugins/lisa-wiki/scripts/validate-config.mjs +232 -0
- package/plugins/lisa-wiki/scripts/verify-migration.mjs +199 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-add-ingest/SKILL.md +34 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-add-role/SKILL.md +30 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-connector-confluence/SKILL.md +25 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-connector-docs/SKILL.md +30 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-connector-git/SKILL.md +25 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-connector-jira/SKILL.md +28 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-connector-memory/SKILL.md +28 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-connector-notion/SKILL.md +25 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-connector-roles/SKILL.md +22 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-connector-slack/SKILL.md +30 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-connector-web/SKILL.md +23 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-doctor/SKILL.md +47 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-ingest/SKILL.md +43 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-lint/SKILL.md +32 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-migrate/SKILL.md +43 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-onboard-me/SKILL.md +33 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-query/SKILL.md +30 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-setup/SKILL.md +45 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-usage/SKILL.md +50 -0
- package/plugins/lisa-wiki/templates/agents/role-agent.claude.md +16 -0
- package/plugins/lisa-wiki/templates/agents/role-agent.codex.toml +15 -0
- package/plugins/lisa-wiki/templates/index.md +17 -0
- package/plugins/lisa-wiki/templates/llm-wiki-contract.md +60 -0
- package/plugins/lisa-wiki/templates/log.md +8 -0
- package/plugins/lisa-wiki/templates/page-types/architecture.md +18 -0
- package/plugins/lisa-wiki/templates/page-types/concept.md +18 -0
- package/plugins/lisa-wiki/templates/page-types/decision.md +18 -0
- package/plugins/lisa-wiki/templates/page-types/entity.md +19 -0
- package/plugins/lisa-wiki/templates/page-types/open-question.md +18 -0
- package/plugins/lisa-wiki/templates/page-types/playbook.md +18 -0
- package/plugins/lisa-wiki/templates/page-types/project.md +19 -0
- package/plugins/lisa-wiki/templates/page-types/requirement.md +19 -0
- package/plugins/lisa-wiki/templates/page-types/staff.md +26 -0
- package/plugins/lisa-wiki/templates/start-here.md +24 -0
- package/plugins/lisa-wiki/templates/state-readme.md +20 -0
- package/plugins/src/base/commands/setup/github.md +7 -0
- package/plugins/src/base/commands/setup/linear.md +7 -0
- package/plugins/src/base/skills/setup-github/SKILL.md +199 -0
- package/plugins/src/base/skills/setup-linear/SKILL.md +217 -0
- package/plugins/src/wiki/.claude-plugin/plugin.json +6 -0
- package/plugins/src/wiki/ci/lisa-wiki-validate.yml +32 -0
- package/plugins/src/wiki/commands/add-ingest.md +6 -0
- package/plugins/src/wiki/commands/add-role.md +6 -0
- package/plugins/src/wiki/commands/doctor.md +6 -0
- package/plugins/src/wiki/commands/ingest.md +6 -0
- package/plugins/src/wiki/commands/lint.md +6 -0
- package/plugins/src/wiki/commands/migrate.md +6 -0
- package/plugins/src/wiki/commands/onboard-me.md +6 -0
- package/plugins/src/wiki/commands/query.md +6 -0
- package/plugins/src/wiki/commands/setup.md +6 -0
- package/plugins/src/wiki/schema/lisa-wiki-config.schema.json +118 -0
- package/plugins/src/wiki/schema/wiki-structure.schema.json +51 -0
- package/plugins/src/wiki/scripts/_wiki-lib.mjs +185 -0
- package/plugins/src/wiki/scripts/diff-guard.mjs +116 -0
- package/plugins/src/wiki/scripts/ingest-git.mjs +189 -0
- package/plugins/src/wiki/scripts/ingest-memory.mjs +130 -0
- package/plugins/src/wiki/scripts/ingest-roles.mjs +85 -0
- package/plugins/src/wiki/scripts/ingest_slack_channel.py +329 -0
- package/plugins/src/wiki/scripts/lint-wiki.mjs +320 -0
- package/plugins/src/wiki/scripts/mcp-doctor.mjs +72 -0
- package/plugins/src/wiki/scripts/render-contract.mjs +107 -0
- package/plugins/src/wiki/scripts/rewrite-refs.mjs +144 -0
- package/plugins/src/wiki/scripts/slack_oauth_user.py +179 -0
- package/plugins/src/wiki/scripts/validate-config.mjs +232 -0
- package/plugins/src/wiki/scripts/verify-migration.mjs +199 -0
- package/plugins/src/wiki/skills/lisa-wiki-add-ingest/SKILL.md +34 -0
- package/plugins/src/wiki/skills/lisa-wiki-add-role/SKILL.md +30 -0
- package/plugins/src/wiki/skills/lisa-wiki-connector-confluence/SKILL.md +25 -0
- package/plugins/src/wiki/skills/lisa-wiki-connector-docs/SKILL.md +30 -0
- package/plugins/src/wiki/skills/lisa-wiki-connector-git/SKILL.md +25 -0
- package/plugins/src/wiki/skills/lisa-wiki-connector-jira/SKILL.md +28 -0
- package/plugins/src/wiki/skills/lisa-wiki-connector-memory/SKILL.md +28 -0
- package/plugins/src/wiki/skills/lisa-wiki-connector-notion/SKILL.md +25 -0
- package/plugins/src/wiki/skills/lisa-wiki-connector-roles/SKILL.md +22 -0
- package/plugins/src/wiki/skills/lisa-wiki-connector-slack/SKILL.md +30 -0
- package/plugins/src/wiki/skills/lisa-wiki-connector-web/SKILL.md +23 -0
- package/plugins/src/wiki/skills/lisa-wiki-doctor/SKILL.md +47 -0
- package/plugins/src/wiki/skills/lisa-wiki-ingest/SKILL.md +43 -0
- package/plugins/src/wiki/skills/lisa-wiki-lint/SKILL.md +32 -0
- package/plugins/src/wiki/skills/lisa-wiki-migrate/SKILL.md +43 -0
- package/plugins/src/wiki/skills/lisa-wiki-onboard-me/SKILL.md +33 -0
- package/plugins/src/wiki/skills/lisa-wiki-query/SKILL.md +30 -0
- package/plugins/src/wiki/skills/lisa-wiki-setup/SKILL.md +45 -0
- package/plugins/src/wiki/skills/lisa-wiki-usage/SKILL.md +50 -0
- package/plugins/src/wiki/templates/agents/role-agent.claude.md +16 -0
- package/plugins/src/wiki/templates/agents/role-agent.codex.toml +15 -0
- package/plugins/src/wiki/templates/index.md +17 -0
- package/plugins/src/wiki/templates/llm-wiki-contract.md +60 -0
- package/plugins/src/wiki/templates/log.md +8 -0
- package/plugins/src/wiki/templates/page-types/architecture.md +18 -0
- package/plugins/src/wiki/templates/page-types/concept.md +18 -0
- package/plugins/src/wiki/templates/page-types/decision.md +18 -0
- package/plugins/src/wiki/templates/page-types/entity.md +19 -0
- package/plugins/src/wiki/templates/page-types/open-question.md +18 -0
- package/plugins/src/wiki/templates/page-types/playbook.md +18 -0
- package/plugins/src/wiki/templates/page-types/project.md +19 -0
- package/plugins/src/wiki/templates/page-types/requirement.md +19 -0
- package/plugins/src/wiki/templates/page-types/staff.md +26 -0
- package/plugins/src/wiki/templates/start-here.md +24 -0
- package/plugins/src/wiki/templates/state-readme.md +20 -0
- package/scripts/build-plugins.sh +29 -21
- package/scripts/check-plugins-sync.sh +1 -1
- 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())
|