openalmanac 0.3.3 → 0.3.4

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.
@@ -2,10 +2,37 @@ import { z } from "zod";
2
2
  import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync, unlinkSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { stringify as yamlStringify } from "yaml";
5
- import { request, ARTICLES_DIR, getAuthStatus } from "../auth.js";
5
+ import { request, ARTICLES_DIR } from "../auth.js";
6
6
  import { validateArticle, parseFrontmatter } from "../validate.js";
7
7
  import { openBrowser } from "../browser.js";
8
8
  const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
9
+ function slugify(title) {
10
+ return title
11
+ .toLowerCase()
12
+ .normalize("NFD")
13
+ .replace(/[\u0300-\u036f]/g, "")
14
+ .replace(/[^a-z0-9]+/g, "-")
15
+ .replace(/^-+|-+$/g, "")
16
+ .replace(/-{2,}/g, "-");
17
+ }
18
+ /**
19
+ * Workaround for Claude Agent SDK MCP transport bug (#18260):
20
+ * Array/object parameters are sometimes serialized as JSON strings
21
+ * instead of native values. This preprocessor coerces them back.
22
+ */
23
+ function coerceJson(schema) {
24
+ return z.preprocess((val) => {
25
+ if (typeof val === "string") {
26
+ try {
27
+ return JSON.parse(val);
28
+ }
29
+ catch {
30
+ return val;
31
+ }
32
+ }
33
+ return val;
34
+ }, schema);
35
+ }
9
36
  const WRITING_GUIDE = `
10
37
  ## Article structure
11
38
 
@@ -124,21 +151,79 @@ External image URLs are auto-persisted on publish — no extra steps needed.
124
151
  function ensureArticlesDir() {
125
152
  mkdirSync(ARTICLES_DIR, { recursive: true });
126
153
  }
154
+ function resolveArticleDir(communitySlug) {
155
+ return communitySlug ? join(ARTICLES_DIR, communitySlug) : ARTICLES_DIR;
156
+ }
157
+ function resolveArticlePaths(slug, communitySlug) {
158
+ const dir = resolveArticleDir(communitySlug);
159
+ return {
160
+ dir,
161
+ filePath: join(dir, `${slug}.md`),
162
+ originalPath: join(dir, `.${slug}.original.md`),
163
+ };
164
+ }
165
+ function findDraftCandidates(slug) {
166
+ const matches = [];
167
+ const root = resolveArticlePaths(slug);
168
+ if (existsSync(root.filePath)) {
169
+ matches.push({ communitySlug: null, filePath: root.filePath, originalPath: root.originalPath });
170
+ }
171
+ try {
172
+ const dirs = readdirSync(ARTICLES_DIR).filter((d) => {
173
+ try {
174
+ return statSync(join(ARTICLES_DIR, d)).isDirectory();
175
+ }
176
+ catch {
177
+ return false;
178
+ }
179
+ });
180
+ for (const communitySlug of dirs) {
181
+ const scoped = resolveArticlePaths(slug, communitySlug);
182
+ if (existsSync(scoped.filePath)) {
183
+ matches.push({ communitySlug, filePath: scoped.filePath, originalPath: scoped.originalPath });
184
+ }
185
+ }
186
+ }
187
+ catch { /* ignore readdir errors */ }
188
+ return matches;
189
+ }
190
+ function resolvePublishCandidate(slug, communitySlug) {
191
+ if (communitySlug) {
192
+ const paths = resolveArticlePaths(slug, communitySlug);
193
+ if (!existsSync(paths.filePath)) {
194
+ throw new Error(`File not found: ${paths.filePath}\nUse download to get an existing article or new to create a scaffold.`);
195
+ }
196
+ return { communitySlug, ...paths };
197
+ }
198
+ const matches = findDraftCandidates(slug);
199
+ if (matches.length === 0) {
200
+ const fallback = resolveArticlePaths(slug);
201
+ throw new Error(`File not found: ${fallback.filePath}\nUse download to get an existing article or new to create a scaffold.`);
202
+ }
203
+ if (matches.length > 1) {
204
+ const paths = matches.map((match) => ` - ${match.filePath}`).join("\n");
205
+ throw new Error(`Multiple local drafts found for slug "${slug}". Publish is ambiguous.\n${paths}\nRemove the duplicate draft or publish with community_slug set.`);
206
+ }
207
+ return matches[0];
208
+ }
127
209
  export function registerArticleTools(server) {
128
210
  server.addTool({
129
211
  name: "search_articles",
130
212
  description: "Search existing OpenAlmanac articles and stubs. Accepts multiple queries for batch lookup. " +
131
213
  "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.",
214
+ "Results include 'stub' field (true/false) and 'entity_type' field. " +
215
+ "Pass community_slug to restrict results to articles owned by that community wiki (does not include global articles). No authentication needed.",
133
216
  parameters: z.object({
134
- queries: z.array(z.string()).min(1).max(20).describe("Search queries (1-20)"),
217
+ queries: coerceJson(z.array(z.string()).min(1).max(20)).describe("Search queries (1-20)"),
135
218
  limit: z.number().default(5).describe("Max results per query (1-50, default 5)"),
136
219
  include_stubs: z.boolean().default(true).describe("Include stub articles in results (default true)"),
220
+ community_slug: z.string().optional().describe("Optional community slug. When set, results are restricted to articles owned by that community wiki only — global articles are excluded. Omit to search all of OpenAlmanac."),
137
221
  }),
138
- async execute({ queries, limit, include_stubs }) {
139
- const resp = await request("POST", "/api/search/batch", {
140
- json: { queries, limit, include_stubs },
141
- });
222
+ async execute({ queries, limit, include_stubs, community_slug }) {
223
+ const json = { queries, limit, include_stubs };
224
+ if (community_slug)
225
+ json.community_slug = community_slug;
226
+ const resp = await request("POST", "/api/search/batch", { json });
142
227
  return JSON.stringify(await resp.json(), null, 2);
143
228
  },
144
229
  });
@@ -148,192 +233,247 @@ export function registerArticleTools(server) {
148
233
  "Use this to reference or summarize existing articles in conversation. " +
149
234
  "For editing articles locally, use 'download' instead. No authentication needed.",
150
235
  parameters: z.object({
151
- slugs: z.array(z.string()).min(1).max(20).describe("Article slugs to read (1-20)"),
236
+ slugs: coerceJson(z.array(z.string()).min(1).max(20)).describe("Article slugs to read (1-20)"),
237
+ community_slug: z.string().optional().describe("Community slug for reading community-owned wiki articles. Omit for global almanac articles."),
152
238
  }),
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
- });
239
+ async execute({ slugs, community_slug }) {
240
+ const json = { slugs };
241
+ if (community_slug)
242
+ json.community_slug = community_slug;
243
+ const resp = await request("POST", "/api/articles/batch", { json });
197
244
  return JSON.stringify(await resp.json(), null, 2);
198
245
  },
199
246
  });
200
247
  server.addTool({
201
248
  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.",
249
+ description: "Download articles to your local workspace for editing. " +
250
+ "Global articles: ~/.openalmanac/articles/{slug}.md. Community wiki: ~/.openalmanac/articles/{community_slug}/{slug}.md. " +
251
+ "Returns a writing guide on first call. After editing, use publish to push changes.",
205
252
  parameters: z.object({
206
- slug: z.string().describe("Article slug (e.g. 'machine-learning')"),
253
+ slugs: coerceJson(z.array(z.string()).min(1).max(50)).describe("Article slugs to download (1-50)"),
254
+ community_slug: z.string().optional().describe("Community slug for community-owned wiki articles"),
207
255
  }),
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').`);
256
+ async execute({ slugs, community_slug }) {
257
+ for (const slug of slugs) {
258
+ if (!SLUG_RE.test(slug)) {
259
+ throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning').`);
260
+ }
211
261
  }
212
- const resp = await request("GET", `/api/articles/${slug}`, {
213
- params: { format: "md" },
214
- });
215
- const markdown = await resp.text();
262
+ const json = { slugs };
263
+ if (community_slug)
264
+ json.community_slug = community_slug;
265
+ const resp = await request("POST", "/api/articles/batch-download", { json });
266
+ const data = (await resp.json());
216
267
  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}`;
268
+ const lines = [];
269
+ for (const slug of slugs) {
270
+ if (data.errors[slug]) {
271
+ lines.push(`FAILED ${slug}: ${data.errors[slug]}`);
272
+ continue;
273
+ }
274
+ const markdown = data.articles[slug];
275
+ if (!markdown) {
276
+ lines.push(`FAILED ${slug}: missing from response`);
277
+ continue;
278
+ }
279
+ const { dir, filePath, originalPath } = resolveArticlePaths(slug, community_slug);
280
+ if (community_slug) {
281
+ mkdirSync(dir, { recursive: true });
282
+ }
283
+ writeFileSync(filePath, markdown, "utf-8");
284
+ writeFileSync(originalPath, markdown, "utf-8");
285
+ const { frontmatter, content } = parseFrontmatter(markdown);
286
+ const title = frontmatter.title || "(untitled)";
287
+ const wordCount = content.trim().split(/\s+/).filter(Boolean).length;
288
+ const isStub = frontmatter.stub === true;
289
+ const stubNote = isStub
290
+ ? "\n\nThis is a STUB article — a placeholder that hasn't been fully written yet. " +
291
+ "Fill in the content body with a complete article, then push to publish."
292
+ : "";
293
+ lines.push(`Downloaded "${title}" to ${filePath}\n${wordCount} words, ${frontmatter.sources?.length ?? 0} sources.${stubNote}`);
294
+ }
295
+ return [lines.join("\n"), "", WRITING_GUIDE].join("\n");
230
296
  },
231
297
  });
232
298
  server.addTool({
233
299
  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.",
300
+ description: "Scaffold new articles locally. Creates .md files with YAML frontmatter and empty bodies. " +
301
+ "Provide explicit slugs when you know the canonical ID; otherwise they are auto-derived from titles. For community wiki articles, provide community_slug. " +
302
+ "After writing content, use publish to go live.",
237
303
  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"),
304
+ articles: coerceJson(z.array(z.object({
305
+ title: z.string().describe("Article title"),
306
+ slug: z.string().optional().describe("Optional explicit kebab-case slug. Encouraged when you know the canonical ID."),
307
+ topics: z.array(z.string()).optional().describe("Topic slugs for community wiki articles"),
308
+ })).min(1).max(50)).describe("Articles to scaffold (1-50)"),
309
+ community_slug: z.string().optional().describe("Community slug for community-owned wiki articles"),
242
310
  }),
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]+)*$`);
311
+ async execute({ articles, community_slug }) {
312
+ if (community_slug && !SLUG_RE.test(community_slug)) {
313
+ throw new Error(`Invalid community_slug "${community_slug}". Must be kebab-case.`);
246
314
  }
247
315
  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.`);
316
+ let dir = ARTICLES_DIR;
317
+ if (community_slug) {
318
+ dir = join(ARTICLES_DIR, community_slug);
319
+ mkdirSync(dir, { recursive: true });
320
+ }
321
+ const created = [];
322
+ const skipped = [];
323
+ for (const item of articles) {
324
+ const slug = item.slug || slugify(item.title);
325
+ if (!slug) {
326
+ skipped.push(`(empty slug from title: "${item.title}")`);
327
+ continue;
328
+ }
329
+ if (!SLUG_RE.test(slug)) {
330
+ skipped.push(`"${item.title}" → invalid slug "${slug}"`);
331
+ continue;
332
+ }
333
+ const filePath = join(dir, `${slug}.md`);
334
+ if (existsSync(filePath)) {
335
+ skipped.push(`${slug}.md already exists — skipped`);
336
+ continue;
337
+ }
338
+ const meta = { article_id: slug, title: item.title };
339
+ if (community_slug)
340
+ meta.community_slug = community_slug;
341
+ if (item.topics && item.topics.length > 0)
342
+ meta.topics = item.topics;
343
+ meta.sources = [];
344
+ const frontmatter = yamlStringify(meta);
345
+ const scaffold = `---\n${frontmatter}---\n\n`;
346
+ writeFileSync(filePath, scaffold, "utf-8");
347
+ created.push(filePath);
251
348
  }
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}`;
349
+ const parts = [
350
+ created.length > 0 ? `Created ${created.length} file(s):\n${created.map((p) => ` - ${p}`).join("\n")}` : "No new files created.",
351
+ skipped.length > 0 ? `Skipped:\n${skipped.map((s) => ` - ${s}`).join("\n")}` : "",
352
+ WRITING_GUIDE,
353
+ ];
354
+ return parts.filter(Boolean).join("\n\n");
256
355
  },
