openalmanac 0.3.6 → 0.4.1

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/auth.d.ts +2 -2
  2. package/dist/auth.js +2 -2
  3. package/dist/cli.js +1 -1
  4. package/dist/instructions.d.ts +1 -0
  5. package/dist/instructions.js +150 -0
  6. package/dist/login-core.js +2 -1
  7. package/dist/onboarding-copy.d.ts +1 -0
  8. package/dist/onboarding-copy.js +14 -0
  9. package/dist/openalmanac_mcp-0.3.1-py3-none-any.whl +0 -0
  10. package/dist/openalmanac_mcp-0.3.1.tar.gz +0 -0
  11. package/dist/openalmanac_mcp-0.3.2-py3-none-any.whl +0 -0
  12. package/dist/openalmanac_mcp-0.3.2.tar.gz +0 -0
  13. package/dist/server.js +5 -150
  14. package/dist/setup/clients.d.ts +10 -0
  15. package/dist/setup/clients.js +291 -0
  16. package/dist/setup/config-files.d.ts +43 -0
  17. package/dist/setup/config-files.js +257 -0
  18. package/dist/setup/index.d.ts +2 -0
  19. package/dist/setup/index.js +55 -0
  20. package/dist/setup/permissions.d.ts +3 -0
  21. package/dist/setup/permissions.js +52 -0
  22. package/dist/{setup.d.ts → setup/reddit.d.ts} +0 -1
  23. package/dist/setup/reddit.js +69 -0
  24. package/dist/setup/tui.d.ts +7 -0
  25. package/dist/setup/tui.js +496 -0
  26. package/dist/setup/types.d.ts +43 -0
  27. package/dist/setup/types.js +1 -0
  28. package/dist/tool-registry.d.ts +11 -0
  29. package/dist/tool-registry.js +148 -0
  30. package/dist/tools/auth.js +1 -1
  31. package/dist/tools/{pages.js → pages/index.js} +39 -202
  32. package/dist/tools/pages/publish-format.d.ts +48 -0
  33. package/dist/tools/pages/publish-format.js +92 -0
  34. package/dist/tools/pages/workspace.d.ts +7 -0
  35. package/dist/tools/pages/workspace.js +14 -0
  36. package/dist/tools/pages/writing-guide.d.ts +1 -0
  37. package/dist/tools/pages/writing-guide.js +56 -0
  38. package/dist/tools/research.js +16 -15
  39. package/package.json +15 -6
  40. package/skills/reddit-wiki/SKILL.md +46 -46
  41. package/dist/setup.js +0 -1243
  42. package/dist/validate.d.ts +0 -971
  43. package/dist/validate.js +0 -154
  44. /package/dist/tools/{pages.d.ts → pages/index.d.ts} +0 -0
