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.
- package/dist/auth.d.ts +2 -2
- package/dist/auth.js +2 -2
- package/dist/cli.js +1 -1
- package/dist/instructions.d.ts +1 -0
- package/dist/instructions.js +150 -0
- package/dist/login-core.js +2 -1
- package/dist/onboarding-copy.d.ts +1 -0
- package/dist/onboarding-copy.js +14 -0
- package/dist/openalmanac_mcp-0.3.1-py3-none-any.whl +0 -0
- package/dist/openalmanac_mcp-0.3.1.tar.gz +0 -0
- package/dist/openalmanac_mcp-0.3.2-py3-none-any.whl +0 -0
- package/dist/openalmanac_mcp-0.3.2.tar.gz +0 -0
- package/dist/server.js +5 -150
- package/dist/setup/clients.d.ts +10 -0
- package/dist/setup/clients.js +291 -0
- package/dist/setup/config-files.d.ts +43 -0
- package/dist/setup/config-files.js +257 -0
- package/dist/setup/index.d.ts +2 -0
- package/dist/setup/index.js +55 -0
- package/dist/setup/permissions.d.ts +3 -0
- package/dist/setup/permissions.js +52 -0
- package/dist/{setup.d.ts → setup/reddit.d.ts} +0 -1
- package/dist/setup/reddit.js +69 -0
- package/dist/setup/tui.d.ts +7 -0
- package/dist/setup/tui.js +496 -0
- package/dist/setup/types.d.ts +43 -0
- package/dist/setup/types.js +1 -0
- package/dist/tool-registry.d.ts +11 -0
- package/dist/tool-registry.js +148 -0
- package/dist/tools/auth.js +1 -1
- package/dist/tools/{pages.js → pages/index.js} +39 -202
- package/dist/tools/pages/publish-format.d.ts +48 -0
- package/dist/tools/pages/publish-format.js +92 -0
- package/dist/tools/pages/workspace.d.ts +7 -0
- package/dist/tools/pages/workspace.js +14 -0
- package/dist/tools/pages/writing-guide.d.ts +1 -0
- package/dist/tools/pages/writing-guide.js +56 -0
- package/dist/tools/research.js +16 -15
- package/package.json +15 -6
- package/skills/reddit-wiki/SKILL.md +46 -46
- package/dist/setup.js +0 -1243
- package/dist/validate.d.ts +0 -971
- package/dist/validate.js +0 -154
- /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);
|
package/dist/tools/auth.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
6
|
-
import { openBrowser } from "
|
|
7
|
-
import { coerceJson } from "
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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: \`\`
|
|
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: "
|
|
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: "
|
|
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/
|
|
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 =
|
|
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: "
|
|
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 `
|
|
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
|
-
|
|
278
|
+
page_slug: z.string().describe("Page slug"),
|
|
442
279
|
}),
|
|
443
|
-
async execute({ wiki_slug,
|
|
444
|
-
const resp = await request("GET", `/api/w/${wiki_slug}/pages/${
|
|
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
|
-
|
|
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,
|
|
293
|
+
async execute({ wiki_slug, page_slugs }) {
|
|
457
294
|
const results = [];
|
|
458
|
-
for (const slug of
|
|
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}/${
|
|
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,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;
|