257
356
  });
258
357
  server.addTool({
259
358
  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.",
359
+ description: "Validate and publish articles from your local workspace. " +
360
+ "Provide specific slugs, or a community_slug to publish all articles in that community folder. " +
361
+ "Empty-body files become stubs. Dead wikilinks auto-create stubs on the server. " +
362
+ "Put edit_summary in frontmatter for per-article change descriptions. Requires login.",
262
363
  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"),
364
+ slugs: coerceJson(z.array(z.string()).min(1).max(50)).optional()
365
+ .describe("Specific article slugs to publish"),
366
+ community_slug: z.string().optional()
367
+ .describe("Publish all .md files in this community folder under ~/.openalmanac/articles/"),
266
368
  }),
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').`);
369
+ async execute({ slugs, community_slug }) {
370
+ if (!slugs?.length && !community_slug) {
371
+ throw new Error("Provide slugs or community_slug (explicit intent required).");
270
372
  }
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);
373
+ const tasks = [];
374
+ if (community_slug && !slugs?.length) {
375
+ if (!SLUG_RE.test(community_slug)) {
376
+ throw new Error(`Invalid community_slug "${community_slug}". Must be kebab-case.`);
377
+ }
378
+ const dir = join(ARTICLES_DIR, community_slug);
379
+ if (!existsSync(dir)) {
380
+ throw new Error(`Community folder not found: ${dir}`);
381
+ }
382
+ const files = readdirSync(dir).filter((f) => f.endsWith(".md") && !f.startsWith("."));
383
+ if (files.length === 0) {
384
+ throw new Error(`No .md files in ${dir}`);
385
+ }
386
+ for (const f of files) {
387
+ const slug = f.replace(/\.md$/i, "");
388
+ tasks.push({ slug, communitySlug: community_slug, ...resolveArticlePaths(slug, community_slug) });
389
+ }
308
390
  }
