openalmanac 0.2.57 → 0.2.59
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/cli.js +0 -0
- package/dist/setup.js +455 -49
- package/dist/tools/articles.d.ts +2 -0
- package/dist/tools/articles.js +401 -0
- package/dist/tools/communities.d.ts +2 -0
- package/dist/tools/communities.js +127 -0
- package/dist/tools/pages.js +4 -2
- package/dist/tools/people.d.ts +2 -0
- package/dist/tools/people.js +20 -0
- package/dist/tools/wikis.js +5 -5
- package/dist/validate.js +10 -6
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/setup.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, cpSync } from "fs";
|
|
|
2
2
|
import { homedir } from "os";
|
|
3
3
|
import { join, dirname } from "path";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
5
6
|
import { performLogin } from "./login-core.js";
|
|
6
7
|
import { getAuthStatus } from "./auth.js";
|
|
7
8
|
const TOOL_GROUPS = [
|
|
@@ -131,9 +132,12 @@ const vis = (s) => s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
|
131
132
|
const w = (s) => process.stdout.write(s + "\n");
|
|
132
133
|
/* ── File helpers ───────────────────────────────────────────────── */
|
|
133
134
|
const CLAUDE_DIR = join(homedir(), ".claude");
|
|
134
|
-
const CLAUDE_JSON = join(homedir(), ".claude.json"); // Claude
|
|
135
|
-
const CLAUDE_CODE_MCP = join(CLAUDE_DIR, "mcp.json"); // Claude Code
|
|
135
|
+
const CLAUDE_JSON = join(homedir(), ".claude.json"); // Claude Code user-scoped MCP config
|
|
136
|
+
const CLAUDE_CODE_MCP = join(CLAUDE_DIR, "mcp.json"); // Claude Code local MCP config
|
|
136
137
|
const SETTINGS_JSON = join(CLAUDE_DIR, "settings.json");
|
|
138
|
+
const CODEX_CONFIG = join(homedir(), ".codex", "config.toml");
|
|
139
|
+
const CURSOR_MCP_JSON = join(homedir(), ".cursor", "mcp.json");
|
|
140
|
+
const WINDSURF_MCP_JSON = join(homedir(), ".codeium", "mcp_config.json");
|
|
137
141
|
function ensureDir(dir) {
|
|
138
142
|
if (!existsSync(dir))
|
|
139
143
|
mkdirSync(dir, { recursive: true });
|
|
@@ -151,34 +155,379 @@ function writeJson(path, data) {
|
|
|
151
155
|
}
|
|
152
156
|
/* ── Step 1 — MCP server ───────────────────────────────────────── */
|
|
153
157
|
const ALMANAC_MCP_ENTRY = { command: "npx", args: ["-y", "openalmanac@latest"] };
|
|
158
|
+
const SUPPORTED_CLIENT_IDS = [
|
|
159
|
+
"claude-code",
|
|
160
|
+
"claude-desktop",
|
|
161
|
+
"codex",
|
|
162
|
+
"cursor",
|
|
163
|
+
"windsurf",
|
|
164
|
+
];
|
|
165
|
+
const SUPPORTED_CLIENTS = {
|
|
166
|
+
"claude-code": {
|
|
167
|
+
id: "claude-code",
|
|
168
|
+
name: "Claude Code",
|
|
169
|
+
detect: () => hasCommand("claude") || existsSync(CLAUDE_JSON) || existsSync(CLAUDE_DIR),
|
|
170
|
+
configure: (mode) => {
|
|
171
|
+
const snippets = [
|
|
172
|
+
{
|
|
173
|
+
path: CLAUDE_JSON,
|
|
174
|
+
content: jsonSnippet({
|
|
175
|
+
mcpServers: {
|
|
176
|
+
almanac: { ...ALMANAC_MCP_ENTRY },
|
|
177
|
+
},
|
|
178
|
+
}),
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
path: CLAUDE_CODE_MCP,
|
|
182
|
+
content: jsonSnippet({
|
|
183
|
+
mcpServers: {
|
|
184
|
+
almanac: { ...ALMANAC_MCP_ENTRY },
|
|
185
|
+
},
|
|
186
|
+
}),
|
|
187
|
+
},
|
|
188
|
+
];
|
|
189
|
+
const changedPrimary = configureJsonMcpFile(CLAUDE_JSON, mode);
|
|
190
|
+
const changedSecondary = configureJsonMcpFile(CLAUDE_CODE_MCP, mode);
|
|
191
|
+
return { changed: changedPrimary || changedSecondary, snippets };
|
|
192
|
+
},
|
|
193
|
+
supportsPermissions: true,
|
|
194
|
+
},
|
|
195
|
+
"claude-desktop": {
|
|
196
|
+
id: "claude-desktop",
|
|
197
|
+
name: "Claude Desktop",
|
|
198
|
+
detect: () => {
|
|
199
|
+
const path = getClaudeDesktopConfigPath();
|
|
200
|
+
return Boolean(path && (existsSync(path) || isClaudeDesktopInstalled()));
|
|
201
|
+
},
|
|
202
|
+
configure: (mode) => {
|
|
203
|
+
const path = getClaudeDesktopConfigPath();
|
|
204
|
+
if (!path)
|
|
205
|
+
return { changed: false, snippets: [] };
|
|
206
|
+
return {
|
|
207
|
+
changed: configureJsonMcpFile(path, mode),
|
|
208
|
+
snippets: [
|
|
209
|
+
{
|
|
210
|
+
path,
|
|
211
|
+
content: jsonSnippet({
|
|
212
|
+
mcpServers: {
|
|
213
|
+
almanac: { ...ALMANAC_MCP_ENTRY },
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
};
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
codex: {
|
|
222
|
+
id: "codex",
|
|
223
|
+
name: "Codex",
|
|
224
|
+
detect: () => hasCommand("codex") || existsSync(CODEX_CONFIG) || existsSync(join(homedir(), ".codex")),
|
|
225
|
+
configure: (mode) => ({
|
|
226
|
+
changed: configureCodexToml(CODEX_CONFIG, mode),
|
|
227
|
+
snippets: [
|
|
228
|
+
{
|
|
229
|
+
path: CODEX_CONFIG,
|
|
230
|
+
content: codexSnippet(),
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
}),
|
|
234
|
+
},
|
|
235
|
+
cursor: {
|
|
236
|
+
id: "cursor",
|
|
237
|
+
name: "Cursor",
|
|
238
|
+
detect: () => hasCommand("cursor-agent") ||
|
|
239
|
+
existsSync(CURSOR_MCP_JSON) ||
|
|
240
|
+
existsSync(join(homedir(), ".cursor")),
|
|
241
|
+
configure: (mode) => ({
|
|
242
|
+
changed: configureJsonMcpFile(CURSOR_MCP_JSON, mode),
|
|
243
|
+
snippets: [
|
|
244
|
+
{
|
|
245
|
+
path: CURSOR_MCP_JSON,
|
|
246
|
+
content: jsonSnippet({
|
|
247
|
+
mcpServers: {
|
|
248
|
+
almanac: { ...ALMANAC_MCP_ENTRY },
|
|
249
|
+
},
|
|
250
|
+
}),
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
}),
|
|
254
|
+
},
|
|
255
|
+
windsurf: {
|
|
256
|
+
id: "windsurf",
|
|
257
|
+
name: "Windsurf",
|
|
258
|
+
detect: () => hasCommand("windsurf") ||
|
|
259
|
+
existsSync(WINDSURF_MCP_JSON) ||
|
|
260
|
+
existsSync(join(homedir(), ".codeium")),
|
|
261
|
+
configure: (mode) => ({
|
|
262
|
+
changed: configureJsonMcpFile(WINDSURF_MCP_JSON, mode),
|
|
263
|
+
snippets: [
|
|
264
|
+
{
|
|
265
|
+
path: WINDSURF_MCP_JSON,
|
|
266
|
+
content: jsonSnippet({
|
|
267
|
+
mcpServers: {
|
|
268
|
+
almanac: { ...ALMANAC_MCP_ENTRY },
|
|
269
|
+
},
|
|
270
|
+
}),
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
}),
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
function parseSetupArgs(argv) {
|
|
277
|
+
const options = {
|
|
278
|
+
all: false,
|
|
279
|
+
clients: [],
|
|
280
|
+
dryRun: false,
|
|
281
|
+
print: false,
|
|
282
|
+
yes: false,
|
|
283
|
+
};
|
|
284
|
+
for (let i = 0; i < argv.length; i++) {
|
|
285
|
+
const arg = argv[i];
|
|
286
|
+
if (arg === "--all") {
|
|
287
|
+
options.all = true;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (arg === "--print") {
|
|
291
|
+
options.print = true;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (arg === "--dry-run") {
|
|
295
|
+
options.dryRun = true;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (arg === "--yes" || arg === "-y") {
|
|
299
|
+
options.yes = true;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (arg === "--client") {
|
|
303
|
+
const value = argv[i + 1];
|
|
304
|
+
if (!value) {
|
|
305
|
+
throw new Error("Missing value for --client");
|
|
306
|
+
}
|
|
307
|
+
i++;
|
|
308
|
+
options.clients.push(...parseClientList(value));
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (arg.startsWith("--client=")) {
|
|
312
|
+
options.clients.push(...parseClientList(arg.slice("--client=".length)));
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
throw new Error(`Unknown setup flag: ${arg}`);
|
|
316
|
+
}
|
|
317
|
+
if (options.all && options.clients.length > 0) {
|
|
318
|
+
throw new Error("Use either --all or --client, not both");
|
|
319
|
+
}
|
|
320
|
+
options.clients = Array.from(new Set(options.clients));
|
|
321
|
+
return options;
|
|
322
|
+
}
|
|
323
|
+
function parseClientList(value) {
|
|
324
|
+
return value
|
|
325
|
+
.split(",")
|
|
326
|
+
.map((part) => part.trim().toLowerCase())
|
|
327
|
+
.filter(Boolean)
|
|
328
|
+
.map((part) => normalizeClientId(part));
|
|
329
|
+
}
|
|
330
|
+
function normalizeClientId(value) {
|
|
331
|
+
const aliases = {
|
|
332
|
+
claude: "claude-code",
|
|
333
|
+
"claude-code": "claude-code",
|
|
334
|
+
"claude-desktop": "claude-desktop",
|
|
335
|
+
desktop: "claude-desktop",
|
|
336
|
+
codex: "codex",
|
|
337
|
+
cursor: "cursor",
|
|
338
|
+
windsurf: "windsurf",
|
|
339
|
+
};
|
|
340
|
+
const normalized = aliases[value];
|
|
341
|
+
if (!normalized) {
|
|
342
|
+
throw new Error(`Unsupported client "${value}". Supported clients: ${SUPPORTED_CLIENT_IDS.join(", ")}`);
|
|
343
|
+
}
|
|
344
|
+
return normalized;
|
|
345
|
+
}
|
|
346
|
+
function hasCommand(command) {
|
|
347
|
+
const checker = process.platform === "win32" ? "where" : "which";
|
|
348
|
+
const result = spawnSync(checker, [command], { stdio: "ignore" });
|
|
349
|
+
return result.status === 0;
|
|
350
|
+
}
|
|
351
|
+
function getClaudeDesktopConfigPath() {
|
|
352
|
+
if (process.platform === "darwin") {
|
|
353
|
+
return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
354
|
+
}
|
|
355
|
+
if (process.platform === "linux") {
|
|
356
|
+
return join(homedir(), ".config", "Claude", "claude_desktop_config.json");
|
|
357
|
+
}
|
|
358
|
+
if (process.platform === "win32") {
|
|
359
|
+
const appData = process.env.APPDATA;
|
|
360
|
+
if (!appData)
|
|
361
|
+
return null;
|
|
362
|
+
return join(appData, "Claude", "claude_desktop_config.json");
|
|
363
|
+
}
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
function isClaudeDesktopInstalled() {
|
|
367
|
+
if (process.platform === "darwin") {
|
|
368
|
+
return (existsSync("/Applications/Claude.app") ||
|
|
369
|
+
existsSync(join(homedir(), "Applications", "Claude.app")));
|
|
370
|
+
}
|
|
371
|
+
if (process.platform === "linux") {
|
|
372
|
+
return (existsSync("/usr/share/applications/claude.desktop") ||
|
|
373
|
+
existsSync(join(homedir(), ".local", "share", "applications", "claude.desktop")) ||
|
|
374
|
+
existsSync("/opt/Claude/claude"));
|
|
375
|
+
}
|
|
376
|
+
if (process.platform === "win32") {
|
|
377
|
+
const localAppData = process.env.LOCALAPPDATA;
|
|
378
|
+
const programFiles = process.env.ProgramFiles;
|
|
379
|
+
return Boolean((localAppData &&
|
|
380
|
+
existsSync(join(localAppData, "Programs", "Claude", "Claude.exe"))) ||
|
|
381
|
+
(programFiles && existsSync(join(programFiles, "Claude", "Claude.exe"))));
|
|
382
|
+
}
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
function jsonSnippet(data) {
|
|
386
|
+
return JSON.stringify(data, null, 2);
|
|
387
|
+
}
|
|
388
|
+
function codexSnippet() {
|
|
389
|
+
return [
|
|
390
|
+
"[mcp_servers.almanac]",
|
|
391
|
+
`command = ${tomlString(ALMANAC_MCP_ENTRY.command)}`,
|
|
392
|
+
`args = ${tomlArray(ALMANAC_MCP_ENTRY.args)}`,
|
|
393
|
+
].join("\n");
|
|
394
|
+
}
|
|
154
395
|
function isAlmanacCurrent(server) {
|
|
155
396
|
return (server?.command === "npx" &&
|
|
156
397
|
JSON.stringify(server.args) === JSON.stringify(ALMANAC_MCP_ENTRY.args));
|
|
157
398
|
}
|
|
158
|
-
function
|
|
159
|
-
let changed = false;
|
|
160
|
-
// Claude Desktop — ~/.claude.json
|
|
161
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
162
|
-
const desktop = readJson(CLAUDE_JSON);
|
|
163
|
-
if (!desktop.mcpServers)
|
|
164
|
-
desktop.mcpServers = {};
|
|
165
|
-
if (!isAlmanacCurrent(desktop.mcpServers.almanac)) {
|
|
166
|
-
desktop.mcpServers.almanac = { ...ALMANAC_MCP_ENTRY };
|
|
167
|
-
writeJson(CLAUDE_JSON, desktop);
|
|
168
|
-
changed = true;
|
|
169
|
-
}
|
|
170
|
-
// Claude Code — ~/.claude/mcp.json
|
|
171
|
-
ensureDir(CLAUDE_DIR);
|
|
399
|
+
function configureJsonMcpFile(path, mode) {
|
|
172
400
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
173
|
-
const
|
|
174
|
-
if (!
|
|
175
|
-
|
|
176
|
-
if (
|
|
177
|
-
|
|
178
|
-
writeJson(CLAUDE_CODE_MCP, code);
|
|
179
|
-
changed = true;
|
|
401
|
+
const json = readJson(path);
|
|
402
|
+
if (!json.mcpServers)
|
|
403
|
+
json.mcpServers = {};
|
|
404
|
+
if (isAlmanacCurrent(json.mcpServers.almanac)) {
|
|
405
|
+
return false;
|
|
180
406
|
}
|
|
181
|
-
|
|
407
|
+
if (mode === "apply") {
|
|
408
|
+
ensureDir(dirname(path));
|
|
409
|
+
json.mcpServers.almanac = { ...ALMANAC_MCP_ENTRY };
|
|
410
|
+
writeJson(path, json);
|
|
411
|
+
}
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
function configureCodexToml(path, mode) {
|
|
415
|
+
const current = readToml(path);
|
|
416
|
+
const next = upsertCodexServer(current);
|
|
417
|
+
if (current.trim() === next.trim()) {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
if (mode === "apply") {
|
|
421
|
+
ensureDir(dirname(path));
|
|
422
|
+
writeFileSync(path, next.endsWith("\n") ? next : next + "\n");
|
|
423
|
+
}
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
function readToml(path) {
|
|
427
|
+
try {
|
|
428
|
+
return readFileSync(path, "utf-8");
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
return "";
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function upsertCodexServer(content) {
|
|
435
|
+
const sectionName = "mcp_servers.almanac";
|
|
436
|
+
const header = `[${sectionName}]`;
|
|
437
|
+
const nextBody = [
|
|
438
|
+
`command = ${tomlString(ALMANAC_MCP_ENTRY.command)}`,
|
|
439
|
+
`args = ${tomlArray(ALMANAC_MCP_ENTRY.args)}`,
|
|
440
|
+
];
|
|
441
|
+
const lines = content === "" ? [] : content.split(/\r?\n/);
|
|
442
|
+
const start = lines.findIndex((line) => line.trim() === header);
|
|
443
|
+
if (start === -1) {
|
|
444
|
+
const prefix = content.trimEnd();
|
|
445
|
+
const block = [header, ...nextBody].join("\n");
|
|
446
|
+
return prefix === "" ? block + "\n" : `${prefix}\n\n${block}\n`;
|
|
447
|
+
}
|
|
448
|
+
let end = lines.length;
|
|
449
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
450
|
+
if (/^\s*\[.+\]\s*$/.test(lines[i])) {
|
|
451
|
+
end = i;
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const existingBody = lines.slice(start + 1, end);
|
|
456
|
+
const preserved = existingBody.filter((line) => {
|
|
457
|
+
const trimmed = line.trim();
|
|
458
|
+
return !trimmed.startsWith("command =") && !trimmed.startsWith("args =");
|
|
459
|
+
});
|
|
460
|
+
const replacement = [header, ...nextBody, ...preserved];
|
|
461
|
+
const updated = [...lines.slice(0, start), ...replacement, ...lines.slice(end)];
|
|
462
|
+
return updated.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\s+$/, "") + "\n";
|
|
463
|
+
}
|
|
464
|
+
function tomlString(value) {
|
|
465
|
+
return JSON.stringify(value);
|
|
466
|
+
}
|
|
467
|
+
function tomlArray(values) {
|
|
468
|
+
return `[${values.map((value) => tomlString(value)).join(", ")}]`;
|
|
469
|
+
}
|
|
470
|
+
function detectClients() {
|
|
471
|
+
return SUPPORTED_CLIENT_IDS.map((id) => SUPPORTED_CLIENTS[id]).filter((client) => client.detect());
|
|
472
|
+
}
|
|
473
|
+
function resolveClients(options) {
|
|
474
|
+
if (options.clients.length > 0) {
|
|
475
|
+
return options.clients.map((id) => SUPPORTED_CLIENTS[id]);
|
|
476
|
+
}
|
|
477
|
+
const detected = detectClients();
|
|
478
|
+
if (options.all)
|
|
479
|
+
return detected;
|
|
480
|
+
return detected;
|
|
481
|
+
}
|
|
482
|
+
function applyClientSetup(clients, mode) {
|
|
483
|
+
const configured = [];
|
|
484
|
+
const alreadyConfigured = [];
|
|
485
|
+
for (const client of clients) {
|
|
486
|
+
const result = client.configure(mode);
|
|
487
|
+
if (result.changed) {
|
|
488
|
+
configured.push(client.name);
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
alreadyConfigured.push(client.name);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return { configured, alreadyConfigured };
|
|
495
|
+
}
|
|
496
|
+
function printSetupPlan(clients, options) {
|
|
497
|
+
const heading = options.dryRun ? "Dry run" : "OpenAlmanac MCP setup";
|
|
498
|
+
process.stdout.write(`${heading}\n\n`);
|
|
499
|
+
if (clients.length === 0) {
|
|
500
|
+
process.stdout.write("No supported clients detected. Use --client <name> to force a target or --print to inspect supported snippets.\n");
|
|
501
|
+
if (options.print) {
|
|
502
|
+
process.stdout.write("\nSupported clients:\n");
|
|
503
|
+
for (const id of SUPPORTED_CLIENT_IDS) {
|
|
504
|
+
process.stdout.write(`- ${SUPPORTED_CLIENTS[id].name}\n`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
const mode = options.print ? "print" : "dry-run";
|
|
510
|
+
for (const client of clients) {
|
|
511
|
+
const result = client.configure(mode);
|
|
512
|
+
const status = result.changed
|
|
513
|
+
? options.print
|
|
514
|
+
? "snippet"
|
|
515
|
+
: "would configure"
|
|
516
|
+
: "already configured";
|
|
517
|
+
process.stdout.write(`- ${client.name}: ${status}\n`);
|
|
518
|
+
if (options.print) {
|
|
519
|
+
for (const snippet of result.snippets) {
|
|
520
|
+
process.stdout.write(` Path: ${snippet.path}\n`);
|
|
521
|
+
process.stdout.write(`${indentBlock(snippet.content, " ")}\n`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
function indentBlock(content, prefix) {
|
|
527
|
+
return content
|
|
528
|
+
.split("\n")
|
|
529
|
+
.map((line) => `${prefix}${line}`)
|
|
530
|
+
.join("\n");
|
|
182
531
|
}
|
|
183
532
|
/* ── Step 2 — Permissions ──────────────────────────────────────── */
|
|
184
533
|
function configurePermissions(tools) {
|
|
@@ -278,12 +627,15 @@ function waitForKey(prompt) {
|
|
|
278
627
|
});
|
|
279
628
|
}
|
|
280
629
|
async function runLoginStep(agent, mcpChanged, toolCount, mode = "default") {
|
|
630
|
+
const label = agent.includes(",") ? "Clients" : "Agent";
|
|
281
631
|
const priorSteps = () => {
|
|
282
|
-
stepDone(
|
|
632
|
+
stepDone(`${label} \u2192 ${WHITE_BOLD}${agent}${RST}`);
|
|
283
633
|
w(BAR);
|
|
284
634
|
stepDone(`MCP server ${mcpChanged ? "configured" : `${DIM}already configured${RST}`}`);
|
|
285
|
-
|
|
286
|
-
|
|
635
|
+
if (toolCount !== null) {
|
|
636
|
+
w(BAR);
|
|
637
|
+
stepDone(`${BLUE}${toolCount}${RST} tool${toolCount !== 1 ? "s" : ""} allowed`);
|
|
638
|
+
}
|
|
287
639
|
w(BAR);
|
|
288
640
|
};
|
|
289
641
|
function renderLoginChoice(name, cursor) {
|
|
@@ -497,17 +849,21 @@ function runToolSelect(agent, mcpChanged, mode = "default") {
|
|
|
497
849
|
});
|
|
498
850
|
}
|
|
499
851
|
/* ── Result screen ──────────────────────────────────────────────── */
|
|
500
|
-
function printResult(
|
|
852
|
+
function printResult(clientsLabel, loginResult, configured, alreadyConfigured, toolCount) {
|
|
501
853
|
process.stdout.write("\x1b[2J\x1b[H");
|
|
502
854
|
printBanner();
|
|
503
855
|
printBadge();
|
|
504
856
|
w("");
|
|
505
|
-
stepDone(`
|
|
857
|
+
stepDone(`Clients \u2192 ${WHITE_BOLD}${clientsLabel}${RST}`);
|
|
506
858
|
w(BAR);
|
|
507
|
-
stepDone(`
|
|
859
|
+
stepDone(`Configured \u2192 ${configured.length > 0 ? configured.join(", ") : `${DIM}none${RST}`}`);
|
|
508
860
|
w(BAR);
|
|
509
|
-
stepDone(
|
|
861
|
+
stepDone(`Already configured \u2192 ${alreadyConfigured.length > 0 ? alreadyConfigured.join(", ") : `${DIM}none${RST}`}`);
|
|
510
862
|
w(BAR);
|
|
863
|
+
if (toolCount > 0) {
|
|
864
|
+
stepDone(`${BLUE}${toolCount}${RST} tool${toolCount !== 1 ? "s" : ""} allowed in ${DIM}~/.claude/settings.json${RST}`);
|
|
865
|
+
w(BAR);
|
|
866
|
+
}
|
|
511
867
|
stepDone(loginLabel(loginResult));
|
|
512
868
|
w(BAR);
|
|
513
869
|
stepDone(`${BLUE}Setup complete${RST}`);
|
|
@@ -519,37 +875,86 @@ function printResult(agent, loginResult, mcpChanged, toolCount) {
|
|
|
519
875
|
return ` ${BLUE_DIM}\u2502${RST}${content}${" ".repeat(padding)}${BLUE_DIM}\u2502${RST}`;
|
|
520
876
|
};
|
|
521
877
|
const empty = row("");
|
|
878
|
+
const nextSteps = getNextSteps(clientsLabel);
|
|
522
879
|
w(` ${BLUE_DIM}\u256d${"─".repeat(innerW)}\u256e${RST}`);
|
|
523
880
|
w(empty);
|
|
524
881
|
w(row(` ${WHITE_BOLD}Next steps${RST}`));
|
|
525
882
|
w(empty);
|
|
526
|
-
|
|
527
|
-
|
|
883
|
+
for (let i = 0; i < nextSteps.length; i++) {
|
|
884
|
+
w(row(` ${BLUE}${i + 1}.${RST} ${nextSteps[i]}`));
|
|
885
|
+
}
|
|
528
886
|
w(empty);
|
|
529
887
|
w(` ${BLUE_DIM}\u2570${"─".repeat(innerW)}\u256f${RST}`);
|
|
530
888
|
w("");
|
|
531
889
|
}
|
|
890
|
+
function getNextSteps(clientsLabel) {
|
|
891
|
+
if (clientsLabel === "Claude Code") {
|
|
892
|
+
return [
|
|
893
|
+
`Type ${WHITE_BOLD}claude${RST} to start Claude Code`,
|
|
894
|
+
`Say ${BLUE}"Let's explore <topic> using Almanac"${RST}`,
|
|
895
|
+
];
|
|
896
|
+
}
|
|
897
|
+
if (clientsLabel === "Codex") {
|
|
898
|
+
return [
|
|
899
|
+
`Type ${WHITE_BOLD}codex${RST} to start Codex`,
|
|
900
|
+
`Ask ${BLUE}"Let's explore <topic> using Almanac"${RST}`,
|
|
901
|
+
];
|
|
902
|
+
}
|
|
903
|
+
if (clientsLabel === "Cursor") {
|
|
904
|
+
return [
|
|
905
|
+
`Open ${WHITE_BOLD}Cursor${RST} in your project`,
|
|
906
|
+
`Ask ${BLUE}"Let's explore <topic> using Almanac"${RST}`,
|
|
907
|
+
];
|
|
908
|
+
}
|
|
909
|
+
if (clientsLabel === "Windsurf") {
|
|
910
|
+
return [
|
|
911
|
+
`Open ${WHITE_BOLD}Windsurf${RST} in your project`,
|
|
912
|
+
`Ask ${BLUE}"Let's explore <topic> using Almanac"${RST}`,
|
|
913
|
+
];
|
|
914
|
+
}
|
|
915
|
+
if (clientsLabel === "Claude Desktop") {
|
|
916
|
+
return [
|
|
917
|
+
`Open ${WHITE_BOLD}Claude Desktop${RST}`,
|
|
918
|
+
`Ask ${BLUE}"Let's explore <topic> using Almanac"${RST}`,
|
|
919
|
+
];
|
|
920
|
+
}
|
|
921
|
+
return [
|
|
922
|
+
`Open one of your configured agents in this project`,
|
|
923
|
+
`Ask ${BLUE}"Let's explore <topic> using Almanac"${RST}`,
|
|
924
|
+
];
|
|
925
|
+
}
|
|
532
926
|
/* ── Entry point ────────────────────────────────────────────────── */
|
|
533
927
|
export async function runSetup() {
|
|
534
|
-
const
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
928
|
+
const options = parseSetupArgs(process.argv.slice(3));
|
|
929
|
+
const clients = resolveClients(options);
|
|
930
|
+
if (options.print || options.dryRun) {
|
|
931
|
+
printSetupPlan(clients, options);
|
|
932
|
+
process.exit(0);
|
|
539
933
|
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
tools = await runToolSelect(agent, mcpChanged);
|
|
934
|
+
if (clients.length === 0) {
|
|
935
|
+
printSetupPlan(clients, options);
|
|
936
|
+
process.exit(0);
|
|
544
937
|
}
|
|
545
|
-
|
|
546
|
-
|
|
938
|
+
const skipTui = options.yes;
|
|
939
|
+
const interactive = process.stdin.isTTY && !skipTui;
|
|
940
|
+
const setupSummary = applyClientSetup(clients, "apply");
|
|
941
|
+
const permissionClient = clients.find((client) => client.supportsPermissions);
|
|
942
|
+
let tools = [];
|
|
943
|
+
if (permissionClient) {
|
|
944
|
+
const mcpChanged = setupSummary.configured.includes(permissionClient.name);
|
|
945
|
+
if (interactive) {
|
|
946
|
+
tools = await runToolSelect(permissionClient.name, mcpChanged);
|
|
947
|
+
}
|
|
948
|
+
else {
|
|
949
|
+
tools = TOOL_GROUPS.flatMap((g) => g.tools);
|
|
950
|
+
}
|
|
547
951
|
}
|
|
548
|
-
const count = configurePermissions(tools);
|
|
549
|
-
|
|
952
|
+
const count = tools.length > 0 ? configurePermissions(tools) : 0;
|
|
953
|
+
const permissionCount = permissionClient ? count : null;
|
|
550
954
|
let loginResult;
|
|
955
|
+
const clientsLabel = clients.map((client) => client.name).join(", ");
|
|
551
956
|
if (interactive) {
|
|
552
|
-
loginResult = await runLoginStep(
|
|
957
|
+
loginResult = await runLoginStep(clientsLabel, setupSummary.configured.length > 0, permissionCount);
|
|
553
958
|
}
|
|
554
959
|
else {
|
|
555
960
|
try {
|
|
@@ -563,7 +968,7 @@ export async function runSetup() {
|
|
|
563
968
|
loginResult = { status: "skipped" };
|
|
564
969
|
}
|
|
565
970
|
}
|
|
566
|
-
printResult(
|
|
971
|
+
printResult(clientsLabel, loginResult, setupSummary.configured, setupSummary.alreadyConfigured, count);
|
|
567
972
|
process.exit(0);
|
|
568
973
|
}
|
|
569
974
|
/* ── Skill installation ────────────────────────────────────────── */
|
|
@@ -629,7 +1034,8 @@ export async function runRedditSetup() {
|
|
|
629
1034
|
if (interactive) {
|
|
630
1035
|
agent = await runAgentSelect("reddit");
|
|
631
1036
|
}
|
|
632
|
-
const
|
|
1037
|
+
const claudeSetup = SUPPORTED_CLIENTS["claude-code"].configure("apply");
|
|
1038
|
+
const mcpChanged = claudeSetup.changed;
|
|
633
1039
|
let tools;
|
|
634
1040
|
if (interactive) {
|
|
635
1041
|
tools = await runToolSelect(agent, mcpChanged, "reddit");
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { stringify as yamlStringify } from "yaml";
|
|
5
|
+
import { request, ARTICLES_DIR, getAuthStatus } from "../auth.js";
|
|
6
|
+
import { validateArticle, parseFrontmatter } from "../validate.js";
|
|
7
|
+
import { openBrowser } from "../browser.js";
|
|
8
|
+
const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
9
|
+
const WRITING_GUIDE = `
|
|
10
|
+
## Article structure
|
|
11
|
+
|
|
12
|
+
\`\`\`yaml
|
|
13
|
+
---
|
|
14
|
+
article_id: the-slug
|
|
15
|
+
title: Article Title
|
|
16
|
+
sources:
|
|
17
|
+
- key: example-source-title
|
|
18
|
+
url: https://example.com
|
|
19
|
+
title: Source Title
|
|
20
|
+
accessed_date: "2025-01-15"
|
|
21
|
+
infobox:
|
|
22
|
+
header:
|
|
23
|
+
image_url: https://... # optional hero image
|
|
24
|
+
subtitle: Short tagline
|
|
25
|
+
details:
|
|
26
|
+
- key: Born
|
|
27
|
+
value: January 1, 1990
|
|
28
|
+
- key: Occupation
|
|
29
|
+
value: Scientist
|
|
30
|
+
links:
|
|
31
|
+
- https://example.com
|
|
32
|
+
sections:
|
|
33
|
+
- type: timeline # chronological events
|
|
34
|
+
title: Career Timeline
|
|
35
|
+
items:
|
|
36
|
+
- primary: "Started company"
|
|
37
|
+
period: "2010"
|
|
38
|
+
location: "San Francisco"
|
|
39
|
+
- type: list # key figures, works, features
|
|
40
|
+
title: Known For
|
|
41
|
+
items:
|
|
42
|
+
- title: First achievement
|
|
43
|
+
- title: Second achievement
|
|
44
|
+
subtitle: Additional detail
|
|
45
|
+
- type: tags # inline tags/chips
|
|
46
|
+
title: Genres
|
|
47
|
+
items:
|
|
48
|
+
- Rock
|
|
49
|
+
- Jazz
|
|
50
|
+
- type: grid # image grid
|
|
51
|
+
title: Gallery
|
|
52
|
+
items:
|
|
53
|
+
- title: Caption
|
|
54
|
+
image_url: https://...
|
|
55
|
+
- type: table # structured comparison
|
|
56
|
+
title: Statistics
|
|
57
|
+
items:
|
|
58
|
+
headers:
|
|
59
|
+
- Name
|
|
60
|
+
- Value
|
|
61
|
+
rows:
|
|
62
|
+
- cells:
|
|
63
|
+
- Height
|
|
64
|
+
- "6'2\\""
|
|
65
|
+
- type: key_value # simple key-value pairs
|
|
66
|
+
title: Quick Facts
|
|
67
|
+
items:
|
|
68
|
+
- key: Population
|
|
69
|
+
value: "1.4 billion"
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
Article body with [@key] citation markers...
|
|
73
|
+
\`\`\`
|
|
74
|
+
|
|
75
|
+
## Infobox
|
|
76
|
+
|
|
77
|
+
Include an infobox for any article about a person, place, organization, event, or concept. Pick the section types that fit — you don't need all six.
|
|
78
|
+
|
|
79
|
+
## Citations
|
|
80
|
+
|
|
81
|
+
- Mark claims with [@key] after punctuation: "The population is 1.4 billion.[@who-world-population]"
|
|
82
|
+
- Keys must be kebab-case with at least one hyphen (e.g. 'nytimes-climate-report', 'who-malaria-2024')
|
|
83
|
+
- Generate keys BibTeX-style: {domain}-{title-words} (e.g. 'arxiv-attention-is-all')
|
|
84
|
+
- Every source in the sources list must be referenced at least once in the body with [@key]
|
|
85
|
+
- Every [@key] marker must have a matching source with that key
|
|
86
|
+
- Display numbers are computed automatically from first-appearance order — just use the keys
|
|
87
|
+
|
|
88
|
+
## Images
|
|
89
|
+
|
|
90
|
+
Use search_images to find relevant images. Images render as figures with visible captions.
|
|
91
|
+
|
|
92
|
+
**Syntax:** \`\`
|
|
93
|
+
|
|
94
|
+
Positions: \`"right"\` (default if omitted), \`"left"\`, \`"center"\`
|
|
95
|
+
|
|
96
|
+
\`\`\`markdown
|
|
97
|
+

|
|
98
|
+
|
|
99
|
+
The early life of Alan Turing began...
|
|
100
|
+
|
|
101
|
+

|
|
102
|
+
\`\`\`
|
|
103
|
+
|
|
104
|
+
**Caption rules:**
|
|
105
|
+
- Every image MUST have a descriptive caption — it is displayed below the image
|
|
106
|
+
- Describe what the image shows: "Alan Turing in 1930, aged 18" not "Photo"
|
|
107
|
+
- Include dates, context, or attribution when relevant
|
|
108
|
+
|
|
109
|
+
**Placement:** 1-3 images per major section, spread throughout. First image near the top.
|
|
110
|
+
For the infobox hero image, use \`infobox.header.image_url\` in frontmatter instead.
|
|
111
|
+
|
|
112
|
+
External image URLs are auto-persisted on publish — no extra steps needed.
|
|
113
|
+
|
|
114
|
+
## Writing quality
|
|
115
|
+
|
|
116
|
+
- Every sentence should contain a specific fact the reader didn't know
|
|
117
|
+
- No filler phrases ("It is worth noting", "In today's world", "Throughout history")
|
|
118
|
+
- No promotional language ("revolutionary", "groundbreaking", "game-changing")
|
|
119
|
+
- No inflated significance ("one of the most important", "changed the world forever")
|
|
120
|
+
- No vague attribution ("many experts say", "it is widely regarded")
|
|
121
|
+
- No formulaic conclusions ("In conclusion", "continues to shape")
|
|
122
|
+
- Write like a concise encyclopedia, not a blog post
|
|
123
|
+
`.trim();
|
|
124
|
+
function ensureArticlesDir() {
|
|
125
|
+
mkdirSync(ARTICLES_DIR, { recursive: true });
|
|
126
|
+
}
|
|
127
|
+
export function registerArticleTools(server) {
|
|
128
|
+
server.addTool({
|
|
129
|
+
name: "search_articles",
|
|
130
|
+
description: "Search existing OpenAlmanac articles and stubs. Accepts multiple queries for batch lookup. " +
|
|
131
|
+
"Use this to check if articles or stubs exist before creating them, or to find entity slugs for wikilinks. " +
|
|
132
|
+
"Results include 'stub' field (true/false) and 'entity_type' field. No authentication needed.",
|
|
133
|
+
parameters: z.object({
|
|
134
|
+
queries: z.array(z.string()).min(1).max(20).describe("Search queries (1-20)"),
|
|
135
|
+
limit: z.number().default(5).describe("Max results per query (1-50, default 5)"),
|
|
136
|
+
include_stubs: z.boolean().default(true).describe("Include stub articles in results (default true)"),
|
|
137
|
+
}),
|
|
138
|
+
async execute({ queries, limit, include_stubs }) {
|
|
139
|
+
const resp = await request("POST", "/api/search/batch", {
|
|
140
|
+
json: { queries, limit, include_stubs },
|
|
141
|
+
});
|
|
142
|
+
return JSON.stringify(await resp.json(), null, 2);
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
server.addTool({
|
|
146
|
+
name: "read",
|
|
147
|
+
description: "Read article content from OpenAlmanac. Returns the content, sources, and metadata for each slug. " +
|
|
148
|
+
"Use this to reference or summarize existing articles in conversation. " +
|
|
149
|
+
"For editing articles locally, use 'download' instead. No authentication needed.",
|
|
150
|
+
parameters: z.object({
|
|
151
|
+
slugs: z.array(z.string()).min(1).max(20).describe("Article slugs to read (1-20)"),
|
|
152
|
+
}),
|
|
153
|
+
async execute({ slugs }) {
|
|
154
|
+
const resp = await request("POST", "/api/articles/batch", {
|
|
155
|
+
json: { slugs },
|
|
156
|
+
});
|
|
157
|
+
return JSON.stringify(await resp.json(), null, 2);
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
server.addTool({
|
|
161
|
+
name: "create_stubs",
|
|
162
|
+
description: "Create stub articles — placeholders for entities that don't have full articles yet. " +
|
|
163
|
+
"Use this for every entity (person, organization, topic, etc.) mentioned in an article. " +
|
|
164
|
+
"Idempotent: existing slugs return their current status. Requires login.",
|
|
165
|
+
parameters: z.object({
|
|
166
|
+
stubs: z.array(z.object({
|
|
167
|
+
slug: z
|
|
168
|
+
.string()
|
|
169
|
+
.min(1)
|
|
170
|
+
.max(500)
|
|
171
|
+
.describe("Unique kebab-case identifier. For people with LinkedIn: use their vanity ID (e.g. 'john-smith-4a8b2c1'). " +
|
|
172
|
+
"For others: descriptive kebab-case (e.g. 'reinforcement-learning', 'openai')"),
|
|
173
|
+
title: z.string().describe("Display title (e.g. 'John Smith', 'Reinforcement Learning')"),
|
|
174
|
+
entity_type: z
|
|
175
|
+
.enum(["person", "organization", "topic", "event", "creative_work", "place"])
|
|
176
|
+
.optional()
|
|
177
|
+
.describe("Entity type: 'person' for individuals, 'organization' for companies/institutions/nonprofits, " +
|
|
178
|
+
"'topic' for concepts/fields/technologies, 'event' for conferences/historical events, " +
|
|
179
|
+
"'creative_work' for books/papers/films/software, 'place' for cities/countries/landmarks"),
|
|
180
|
+
headline: z
|
|
181
|
+
.string()
|
|
182
|
+
.optional()
|
|
183
|
+
.describe("Short headline (e.g. 'Professor of CS at MIT', 'AI research laboratory')"),
|
|
184
|
+
image_url: z.string().url().optional().describe("Image URL for the entity"),
|
|
185
|
+
summary: z
|
|
186
|
+
.string()
|
|
187
|
+
.optional()
|
|
188
|
+
.describe("2-4 sentence summary of the entity. This becomes the stub page content. " +
|
|
189
|
+
"Be informative — include key facts, dates, and context."),
|
|
190
|
+
})).min(1).max(50).describe("Stubs to create (1-50)"),
|
|
191
|
+
}),
|
|
192
|
+
async execute({ stubs }) {
|
|
193
|
+
const resp = await request("POST", "/api/articles/stubs", {
|
|
194
|
+
auth: true,
|
|
195
|
+
json: { stubs },
|
|
196
|
+
});
|
|
197
|
+
return JSON.stringify(await resp.json(), null, 2);
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
server.addTool({
|
|
201
|
+
name: "download",
|
|
202
|
+
description: "Download an article to your local workspace for editing. The file is saved to ~/.openalmanac/articles/{slug}.md " +
|
|
203
|
+
"with YAML frontmatter. Returns a writing guide covering article structure, infobox format, citations, and quality rules. " +
|
|
204
|
+
"After editing, use 'publish' to push your changes live.",
|
|
205
|
+
parameters: z.object({
|
|
206
|
+
slug: z.string().describe("Article slug (e.g. 'machine-learning')"),
|
|
207
|
+
}),
|
|
208
|
+
async execute({ slug }) {
|
|
209
|
+
if (!SLUG_RE.test(slug)) {
|
|
210
|
+
throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning').`);
|
|
211
|
+
}
|
|
212
|
+
const resp = await request("GET", `/api/articles/${slug}`, {
|
|
213
|
+
params: { format: "md" },
|
|
214
|
+
});
|
|
215
|
+
const markdown = await resp.text();
|
|
216
|
+
ensureArticlesDir();
|
|
217
|
+
const filePath = join(ARTICLES_DIR, `${slug}.md`);
|
|
218
|
+
writeFileSync(filePath, markdown, "utf-8");
|
|
219
|
+
const originalPath = join(ARTICLES_DIR, `.${slug}.original.md`);
|
|
220
|
+
writeFileSync(originalPath, markdown, "utf-8");
|
|
221
|
+
const { frontmatter, content } = parseFrontmatter(markdown);
|
|
222
|
+
const title = frontmatter.title || "(untitled)";
|
|
223
|
+
const wordCount = content.trim().split(/\s+/).filter(Boolean).length;
|
|
224
|
+
const isStub = frontmatter.stub === true;
|
|
225
|
+
const stubNote = isStub
|
|
226
|
+
? "\n\nThis is a STUB article — a placeholder that hasn't been fully written yet. " +
|
|
227
|
+
"Fill in the content body with a complete article, then push to publish."
|
|
228
|
+
: "";
|
|
229
|
+
return `Downloaded "${title}" to ${filePath}\n${wordCount} words, ${frontmatter.sources?.length ?? 0} sources.${stubNote}\n\n${WRITING_GUIDE}`;
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
server.addTool({
|
|
233
|
+
name: "new",
|
|
234
|
+
description: "Create a new article scaffold in your local working directory (~/.openalmanac/articles/). " +
|
|
235
|
+
"The file is created with YAML frontmatter and an empty body. Returns a writing guide covering article structure, infobox format, citations, and quality rules. " +
|
|
236
|
+
"Edit the file to add content and sources, then use publish to go live.",
|
|
237
|
+
parameters: z.object({
|
|
238
|
+
slug: z
|
|
239
|
+
.string()
|
|
240
|
+
.describe("Unique kebab-case identifier (e.g. 'machine-learning'). Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$"),
|
|
241
|
+
title: z.string().describe("Article title"),
|
|
242
|
+
}),
|
|
243
|
+
async execute({ slug, title }) {
|
|
244
|
+
if (!SLUG_RE.test(slug)) {
|
|
245
|
+
throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning'). Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$`);
|
|
246
|
+
}
|
|
247
|
+
ensureArticlesDir();
|
|
248
|
+
const filePath = join(ARTICLES_DIR, `${slug}.md`);
|
|
249
|
+
if (existsSync(filePath)) {
|
|
250
|
+
throw new Error(`File already exists: ${filePath}\nUse download to refresh it, or publish to push changes.`);
|
|
251
|
+
}
|
|
252
|
+
const frontmatter = yamlStringify({ article_id: slug, title, sources: [] });
|
|
253
|
+
const scaffold = `---\n${frontmatter}---\n\n`;
|
|
254
|
+
writeFileSync(filePath, scaffold, "utf-8");
|
|
255
|
+
return `Created ${filePath}\n\n${WRITING_GUIDE}`;
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
server.addTool({
|
|
259
|
+
name: "publish",
|
|
260
|
+
description: "Validate and publish an article from your local workspace. Reads ~/.openalmanac/articles/{slug}.md, " +
|
|
261
|
+
"validates content and sources, and publishes to OpenAlmanac. Requires login.",
|
|
262
|
+
parameters: z.object({
|
|
263
|
+
slug: z.string().describe("Article slug matching the filename (without .md)"),
|
|
264
|
+
change_title: z.string().optional().describe("Short title for the change (e.g. 'Added early life section')"),
|
|
265
|
+
change_description: z.string().optional().describe("Longer description of what changed and why"),
|
|
266
|
+
}),
|
|
267
|
+
async execute({ slug, change_title, change_description }) {
|
|
268
|
+
if (!SLUG_RE.test(slug)) {
|
|
269
|
+
throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning').`);
|
|
270
|
+
}
|
|
271
|
+
const filePath = join(ARTICLES_DIR, `${slug}.md`);
|
|
272
|
+
let raw;
|
|
273
|
+
try {
|
|
274
|
+
raw = readFileSync(filePath, "utf-8");
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
throw new Error(`File not found: ${filePath}\nUse download to get an existing article or new to create a scaffold.`);
|
|
278
|
+
}
|
|
279
|
+
// Local validation
|
|
280
|
+
const errors = validateArticle(raw);
|
|
281
|
+
if (errors.length > 0) {
|
|
282
|
+
const lines = errors.map((e) => ` ${e.field}: ${e.message}`);
|
|
283
|
+
throw new Error(`Validation failed (${errors.length} error${errors.length > 1 ? "s" : ""}):\n${lines.join("\n")}\n\nFix the file and try again.`);
|
|
284
|
+
}
|
|
285
|
+
// Inject change_title/change_description into frontmatter if provided
|
|
286
|
+
let body = raw;
|
|
287
|
+
if (change_title || change_description) {
|
|
288
|
+
const { frontmatter, content } = parseFrontmatter(raw);
|
|
289
|
+
if (change_title)
|
|
290
|
+
frontmatter.change_title = change_title;
|
|
291
|
+
if (change_description)
|
|
292
|
+
frontmatter.change_description = change_description;
|
|
293
|
+
const newFrontmatter = yamlStringify(frontmatter);
|
|
294
|
+
body = `---\n${newFrontmatter}---\n${content}`;
|
|
295
|
+
}
|
|
296
|
+
const resp = await request("PUT", `/api/articles/${slug}`, {
|
|
297
|
+
auth: true,
|
|
298
|
+
body,
|
|
299
|
+
contentType: "text/markdown",
|
|
300
|
+
});
|
|
301
|
+
const data = (await resp.json());
|
|
302
|
+
const articleUrl = `https://www.openalmanac.org/article/${slug}?celebrate=true`;
|
|
303
|
+
openBrowser(articleUrl);
|
|
304
|
+
// Clean up local files after successful publish
|
|
305
|
+
let cleanupWarning = "";
|
|
306
|
+
try {
|
|
307
|
+
unlinkSync(filePath);
|
|
308
|
+
}
|
|
309
|
+
catch (e) {
|
|
310
|
+
if (e.code !== "ENOENT") {
|
|
311
|
+
cleanupWarning = `\nNote: could not remove local draft: ${e.message}`;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
unlinkSync(join(ARTICLES_DIR, `.${slug}.original.md`));
|
|
316
|
+
}
|
|
317
|
+
catch (e) {
|
|
318
|
+
if (e.code !== "ENOENT") {
|
|
319
|
+
cleanupWarning += `\nNote: could not remove original copy: ${e.message}`;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return `Pushed successfully.\n\nArticle URL (share this exact link with the user): ${articleUrl}${cleanupWarning}\n\n${JSON.stringify(data, null, 2)}`;
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
server.addTool({
|
|
326
|
+
name: "requested_articles",
|
|
327
|
+
description: "List requested articles — stubs that are referenced by the most articles but haven't been fully written yet. " +
|
|
328
|
+
"Use this to find high-demand topics to write about. No authentication needed.",
|
|
329
|
+
parameters: z.object({
|
|
330
|
+
limit: z.number().default(20).describe("Max results (1-200, default 20)"),
|
|
331
|
+
offset: z.number().default(0).describe("Pagination offset (default 0)"),
|
|
332
|
+
}),
|
|
333
|
+
async execute({ limit, offset }) {
|
|
334
|
+
const resp = await request("GET", "/api/articles/requested", {
|
|
335
|
+
params: { limit, offset },
|
|
336
|
+
});
|
|
337
|
+
return JSON.stringify(await resp.json(), null, 2);
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
server.addTool({
|
|
341
|
+
name: "propose_article",
|
|
342
|
+
description: "Propose an article before writing it. Call this when you've researched enough and a specific article topic has come into focus. " +
|
|
343
|
+
"Structures your proposal with a user-facing summary and a detailed brief. " +
|
|
344
|
+
"The client environment determines what happens next — in GUI environments the user sees a plan card with options, " +
|
|
345
|
+
"in CLI environments you'll get a response telling you to proceed with writing. " +
|
|
346
|
+
"Do not start writing an article without proposing first.",
|
|
347
|
+
parameters: z.object({
|
|
348
|
+
summary: z.string().describe("User-facing summary: title, key sections, angle. Markdown. Concise — 3-5 bullet points."),
|
|
349
|
+
details: z.string().describe("Full handoff brief for the background agent. Include: all sources, key facts, user preferences, angle, what to avoid, related articles. Be thorough."),
|
|
350
|
+
title: z.string().describe("Proposed article title"),
|
|
351
|
+
slug: z.string().describe("Proposed article slug (kebab-case)"),
|
|
352
|
+
_userChoice: z.enum(["background", "here", "expired", "already_in_progress"]).optional().describe("Internal field set by GUI client. Never set this manually."),
|
|
353
|
+
}),
|
|
354
|
+
async execute({ summary, details, title, slug, _userChoice }) {
|
|
355
|
+
if (!SLUG_RE.test(slug)) {
|
|
356
|
+
throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning'). Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$`);
|
|
357
|
+
}
|
|
358
|
+
if (_userChoice === "background") {
|
|
359
|
+
return `Article "${title}" is now being written in a background process. Continue exploring with the user. Do not write this article in this conversation.`;
|
|
360
|
+
}
|
|
361
|
+
if (_userChoice === "expired") {
|
|
362
|
+
return `The user navigated away before responding to the proposal. Proposal expired. Continue the conversation naturally.`;
|
|
363
|
+
}
|
|
364
|
+
if (_userChoice === "already_in_progress") {
|
|
365
|
+
return `Article "${title}" is already being generated in a background process. No action needed.`;
|
|
366
|
+
}
|
|
367
|
+
// "here" OR no _userChoice (CLI default) — proceed with writing
|
|
368
|
+
return `Article Proposal: ${title}\n\n${summary}\n\nProceed with writing this article following the writing flow in your instructions.`;
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
server.addTool({
|
|
372
|
+
name: "status",
|
|
373
|
+
description: "Show login status and list all article files in your local working directory (~/.openalmanac/articles/). " +
|
|
374
|
+
"Shows auth state, filename, title, file size, and last modified time.",
|
|
375
|
+
async execute() {
|
|
376
|
+
ensureArticlesDir();
|
|
377
|
+
const auth = await getAuthStatus();
|
|
378
|
+
const authLine = auth.loggedIn
|
|
379
|
+
? `Logged in as ${auth.name}.`
|
|
380
|
+
: "Not logged in. Use login to authenticate.";
|
|
381
|
+
const files = readdirSync(ARTICLES_DIR).filter((f) => f.endsWith(".md") && !f.startsWith("."));
|
|
382
|
+
if (files.length === 0) {
|
|
383
|
+
return `${authLine}\n\nLocal articles (0 files in ${ARTICLES_DIR}):\n (none — use download or new to get started)`;
|
|
384
|
+
}
|
|
385
|
+
const rows = [];
|
|
386
|
+
for (const file of files) {
|
|
387
|
+
const filePath = join(ARTICLES_DIR, file);
|
|
388
|
+
const stat = statSync(filePath);
|
|
389
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
390
|
+
const { frontmatter } = parseFrontmatter(raw);
|
|
391
|
+
const title = frontmatter.title || "(untitled)";
|
|
392
|
+
const size = stat.size < 1024
|
|
393
|
+
? `${stat.size}B`
|
|
394
|
+
: `${(stat.size / 1024).toFixed(1)}KB`;
|
|
395
|
+
const modified = stat.mtime.toISOString().slice(0, 16).replace("T", " ");
|
|
396
|
+
rows.push(` ${file} — "${title}" (${size}, modified ${modified})`);
|
|
397
|
+
}
|
|
398
|
+
return `${authLine}\n\nLocal articles (${files.length} file(s) in ${ARTICLES_DIR}):\n${rows.join("\n")}`;
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { request } from "../auth.js";
|
|
3
|
+
export function registerCommunityTools(server) {
|
|
4
|
+
server.addTool({
|
|
5
|
+
name: "search_communities",
|
|
6
|
+
description: "Search or list OpenAlmanac communities. Returns community names, descriptions, and member counts. " +
|
|
7
|
+
"Use this after pushing an article to find relevant communities for auto-linking. No authentication needed.",
|
|
8
|
+
parameters: z.object({
|
|
9
|
+
query: z
|
|
10
|
+
.string()
|
|
11
|
+
.optional()
|
|
12
|
+
.describe("Search term (case-insensitive match on name, slug, or description). Omit to list all."),
|
|
13
|
+
sort: z
|
|
14
|
+
.enum(["popular", "newest"])
|
|
15
|
+
.default("popular")
|
|
16
|
+
.describe("Sort order (default: popular)"),
|
|
17
|
+
limit: z
|
|
18
|
+
.number()
|
|
19
|
+
.min(1)
|
|
20
|
+
.max(100)
|
|
21
|
+
.default(20)
|
|
22
|
+
.describe("Max results (1-100, default 20)"),
|
|
23
|
+
}),
|
|
24
|
+
async execute({ query, sort, limit }) {
|
|
25
|
+
const params = { sort, limit };
|
|
26
|
+
if (query)
|
|
27
|
+
params.query = query;
|
|
28
|
+
const resp = await request("GET", "/api/communities", { params });
|
|
29
|
+
const data = (await resp.json());
|
|
30
|
+
const communities = data.communities.map((c) => ({
|
|
31
|
+
slug: c.slug,
|
|
32
|
+
name: c.name,
|
|
33
|
+
description: c.description,
|
|
34
|
+
member_count: c.member_count,
|
|
35
|
+
created_at: c.created_at,
|
|
36
|
+
}));
|
|
37
|
+
return `Found ${data.total} communities:\n\n${JSON.stringify(communities, null, 2)}`;
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
server.addTool({
|
|
41
|
+
name: "create_community",
|
|
42
|
+
description: "Create a new OpenAlmanac community. Requires login and at least 1 published article. " +
|
|
43
|
+
"Communities are spaces where articles can be curated and discussed around a topic.",
|
|
44
|
+
parameters: z.object({
|
|
45
|
+
name: z.string().min(1).max(100).describe("Community name (1-100 chars)"),
|
|
46
|
+
slug: z
|
|
47
|
+
.string()
|
|
48
|
+
.min(1)
|
|
49
|
+
.max(100)
|
|
50
|
+
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
|
51
|
+
.describe("Unique kebab-case identifier (e.g. 'machine-learning')"),
|
|
52
|
+
description: z
|
|
53
|
+
.string()
|
|
54
|
+
.min(1)
|
|
55
|
+
.max(2000)
|
|
56
|
+
.describe("What the community is about (1-2000 chars)"),
|
|
57
|
+
}),
|
|
58
|
+
async execute({ name, slug, description }) {
|
|
59
|
+
const resp = await request("POST", "/api/communities", {
|
|
60
|
+
auth: true,
|
|
61
|
+
json: { name, slug, description },
|
|
62
|
+
});
|
|
63
|
+
const data = (await resp.json());
|
|
64
|
+
const communityUrl = `https://www.openalmanac.org/communities/${slug}`;
|
|
65
|
+
return `Community created!\n\nURL: ${communityUrl}\n\n${JSON.stringify(data, null, 2)}`;
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
server.addTool({
|
|
69
|
+
name: "create_post",
|
|
70
|
+
description: "Create a post in an OpenAlmanac community. Requires login and community membership. " +
|
|
71
|
+
"If you get a 403 error, you need to join the community first.",
|
|
72
|
+
parameters: z.object({
|
|
73
|
+
community_slug: z.string().describe("Community slug (e.g. 'machine-learning')"),
|
|
74
|
+
title: z.string().min(1).max(300).describe("Post title (1-300 chars)"),
|
|
75
|
+
body: z.string().max(10000).default("").describe("Post body (max 10000 chars)"),
|
|
76
|
+
flair: z
|
|
77
|
+
.enum(["discussion", "article-request", "question", "announcement"])
|
|
78
|
+
.optional()
|
|
79
|
+
.describe("Post flair/category"),
|
|
80
|
+
}),
|
|
81
|
+
async execute({ community_slug, title, body, flair }) {
|
|
82
|
+
const json = { title, body };
|
|
83
|
+
if (flair)
|
|
84
|
+
json.flair = flair;
|
|
85
|
+
const resp = await request("POST", `/api/communities/${community_slug}/posts`, {
|
|
86
|
+
auth: true,
|
|
87
|
+
json,
|
|
88
|
+
});
|
|
89
|
+
const data = (await resp.json());
|
|
90
|
+
const postUrl = `https://www.openalmanac.org/communities/${community_slug}/post/${data.id}`;
|
|
91
|
+
return `Post created!\n\nURL: ${postUrl}\n\n${JSON.stringify(data, null, 2)}`;
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
server.addTool({
|
|
95
|
+
name: "link_article",
|
|
96
|
+
description: "Link an article to one or more communities. Use this after pushing an article to connect it " +
|
|
97
|
+
"with relevant communities. Call search_communities first to find matching communities. " +
|
|
98
|
+
"Idempotent — already-linked articles are reported but don't cause errors. Requires login.",
|
|
99
|
+
parameters: z.object({
|
|
100
|
+
article_id: z.string().describe("Article slug/ID to link (e.g. 'machine-learning')"),
|
|
101
|
+
community_slugs: z
|
|
102
|
+
.array(z.string())
|
|
103
|
+
.min(1)
|
|
104
|
+
.max(50)
|
|
105
|
+
.describe("List of community slugs to link the article to (max 50)"),
|
|
106
|
+
}),
|
|
107
|
+
async execute({ article_id, community_slugs }) {
|
|
108
|
+
const resp = await request("POST", `/api/articles/${article_id}/auto-link`, {
|
|
109
|
+
auth: true,
|
|
110
|
+
json: { community_slugs },
|
|
111
|
+
});
|
|
112
|
+
const data = (await resp.json());
|
|
113
|
+
const lines = [];
|
|
114
|
+
if (data.linked.length > 0) {
|
|
115
|
+
lines.push(`Linked to ${data.linked.length} communities: ${data.linked.join(", ")}`);
|
|
116
|
+
}
|
|
117
|
+
if (data.failed.length > 0) {
|
|
118
|
+
const failLines = data.failed.map((f) => ` ${f.slug}: ${f.reason}`);
|
|
119
|
+
lines.push(`Failed (${data.failed.length}):\n${failLines.join("\n")}`);
|
|
120
|
+
}
|
|
121
|
+
if (lines.length === 0) {
|
|
122
|
+
lines.push("No communities to link.");
|
|
123
|
+
}
|
|
124
|
+
return lines.join("\n\n");
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
package/dist/tools/pages.js
CHANGED
|
@@ -283,8 +283,10 @@ export function registerPageTools(server) {
|
|
|
283
283
|
continue;
|
|
284
284
|
}
|
|
285
285
|
okCount++;
|
|
286
|
-
//
|
|
287
|
-
|
|
286
|
+
// The local file was named with the pre-rename slug. The server returns
|
|
287
|
+
// `renamed_from` on rename so we can clean up the right file without
|
|
288
|
+
// relying on request/response index parity.
|
|
289
|
+
const published_slug = r.renamed_from ?? r.slug;
|
|
288
290
|
const { filePath, refPath } = resolvePagePaths(published_slug, wiki_slug);
|
|
289
291
|
try {
|
|
290
292
|
unlinkSync(filePath);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { request } from "../auth.js";
|
|
3
|
+
export function registerPeopleTools(server) {
|
|
4
|
+
server.addTool({
|
|
5
|
+
name: "search_people",
|
|
6
|
+
description: "Search for people to find their canonical slug for linking. Returns candidates with name, headline, " +
|
|
7
|
+
"image, and location. Use the returned slug when creating stubs and [[links]] for people. Requires login.",
|
|
8
|
+
parameters: z.object({
|
|
9
|
+
query: z.string().describe("Search terms (e.g. 'John Smith MIT professor')"),
|
|
10
|
+
limit: z.number().min(1).max(10).default(5).describe("Max results (1-10, default 5)"),
|
|
11
|
+
}),
|
|
12
|
+
async execute({ query, limit }) {
|
|
13
|
+
const resp = await request("GET", "/api/people/search", {
|
|
14
|
+
auth: true,
|
|
15
|
+
params: { query, limit },
|
|
16
|
+
});
|
|
17
|
+
return JSON.stringify(await resp.json(), null, 2);
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
}
|
package/dist/tools/wikis.js
CHANGED
|
@@ -119,13 +119,13 @@ export function registerWikiTools(server) {
|
|
|
119
119
|
nav: coerceJson(z.array(navItemSchema)).describe("Nav items"),
|
|
120
120
|
}),
|
|
121
121
|
async execute({ wiki_slug, nav }) {
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
122
|
+
// Single atomic PATCH — the backend's update_settings service uses
|
|
123
|
+
// model_dump(exclude_unset=True) to merge partial bodies, so sending
|
|
124
|
+
// only {nav} doesn't touch theme or cover_image_url. Previously this
|
|
125
|
+
// did a GET-then-PATCH, which raced with concurrent settings updates.
|
|
126
126
|
const resp = await request("PATCH", `/api/w/${wiki_slug}/settings`, {
|
|
127
127
|
auth: true,
|
|
128
|
-
json:
|
|
128
|
+
json: { nav },
|
|
129
129
|
});
|
|
130
130
|
return JSON.stringify(await resp.json(), null, 2);
|
|
131
131
|
},
|
package/dist/validate.js
CHANGED
|
@@ -2,13 +2,13 @@ import { parse as parseYaml } from "yaml";
|
|
|
2
2
|
import { z, ZodError } from "zod";
|
|
3
3
|
// ── Primitives ──────────────────────────────────────────────────
|
|
4
4
|
const sourceKeyRe = /^[a-z0-9]+-[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
5
|
+
// Match pydantic's `date` coercion: a bare YYYY-MM-DD date, OR any ISO-8601
|
|
6
|
+
// datetime prefix — pydantic accepts "2026-04-17T00:00:00Z" as a date. yaml
|
|
7
|
+
// may deserialize either as a `Date` object or a string.
|
|
8
|
+
const isoDateLikeRe = /^\d{4}-\d{2}-\d{2}([T ].*)?$/;
|
|
9
9
|
const accessedDateSchema = z.union([
|
|
10
10
|
z.date(),
|
|
11
|
-
z.string().regex(
|
|
11
|
+
z.string().regex(isoDateLikeRe, "Must be YYYY-MM-DD (optionally with an ISO time suffix)"),
|
|
12
12
|
]);
|
|
13
13
|
// ── Source — mirrors backend/src/schemas/source_schemas.py ──────
|
|
14
14
|
export const sourceSchema = z.object({
|
|
@@ -36,6 +36,10 @@ const headerBlockSchema = z.object({
|
|
|
36
36
|
// `list` section with ListItem.link.
|
|
37
37
|
links: z.array(z.string()).default([]),
|
|
38
38
|
}).strict();
|
|
39
|
+
// Matches `default_factory=HeaderBlock` on the pydantic model. A factory
|
|
40
|
+
// (not a literal) so each default produces a fresh object — preventing
|
|
41
|
+
// shared-reference aliasing if a caller mutates the default.
|
|
42
|
+
const emptyHeader = () => ({ details: [], links: [] });
|
|
39
43
|
const timelineItemSchema = z.object({
|
|
40
44
|
primary: z.string(),
|
|
41
45
|
secondary: z.string().nullable().optional(),
|
|
@@ -105,7 +109,7 @@ const sectionSchema = z.discriminatedUnion("type", [
|
|
|
105
109
|
keyValueSectionSchema,
|
|
106
110
|
]);
|
|
107
111
|
export const infoboxSchema = z.object({
|
|
108
|
-
header: headerBlockSchema.default(
|
|
112
|
+
header: headerBlockSchema.default(emptyHeader),
|
|
109
113
|
sections: z.array(sectionSchema).default([]),
|
|
110
114
|
}).strict();
|
|
111
115
|
// ── Frontmatter — mirrors backend PagePublishFrontmatter ────────
|