@@ -0,0 +1,11 @@
1
+ export declare const MCP_TOOL_NAMES: readonly ["search_pages", "search_topics", "list_pages", "download", "new", "publish", "read_page", "delete_pages", "list_topics", "update_topic", "create_topics", "list_wikis", "create_wiki", "get_wiki_settings", "update_wiki_settings", "join_wiki", "get_wiki_membership", "login", "logout", "whoami", "search_web", "read_webpage", "search_images", "view_images"];
2
+ export type McpToolName = (typeof MCP_TOOL_NAMES)[number];
3
+ export declare const INTERACTIVE_TOOL_NAMES: readonly string[];
4
+ export interface McpToolGroup {
5
+ name: string;
6
+ description: string;
7
+ tools: readonly McpToolName[];
8
+ }
9
+ export declare const MCP_TOOL_GROUPS: readonly McpToolGroup[];
10
+ export declare function toClaudePermissionName(name: McpToolName): string;
11
+ export declare const MCP_TOOL_PERMISSION_NAMES: readonly string[];
@@ -0,0 +1,148 @@
1
+ // Single source of truth for the MCP tools the OpenAlmanac server exposes.
2
+ //
3
+ // This file is the contract that ties three otherwise-independent surfaces
4
+ // together:
5
+ //
6
+ // 1. tools/*.ts — the actual `server.addTool({ name: ... })`
7
+ // registrations.
8
+ // 2. setup.ts TOOL_GROUPS — the permission grouping shown in the
9
+ // `npx openalmanac setup` TUI, written into
10
+ // `~/.claude/settings.json` so a user's
11
+ // agent can call these tools without a
12
+ // per-call approval prompt.
13
+ // 3. gui/config.js — the Electron app's allow-list passed to
14
+ // the Claude Code SDK.
15
+ //
16
+ // Drift between these three surfaces was a real bug: the rename refactor
17
+ // added `read_page`, `list_wikis`, `create_wiki`, the topic tools, etc., but
18
+ // only (1) was updated. (2) was still grouping the pre-refactor tool set, so
19
+ // users who ran `setup` had to manually approve every wiki/topic call.
20
+ //
21
+ // The drift test in `test/tool-registry.test.ts` walks every register*Tools
22
+ // function with a fake server, collects the names actually registered, and
23
+ // asserts:
24
+ //
25
+ // - every registered name is in MCP_TOOL_NAMES
26
+ // - every name in MCP_TOOL_NAMES is actually registered
27
+ // - every non-INTERACTIVE name appears in exactly one MCP_TOOL_GROUPS entry
28
+ //
29
+ // Adding a new MCP tool means: register it in tools/*.ts, add its name here,
30
+ // and place it in a group below. Skipping any of those three breaks CI.
31
+ export const MCP_TOOL_NAMES = [
32
+ // Pages
33
+ "search_pages",
34
+ "search_topics",
35
+ "list_pages",
36
+ "download",
37
+ "new",
38
+ "publish",
39
+ "read_page",
40
+ "delete_pages",
41
+ // Topics
42
+ "list_topics",
43
+ "update_topic",
44
+ "create_topics",
45
+ // Wikis
46
+ "list_wikis",
47
+ "create_wiki",
48
+ "get_wiki_settings",
49
+ "update_wiki_settings",
50
+ "join_wiki",
51
+ "get_wiki_membership",
52
+ // Account
53
+ "login",
54
+ "logout",
55
+ "whoami",
56
+ // Research
57
+ "search_web",
58
+ "read_webpage",
59
+ "search_images",
60
+ "view_images",
61
+ ];
62
+ // Tools intentionally excluded from the setup-time permission grant because
63
+ // they require interactive UI / per-call approval. The drift test allows these
64
+ // to be absent from MCP_TOOL_GROUPS — but they must still appear in
65
+ // MCP_TOOL_NAMES if they are actually registered.
66
+ //
67
+ // `register_sources` was a GUI citation-bubble handshake; it is currently
68
+ // commented out in tools/research.ts (REV-62) and therefore not in
69
+ // MCP_TOOL_NAMES. When it comes back, add it both there and here.
70
+ export const INTERACTIVE_TOOL_NAMES = [
71
+ "register_sources",
72
+ ];
73
+ // Permission groupings shown in the setup TUI. Each group is a checkbox the
74
+ // user toggles; checked groups are written into `~/.claude/settings.json`
75
+ // `permissions.allow` so the agent can call them without per-call approval.
76
+ //
77
+ // Group boundaries are user-facing — they should match the user's mental
78
+ // model ("Search & Read", "Write & Publish") rather than the file the tool
79
+ // happens to live in. Every non-INTERACTIVE name in MCP_TOOL_NAMES must
80
+ // appear in exactly one group; the drift test enforces this.
81
+ export const MCP_TOOL_GROUPS = [
82
+ {
83
+ name: "Search & Read",
84
+ description: "search, read, download, and browse pages, topics, and wikis",
85
+ tools: [
86
+ "search_pages",
87
+ "search_topics",
88
+ "list_pages",
89
+ "list_topics",
90
+ "list_wikis",
91
+ "download",
92
+ "read_page",
93
+ ],
94
+ },
95
+ {
96
+ name: "Research",
97
+ description: "web search, read pages, find and view images",
98
+ tools: [
99
+ "search_web",
100
+ "read_webpage",
101
+ "search_images",
102
+ "view_images",
103
+ ],
104
+ },
105
+ {
106
+ name: "Write & Publish",
107
+ description: "create, edit, and publish pages and topics",
108
+ tools: [
109
+ "new",
110
+ "publish",
111
+ "delete_pages",
112
+ "create_topics",
113
+ "update_topic",
114
+ ],
115
+ },
116
+ {
117
+ name: "Wikis",
118
+ description: "create wikis, configure settings, manage membership",
119
+ tools: [
120
+ "create_wiki",
121
+ "get_wiki_settings",
122
+ "update_wiki_settings",
123
+ "join_wiki",
124
+ "get_wiki_membership",
125
+ ],
126
+ },
127
+ {
128
+ name: "Account",
129
+ description: "login, logout, identity",
130
+ tools: [
131
+ "login",
132
+ "logout",
133
+ "whoami",
134
+ ],
135
+ },
136
+ ];
137
+ // Convert a bare MCP tool name into the prefixed form Claude Code uses in
138
+ // `~/.claude/settings.json` permissions and the Electron SDK's allow-list.
139
+ //
140
+ // Example: `read_page` → `mcp__almanac__read_page`.
141
+ export function toClaudePermissionName(name) {
142
+ return `mcp__almanac__${name}`;
143
+ }
144
+ // Full set of allow-list entries for every registered MCP tool, in the
145
+ // `mcp__almanac__*` form Claude Code expects. Consumers (gui/config.js once
146
+ // it's bumped to a registry-aware mcp version) should derive their tool
147
+ // allow-list from this.
148
+ export const MCP_TOOL_PERMISSION_NAMES = MCP_TOOL_NAMES.map(toClaudePermissionName);
@@ -4,7 +4,7 @@ export function registerAuthTools(server) {
4
4
  server.addTool({
5
5
  name: "login",
6
6
  description: "Log in via browser to connect your account and get a personal API key. This is the required " +
7
- "first step before creating or updating articles. Only needs to be called once.\n\n" +
7
+ "first step before creating or updating pages. Only needs to be called once.\n\n" +
8
8
  "If you already have a valid API key, this returns immediately without opening a browser.",
9
9
  async execute() {
10
10
  const result = await performLogin();
@@ -1,167 +1,15 @@
1
1
  import { z } from "zod";
2
- import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync, unlinkSync } from "node:fs";
3
- import { join } from "node:path";
2
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync } from "node:fs";
4
3
  import { stringify as yamlStringify } from "yaml";
5
- import { request, ARTICLES_DIR } from "../auth.js";
6
- import { openBrowser } from "../browser.js";
7
- import { coerceJson } from "../utils.js";
8
- const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
9
- function resolvePageDir(wikiSlug) {
10
- return join(ARTICLES_DIR, wikiSlug);
11
- }
12
- function resolvePagePaths(slug, wikiSlug) {
13
- const dir = resolvePageDir(wikiSlug);
14
- return {
15
- dir,
16
- filePath: join(dir, `${slug}.md`),
17
- refPath: join(dir, `.${slug}.ref`),
18
- };
19
- }
20
- const WRITING_GUIDE = `
21
- ## Page structure
22
-
23
- \`\`\`yaml
24
- ---
25
- title: Page Title
26
- wiki: wiki-slug
27
- topics: [topic-one, topic-two]
28
- sources:
29
- - key: example-source
30
- url: https://example.com
31
- title: Source Title
32
- accessed_date: "2025-01-15"
33
- infobox:
34
- header:
35
- image_url: https://...
36
- subtitle: Short tagline
37
- details:
38
- - key: Born
39
- value: January 1, 1990
40
- ---
41
-
42
- Page body with [@key] citation markers and [[wikilinks]]...
43
- \`\`\`
44
-
45
- ## Wikilinks
46
-
47
- - Write natural text in double brackets: [[spool pins]], [[pin tumbler locks]]
48
- - Display text: [[spool-pins|spool pins]]
49
- - Cross-wiki: [[global:reddit|Reddit]], [[lockpicking:spool-pins|spool pins]]
50
-
51
- ## Citations
52
-
53
- - Mark claims with [@key] after punctuation
54
- - Keys must be kebab-case with at least one hyphen
55
- - Every source must be referenced; every reference must have a source
56
-
57
- ## Quoting
58
-
59
- For any string value with punctuation, quotes, or special characters (common in \`sources[].title\`), use YAML block-literal syntax:
60
-
61
- \`\`\`yaml
62
- sources:
63
- - key: farza-yc
64
- title: |-
65
- "I'm joining Y Combinator, again" — Farza Majeed
66
- url: https://...
67
- \`\`\`
68
-
69
- This sidesteps every YAML escaping rule. If you skip this, inner double quotes or em-dashes will break the parser.
70
-
71
- ## Images
72
-
73
- Use search_images to find relevant images. Syntax: \`![Caption](url "position")\`
74
- Positions: "right" (default), "left", "center". Every image needs a descriptive caption.
75
- `.trim();
76
- function formatPublishResults(results, targetSlugs, wiki_slug, dry_run) {
77
- const allAutoStubs = new Set();
78
- const lines = [];
79
- let okCount = 0;
80
- let errorCount = 0;
81
- for (let i = 0; i < results.length; i++) {
82
- const r = results[i];
83
- const slug = targetSlugs[i] ?? r.slug;
84
- if (dry_run && r.plan) {
85
- const plan = r.plan;
86
- const hasError = plan.validation.status === "failed" ||
87
- !plan.authorization.can_write ||
88
- plan.action === "error";
89
- if (hasError) {
90
- errorCount++;
91
- const reasons = [];
92
- for (const e of plan.validation.errors) {
93
- reasons.push(`${e.field}: ${e.message}`);
94
- }
95
- if (!plan.authorization.can_write && plan.authorization.reason) {
96
- reasons.push(`auth: ${plan.authorization.reason}`);
97
- }
98
- lines.push(`- ${slug}: **error** — ${reasons.join("; ")}`);
99
- }
100
- else {
101
- okCount++;
102
- let line = `- ${plan.slug}: **${plan.action}**`;
103
- if (plan.renamed_from)
104
- line += ` (rename: ${plan.renamed_from} → ${plan.slug})`;
105
- const details = [];
106
- if (plan.source_keys.referenced.length > 0) {
107
- details.push(`${plan.source_keys.referenced.length} source(s)`);
108
- }
109
- if (plan.wikilinks.will_auto_stub.length > 0) {
110
- details.push(`${plan.wikilinks.will_auto_stub.length} new stub(s)`);
111
- plan.wikilinks.will_auto_stub.forEach(s => allAutoStubs.add(s));
112
- }
113
- if (plan.source_keys.orphaned.length > 0) {
114
- details.push(`missing source key(s): ${plan.source_keys.orphaned.join(", ")}`);
115
- }
116
- if (plan.source_keys.unreferenced.length > 0) {
117
- details.push(`unreferenced source(s): ${plan.source_keys.unreferenced.join(", ")}`);
118
- }
119
- if (details.length > 0)
120
- line += ` (${details.join(", ")})`;
121
- lines.push(line);
122
- }
123
- }
124
- else {
125
- // Real publish result
126
- if (r.status === "error") {
127
- errorCount++;
128
- lines.push(`- ${r.slug}: **error** — ${r.error}`);
129
- }
130
- else {
131
- okCount++;
132
- // Clean up local files — pre-rename slug names the file
133
- const fileSlug = r.renamed_from ?? slug;
134
- const { filePath, refPath } = resolvePagePaths(fileSlug, wiki_slug);
135
- try {
136
- unlinkSync(filePath);
137
- }
138
- catch { /* ok */ }
139
- try {
140
- unlinkSync(refPath);
141
- }
142
- catch { /* ok */ }
143
- let line = `- ${r.slug}: **${r.status}**`;
144
- if (r.renamed_from)
145
- line += ` (renamed from ${r.renamed_from})`;
146
- if (r.stubs_created?.length) {
147
- r.stubs_created.forEach(s => allAutoStubs.add(s));
148
- }
149
- lines.push(line);
150
- }
151
- }
152
- }
153
- const verb = dry_run ? "Dry-run" : "Published";
154
- const summary = `${verb}: ${okCount}/${targetSlugs.length} OK${errorCount > 0 ? `, ${errorCount} error(s)` : ""}.`;
155
- const parts = [summary, "", ...lines];
156
- if (allAutoStubs.size > 0) {
157
- const stubVerb = dry_run ? "Stubs that will be auto-created" : "Stubs auto-created";
158
- parts.push("", `${stubVerb}: ${[...allAutoStubs].join(", ")}`);
159
- }
160
- return parts.join("\n");
161
- }
4
+ import { request } from "../../auth.js";
5
+ import { openBrowser } from "../../browser.js";
6
+ import { coerceJson } from "../../utils.js";
7
+ import { formatPublishResults } from "./publish-format.js";
8
+ import { resolvePageDir, resolvePagePaths, SLUG_RE } from "./workspace.js";
9
+ import { WRITING_GUIDE } from "./writing-guide.js";
162
10
  export function registerPageTools(server) {
163
11
  server.addTool({
164
- name: "search_articles",
12
+ name: "search_pages",
165
13
  description: "Search OpenAlmanac pages and stubs across all wikis. Use to check existence, find slugs for wikilinks, " +
166
14
  "or discover content. Optional wiki filter to scope results. No authentication needed.",
167
15
  parameters: z.object({
@@ -213,7 +61,7 @@ export function registerPageTools(server) {
213
61
  },
214
62
  });
215
63
  server.addTool({
216
- name: "list_articles",
64
+ name: "list_pages",
217
65
  description: "Browse pages in a wiki. Structured listing, not fuzzy search. " +
218
66
  "Use to see what exists, find stubs, or discover pages by topic. " +
219
67
  "Each returned page includes topic objects with both slug and title.",
@@ -237,7 +85,7 @@ export function registerPageTools(server) {
237
85
  server.addTool({
238
86
  name: "download",
239
87
  description: "Download pages to your local workspace for editing. " +
240
- "Files go to ~/.openalmanac/articles/{wiki_slug}/{slug}.md with a .ref sidecar. " +
88
+ "Files go to ~/.openalmanac/pages/{wiki_slug}/{slug}.md with a .ref sidecar. " +
241
89
  "After editing, use publish to push changes. The .ref file is system-managed — don't edit it.",
242
90
  parameters: z.object({
243
91
  slugs: coerceJson(z.array(z.string()).min(1).max(50)).describe("Page slugs to download"),
@@ -308,7 +156,7 @@ export function registerPageTools(server) {
308
156
  .replace(/^-+|-+$/g, "")
309
157
  .replace(/-{2,}/g, "-") || "untitled";
310
158
  }
311
- const filePath = join(dir, `${fileSlug}.md`);
159
+ const { filePath } = resolvePagePaths(fileSlug, wiki_slug);
312
160
  if (existsSync(filePath)) {
313
161
  skipped.push(`${fileSlug}.md already exists`);
314
162
  continue;
@@ -326,9 +174,28 @@ export function registerPageTools(server) {
326
174
  writeFileSync(filePath, `---\n${frontmatter}---\n\n`, "utf-8");
327
175
  created.push(filePath);
328
176
  }
177
+ // Scaffold-time nudge: check if any created pages have matching slugs
178
+ // in the global wiki (Almanac). Fires before writing so the agent can
179
+ // decide to cross-link instead of writing a duplicate treatment.
180
+ const nudges = [];
181
+ if (created.length > 0 && wiki_slug !== "global") {
182
+ const createdSlugs = created.map(p => p.split("/").pop().replace(".md", ""));
183
+ for (const slug of createdSlugs) {
184
+ try {
185
+ const res = await request("GET", `/api/w/global/pages/${slug}`);
186
+ if (res.ok) {
187
+ const page = await res.json();
188
+ nudges.push(`Note: Almanac already has a page "${page.title ?? slug}" (slug: ${slug}). ` +
189
+ `Write your own treatment for this wiki, or cross-link with [[global:${slug}]] instead.`);
190
+ }
191
+ }
192
+ catch { /* page doesn't exist in global wiki — no nudge */ }
193
+ }
194
+ }
329
195
  const parts = [
330
196
  created.length > 0 ? `Created ${created.length} file(s):\n${created.map(p => ` - ${p}`).join("\n")}` : "No new files created.",
331
197
  skipped.length > 0 ? `Skipped:\n${skipped.map(s => ` - ${s}`).join("\n")}` : "",
198
+ nudges.length > 0 ? nudges.join("\n") : "",
332
199
  WRITING_GUIDE,
333
200
  ];
334
201
  return parts.filter(Boolean).join("\n\n");
@@ -400,48 +267,18 @@ export function registerPageTools(server) {
400
267
  return summary;
401
268
  },
402
269
  });
403
- // propose_article — GUI-only handshake. Commented out 2026-04-23 per REV-62.
404
- // Revive when the GUI plan-card proposal flow is in active use.
405
- /*
406
- server.addTool({
407
- name: "propose_article",
408
- description:
409
- "Propose an article before writing it. Structures your proposal with a user-facing summary and a detailed brief. " +
410
- "Do not start writing without proposing first.",
411
- parameters: z.object({
412
- summary: z.string().describe("User-facing summary (3-5 bullet points)"),
413
- details: z.string().describe("Full handoff brief with all sources, key facts, angle"),
414
- title: z.string().describe("Proposed title"),
415
- slug: z.string().describe("Proposed slug (kebab-case)"),
416
- wiki_slug: z.string().default("global").describe("Wiki slug"),
417
- _userChoice: z.enum(["background", "here", "expired", "already_in_progress"]).optional(),
418
- }),
419
- async execute({ summary, details, title, slug, wiki_slug, _userChoice }) {
420
- if (_userChoice === "background") {
421
- return `Article "${title}" is now being written in a background process.`;
422
- }
423
- if (_userChoice === "expired") {
424
- return `Proposal expired. Continue the conversation naturally.`;
425
- }
426
- if (_userChoice === "already_in_progress") {
427
- return `Article "${title}" is already being generated.`;
428
- }
429
- return `Article Proposal: ${title}\n\n${summary}\n\nProceed with writing this article following the writing flow in your instructions.`;
430
- },
431
- });
432
- */
433
270
  server.addTool({
434
- name: "read_article",
271
+ name: "read_page",
435
272
  description: "Read a single page by slug. Returns the full page JSON including content, topics, sources, and infobox. " +
436
273
  "No side effects — use this to read a page without downloading it to disk or joining the wiki. " +
437
274
  "For editing, use `download` instead (it writes local files and handles ref tokens). " +
438
- "For discovery, use `search_articles` instead. No authentication needed.",
275
+ "For discovery, use `search_pages` instead. No authentication needed.",
439
276
  parameters: z.object({
440
277
  wiki_slug: z.string().describe("Wiki slug"),
441
- article_slug: z.string().describe("Page slug"),
278
+ page_slug: z.string().describe("Page slug"),
442
279
  }),
443
- async execute({ wiki_slug, article_slug }) {
444
- const resp = await request("GET", `/api/w/${wiki_slug}/pages/${article_slug}`);
280
+ async execute({ wiki_slug, page_slug }) {
281
+ const resp = await request("GET", `/api/w/${wiki_slug}/pages/${page_slug}`);
445
282
  return JSON.stringify(await resp.json(), null, 2);
446
283
  },
447
284
  });
@@ -451,11 +288,11 @@ export function registerPageTools(server) {
451
288
  "Accepts multiple slugs and deletes them in sequence. Requires moderator or creator access.",
452
289
  parameters: z.object({
453
290
  wiki_slug: z.string().describe("Wiki slug"),
454
- article_slugs: coerceJson(z.array(z.string()).min(1).max(50)).describe("Page slugs to delete (1-50)"),
291
+ page_slugs: coerceJson(z.array(z.string()).min(1).max(50)).describe("Page slugs to delete (1-50)"),
455
292
  }),
456
- async execute({ wiki_slug, article_slugs }) {
293
+ async execute({ wiki_slug, page_slugs }) {
457
294
  const results = [];
458
- for (const slug of article_slugs) {
295
+ for (const slug of page_slugs) {
459
296
  try {
460
297
  // DELETE returns 204 No Content on success
461
298
  await request("DELETE", `/api/w/${wiki_slug}/pages/${slug}`, { auth: true });
@@ -470,7 +307,7 @@ export function registerPageTools(server) {
470
307
  const lines = results.map(r => r.status === "deleted"
471
308
  ? `- ${r.slug}: deleted`
472
309
  : `- ${r.slug}: error — ${r.message}`);
473
- return `Deleted ${deleted}/${article_slugs.length} pages.\n\n${lines.join("\n")}`;
310
+ return `Deleted ${deleted}/${page_slugs.length} pages.\n\n${lines.join("\n")}`;
474
311
  },
475
312
  });
476
313
  }
@@ -0,0 +1,48 @@
1
+ export type PublishPlanValidation = {
2
+ status: string;
3
+ errors: Array<{
4
+ field: string;
5
+ message: string;
6
+ }>;
7
+ };
8
+ export type PublishPlanAuthorization = {
9
+ can_write: boolean;
10
+ reason?: string;
11
+ };
12
+ export type PublishPlanWikilinks = {
13
+ found: string[];
14
+ in_batch?: string[];
15
+ stubs: string[];
16
+ will_auto_stub: string[];
17
+ };
18
+ export type PublishPlanSourceKeys = {
19
+ referenced: string[];
20
+ unreferenced: string[];
21
+ orphaned: string[];
22
+ };
23
+ export type PublishPlanInfobox = {
24
+ status: string;
25
+ errors: Array<{
26
+ field: string;
27
+ message: string;
28
+ }>;
29
+ };
30
+ export type PublishPlan = {
31
+ action: string;
32
+ slug: string;
33
+ renamed_from?: string;
34
+ validation: PublishPlanValidation;
35
+ authorization: PublishPlanAuthorization;
36
+ wikilinks: PublishPlanWikilinks;
37
+ source_keys: PublishPlanSourceKeys;
38
+ infobox: PublishPlanInfobox;
39
+ };
40
+ export type PublishResult = {
41
+ slug: string;
42
+ status: string;
43
+ renamed_from?: string;
44
+ stubs_created?: string[];
45
+ error?: string;
46
+ plan?: PublishPlan;
47
+ };
48
+ export declare function formatPublishResults(results: PublishResult[], targetSlugs: string[], wiki_slug: string, dry_run: boolean): string;
@@ -0,0 +1,92 @@
1
+ import { unlinkSync } from "node:fs";
2
+ import { resolvePagePaths } from "./workspace.js";
3
+ export function formatPublishResults(results, targetSlugs, wiki_slug, dry_run) {
4
+ const allAutoStubs = new Set();
5
+ const lines = [];
6
+ let okCount = 0;
7
+ let errorCount = 0;
8
+ for (let i = 0; i < results.length; i++) {
9
+ const r = results[i];
10
+ const slug = targetSlugs[i] ?? r.slug;
11
+ if (dry_run && r.plan) {
12
+ const plan = r.plan;
13
+ const hasError = plan.validation.status === "failed" ||
14
+ !plan.authorization.can_write ||
15
+ plan.action === "error";
16
+ if (hasError) {
17
+ errorCount++;
18
+ const reasons = [];
19
+ for (const e of plan.validation.errors) {
20
+ reasons.push(`${e.field}: ${e.message}`);
21
+ }
22
+ if (!plan.authorization.can_write && plan.authorization.reason) {
23
+ reasons.push(`auth: ${plan.authorization.reason}`);
24
+ }
25
+ lines.push(`- ${slug}: **error** — ${reasons.join("; ")}`);
26
+ }
27
+ else {
28
+ okCount++;
29
+ let line = `- ${plan.slug}: **${plan.action}**`;
30
+ if (plan.renamed_from)
31
+ line += ` (rename: ${plan.renamed_from} → ${plan.slug})`;
32
+ const details = [];
33
+ if (plan.source_keys.referenced.length > 0) {
34
+ details.push(`${plan.source_keys.referenced.length} source(s)`);
35
+ }
36
+ if (plan.wikilinks.will_auto_stub.length > 0) {
37
+ details.push(`${plan.wikilinks.will_auto_stub.length} new stub(s)`);
38
+ plan.wikilinks.will_auto_stub.forEach(s => allAutoStubs.add(s));
39
+ }
40
+ const inBatchLinks = plan.wikilinks.in_batch ?? [];
41
+ if (inBatchLinks.length > 0) {
42
+ details.push(`${inBatchLinks.length} in-batch link(s)`);
43
+ }
44
+ if (plan.source_keys.orphaned.length > 0) {
45
+ details.push(`missing source key(s): ${plan.source_keys.orphaned.join(", ")}`);
46
+ }
47
+ if (plan.source_keys.unreferenced.length > 0) {
48
+ details.push(`unreferenced source(s): ${plan.source_keys.unreferenced.join(", ")}`);
49
+ }
50
+ if (details.length > 0)
51
+ line += ` (${details.join(", ")})`;
52
+ lines.push(line);
53
+ }
54
+ }
55
+ else {
56
+ // Real publish result
57
+ if (r.status === "error") {
58
+ errorCount++;
59
+ lines.push(`- ${r.slug}: **error** — ${r.error}`);
60
+ }
61
+ else {
62
+ okCount++;
63
+ // Clean up local files — pre-rename slug names the file
64
+ const fileSlug = r.renamed_from ?? slug;
65
+ const { filePath, refPath } = resolvePagePaths(fileSlug, wiki_slug);
66
+ try {
67
+ unlinkSync(filePath);
68
+ }
69
+ catch { /* ok */ }
70
+ try {
71
+ unlinkSync(refPath);
72
+ }
73
+ catch { /* ok */ }
74
+ let line = `- ${r.slug}: **${r.status}**`;
75
+ if (r.renamed_from)
76
+ line += ` (renamed from ${r.renamed_from})`;
77
+ if (r.stubs_created?.length) {
78
+ r.stubs_created.forEach(s => allAutoStubs.add(s));
79
+ }
80
+ lines.push(line);
81
+ }
82
+ }
83
+ }
84
+ const verb = dry_run ? "Dry-run" : "Published";
85
+ const summary = `${verb}: ${okCount}/${targetSlugs.length} OK${errorCount > 0 ? `, ${errorCount} error(s)` : ""}.`;
86
+ const parts = [summary, "", ...lines];
87
+ if (allAutoStubs.size > 0) {
88
+ const stubVerb = dry_run ? "Stubs that will be auto-created" : "Stubs auto-created";
89
+ parts.push("", `${stubVerb}: ${[...allAutoStubs].join(", ")}`);
90
+ }
91
+ return parts.join("\n");
92
+ }
@@ -0,0 +1,7 @@
1
+ export declare const SLUG_RE: RegExp;
2
+ export declare function resolvePageDir(wikiSlug: string): string;
3
+ export declare function resolvePagePaths(slug: string, wikiSlug: string): {
4
+ dir: string;
5
+ filePath: string;
6
+ refPath: string;
7
+ };
@@ -0,0 +1,14 @@
1
+ import { join } from "node:path";
2
+ import { PAGES_DIR } from "../../auth.js";
3
+ export const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
4
+ export function resolvePageDir(wikiSlug) {
5
+ return join(PAGES_DIR, wikiSlug);
6
+ }
7
+ export function resolvePagePaths(slug, wikiSlug) {
8
+ const dir = resolvePageDir(wikiSlug);
9
+ return {
10
+ dir,
11
+ filePath: join(dir, `${slug}.md`),
12
+ refPath: join(dir, `.${slug}.ref`),
13
+ };
14
+ }
@@ -0,0 +1 @@
1
+ export declare const WRITING_GUIDE: string;