309
- catch (e) {
310
- if (e.code !== "ENOENT") {
311
- cleanupWarning = `\nNote: could not remove local draft: ${e.message}`;
391
+ else if (slugs?.length) {
392
+ for (const slug of slugs) {
393
+ if (!SLUG_RE.test(slug)) {
394
+ throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning').`);
395
+ }
396
+ tasks.push({ slug, ...resolvePublishCandidate(slug, community_slug ?? undefined) });
312
397
  }
313
398
  }
314
- try {
315
- unlinkSync(join(ARTICLES_DIR, `.${slug}.original.md`));
399
+ const validationLines = [];
400
+ const validArticles = [];
401
+ for (const task of tasks) {
402
+ const raw = readFileSync(task.filePath, "utf-8");
403
+ const errors = validateArticle(raw);
404
+ if (errors.length > 0) {
405
+ const lines = errors.map((e) => ` ${e.field}: ${e.message}`);
406
+ validationLines.push(`FAILED ${task.slug}: Validation failed (${errors.length} error${errors.length > 1 ? "s" : ""}):\n${lines.join("\n")}`);
407
+ }
408
+ else {
409
+ validArticles.push({ slug: task.slug, markdown: raw });
410
+ }
316
411
  }
317
- catch (e) {
318
- if (e.code !== "ENOENT") {
319
- cleanupWarning += `\nNote: could not remove original copy: ${e.message}`;
412
+ const inGui = process.env.OPENALMANAC_GUI === "1";
413
+ const resultLines = [...validationLines];
414
+ let okCount = 0;
415
+ if (validArticles.length > 0) {
416
+ const resp = await request("POST", "/api/articles/batch-publish", {
417
+ auth: true,
418
+ json: { articles: validArticles },
419
+ });
420
+ const data = (await resp.json());
421
+ for (const r of data.results) {
422
+ if (r.status === "failed") {
423
+ resultLines.push(`FAILED ${r.slug}: ${r.error ?? "unknown error"}`);
424
+ continue;
425
+ }
426
+ okCount += 1;
427
+ const task = tasks.find((t) => t.slug === r.slug);
428
+ if (task) {
429
+ try {
430
+ unlinkSync(task.filePath);
431
+ }
432
+ catch (e) {
433
+ if (e.code !== "ENOENT") {
434
+ resultLines.push(`Note: could not remove local draft for ${r.slug}: ${e.message}`);
435
+ }
436
+ }
437
+ try {
438
+ unlinkSync(task.originalPath);
439
+ }
440
+ catch (e) {
441
+ if (e.code !== "ENOENT") {
442
+ resultLines.push(`Note: could not remove original copy for ${r.slug}: ${e.message}`);
443
+ }
444
+ }
445
+ }
446
+ resultLines.push(`OK ${r.slug}: ${r.status}\n${JSON.stringify(r, null, 2)}`);
447
+ if (!inGui && tasks.length === 1 && r.canonical_path) {
448
+ const articleUrl = `https://www.openalmanac.org${r.canonical_path}?celebrate=true`;
449
+ openBrowser(articleUrl);
450
+ }
320
451
  }
321
452
  }
322
- return `Pushed successfully.\n\nArticle URL (share this exact link with the user): ${articleUrl}${cleanupWarning}\n\n${JSON.stringify(data, null, 2)}`;
453
+ const urlHint = inGui
454
+ ? "\n\nThe article(s) have been published! Let the user know they're live. Do not send them to a web URL."
455
+ : tasks.length > 1
456
+ ? "\n\n(Opening browser skipped for batch publish — share URLs from results above.)"
457
+ : "";
458
+ return `Published ${okCount}/${tasks.length}.\n\n${resultLines.join("\n\n")}${urlHint}`;
323
459
  },
324
460
  });
325
461
  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.",
462
+ name: "list_articles",
463
+ description: "Browse a community's wiki articles. Structured listing, not fuzzy search. " +
464
+ "Use this to see what exists, find stubs to fill, or discover most-referenced gaps.",
329
465
  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)"),
466
+ community_slug: z.string().describe("Community slug"),
467
+ topic: z.string().optional().describe("Filter by topic slug"),
468
+ sort: z.enum(["recent", "most_referenced"]).default("recent").describe("Sort order"),
469
+ stubs_only: z.boolean().default(false).describe("Only return stubs"),
470
+ limit: z.number().min(1).max(200).default(50).describe("Max results (1-200)"),
332
471
  }),
333
- async execute({ limit, offset }) {
334
- const resp = await request("GET", "/api/articles/requested", {
335
- params: { limit, offset },
336
- });
472
+ async execute({ community_slug, topic, sort, stubs_only, limit }) {
473
+ const params = { sort, stubs_only, limit };
474
+ if (topic)
475
+ params.topic = topic;
476
+ const resp = await request("GET", `/api/communities/${community_slug}/wiki`, { params });
337
477
  return JSON.stringify(await resp.json(), null, 2);
338
478
  },
339
479
  });
@@ -368,34 +508,4 @@ export function registerArticleTools(server) {
368
508
  return `Article Proposal: ${title}\n\n${summary}\n\nProceed with writing this article following the writing flow in your instructions.`;
369
509
  },
370
510
  });
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
511
  }
@@ -4,7 +4,7 @@ export function registerCommunityTools(server) {
4
4
  server.addTool({
5
5
  name: "search_communities",
6
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.",
7
+ "Use this to discover communities by topic. No authentication needed.",
8
8
  parameters: z.object({
9
9
  query: z
10
10
  .string()
@@ -54,11 +54,18 @@ export function registerCommunityTools(server) {
54
54
  .min(1)
55
55
  .max(2000)
56
56
  .describe("What the community is about (1-2000 chars)"),
57
+ cover_image_url: z.string().url().optional().describe("Hero/banner image URL. Use search_images to find a compelling image first."),
58
+ cover_image_position: z.number().min(0).max(100).optional().describe("Vertical focal point of cover image (0=top, 50=center, 100=bottom)"),
57
59
  }),
58
- async execute({ name, slug, description }) {
60
+ async execute({ name, slug, description, cover_image_url, cover_image_position }) {
61
+ const json = { name, slug, description };
62
+ if (cover_image_url)
63
+ json.cover_image_url = cover_image_url;
64
+ if (cover_image_position !== undefined)
65
+ json.cover_image_position = cover_image_position;
59
66
  const resp = await request("POST", "/api/communities", {
60
67
  auth: true,
61
- json: { name, slug, description },
68
+ json,
62
69
  });
63
70
  const data = (await resp.json());
64
71
  const communityUrl = `https://www.openalmanac.org/communities/${slug}`;
@@ -91,37 +98,4 @@ export function registerCommunityTools(server) {
91
98
  return `Post created!\n\nURL: ${postUrl}\n\n${JSON.stringify(data, null, 2)}`;
92
99
  },
93
100
  });
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
101
  }
@@ -46,6 +46,34 @@ export function registerTopicTools(server) {
46
46
  return JSON.stringify(await resp.json(), null, 2);
47
47
  },
48
48
  });
49
+ server.addTool({
50
+ name: "update_topic",
51
+ description: "Update a topic's title, description, or image. Requires wiki moderator role.",
52
+ parameters: z.object({
53
+ wiki_slug: z.string().describe("Wiki slug"),
54
+ topic_slug: z.string().describe("Slug of the topic to update"),
55
+ title: z.string().optional().describe("New title (also updates slug)"),
56
+ description: z.string().optional().describe("New description"),
57
+ image_url: z
58
+ .string()
59
+ .url()
60
+ .max(2048)
61
+ .nullable()
62
+ .optional()
63
+ .describe("Topic image URL (https:// or http://). Pass null to clear."),
64
+ }),
65
+ async execute({ wiki_slug, topic_slug, title, description, image_url }) {
66
+ const body = {};
67
+ if (title !== undefined)
68
+ body.title = title;
69
+ if (description !== undefined)
70
+ body.description = description;
71
+ if (image_url !== undefined)
72
+ body.image_url = image_url;
73
+ const resp = await request("PATCH", `/api/w/${wiki_slug}/topics/${topic_slug}`, { auth: true, json: body });
74
+ return JSON.stringify(await resp.json(), null, 2);
75
+ },
76
+ });
49
77
  server.addTool({
50
78
  name: "create_topics_batch",
51
79
  description: "Batch create topics in a wiki. Useful for bootstrapping a topic hierarchy. Requires wiki membership.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openalmanac",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "OpenAlmanac — pull, edit, and push articles to the open knowledge base",
5
5
  "type": "module",
6
6
  "bin": {