codealmanac 0.2.6 → 0.2.7
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/LICENSE +21 -133
- package/README.md +20 -15
- package/dist/{agents-HYRWRHRX.js → agents-V2ZOIACP.js} +6 -5
- package/dist/{chunk-PDFS5VFE.js → chunk-447U3GQJ.js} +5 -17
- package/dist/chunk-447U3GQJ.js.map +1 -0
- package/dist/{chunk-3E7JNMTZ.js → chunk-5BWUMAOX.js} +4 -29
- package/dist/chunk-5BWUMAOX.js.map +1 -0
- package/dist/{chunk-KQUVMF27.js → chunk-BFIG2CXM.js} +2 -516
- package/dist/chunk-BFIG2CXM.js.map +1 -0
- package/dist/{chunk-K2JBCB7R.js → chunk-BQY5L3DL.js} +7 -43
- package/dist/chunk-BQY5L3DL.js.map +1 -0
- package/dist/{chunk-F53U6JQG.js → chunk-CQJVM34R.js} +2 -2
- package/dist/chunk-FUBE6KCO.js +124 -0
- package/dist/chunk-FUBE6KCO.js.map +1 -0
- package/dist/chunk-IZBXXAVL.js +524 -0
- package/dist/chunk-IZBXXAVL.js.map +1 -0
- package/dist/{chunk-7JUX4ADQ.js → chunk-IZT6RBHS.js} +1 -1
- package/dist/{chunk-DW32TL5W.js → chunk-JLQZELHQ.js} +18 -58
- package/dist/chunk-JLQZELHQ.js.map +1 -0
- package/dist/{chunk-2BNDNGUR.js → chunk-KZXWPG4P.js} +4 -8
- package/dist/{chunk-2BNDNGUR.js.map → chunk-KZXWPG4P.js.map} +1 -1
- package/dist/{chunk-GPFVEF6V.js → chunk-QIA22IAM.js} +6 -24
- package/dist/chunk-QIA22IAM.js.map +1 -0
- package/dist/{chunk-J7DNV2DH.js → chunk-RALBM6HZ.js} +43 -355
- package/dist/chunk-RALBM6HZ.js.map +1 -0
- package/dist/{chunk-HJ3WREGP.js → chunk-U5DLLWIC.js} +3 -3
- package/dist/chunk-WL4UE7Q6.js +1386 -0
- package/dist/chunk-WL4UE7Q6.js.map +1 -0
- package/dist/{chunk-VXDPUOQ5.js → chunk-ZUQN5Y3K.js} +129 -382
- package/dist/chunk-ZUQN5Y3K.js.map +1 -0
- package/dist/{chunk-ODJAAJGZ.js → chunk-ZZLLOAI6.js} +3 -3
- package/dist/{cli-MKXCNEMW.js → cli-XWPNARA6.js} +37 -20
- package/dist/cli-XWPNARA6.js.map +1 -0
- package/dist/codealmanac.js +1 -1
- package/dist/{config-F7FKEQ7F.js → config-KH3JUMG6.js} +4 -4
- package/dist/doctor-ENJT665Z.js +18 -0
- package/dist/{hook-4SVX446M.js → hook-2NP3UE7U.js} +2 -4
- package/dist/paths-O5CZADP2.js +14 -0
- package/dist/process-KFSLENL3.js +61 -0
- package/dist/{register-commands-2F6SXLDI.js → register-commands-LULZUSPO.js} +999 -1030
- package/dist/register-commands-LULZUSPO.js.map +1 -0
- package/dist/uninstall-BD4MMQ7M.js +16 -0
- package/dist/uninstall-BD4MMQ7M.js.map +1 -0
- package/dist/update-XSKPDFMJ.js +11 -0
- package/dist/update-XSKPDFMJ.js.map +1 -0
- package/dist/{wiki-IGNRNLUZ.js → wiki-O4RWMAE6.js} +8 -6
- package/dist/wiki-O4RWMAE6.js.map +1 -0
- package/guides/mini.md +8 -6
- package/guides/reference.md +89 -32
- package/hooks/almanac-capture.sh +7 -8
- package/package.json +3 -4
- package/prompts/agents/.gitkeep +1 -0
- package/prompts/base/notability.md +139 -0
- package/prompts/base/purpose.md +85 -0
- package/prompts/base/syntax.md +114 -0
- package/prompts/operations/absorb.md +43 -0
- package/prompts/operations/build.md +49 -0
- package/prompts/operations/garden.md +51 -0
- package/COMMERCIAL.md +0 -9
- package/dist/chunk-3E7JNMTZ.js.map +0 -1
- package/dist/chunk-DW32TL5W.js.map +0 -1
- package/dist/chunk-GPFVEF6V.js.map +0 -1
- package/dist/chunk-J7DNV2DH.js.map +0 -1
- package/dist/chunk-K2JBCB7R.js.map +0 -1
- package/dist/chunk-KQUVMF27.js.map +0 -1
- package/dist/chunk-PDFS5VFE.js.map +0 -1
- package/dist/chunk-VXDPUOQ5.js.map +0 -1
- package/dist/cli-MKXCNEMW.js.map +0 -1
- package/dist/doctor-37UH3HT5.js +0 -17
- package/dist/register-commands-2F6SXLDI.js.map +0 -1
- package/dist/uninstall-C62ZOK32.js +0 -17
- package/dist/update-2UGOFN5C.js +0 -11
- package/dist/wiki-IGNRNLUZ.js.map +0 -1
- package/prompts/bootstrap.md +0 -176
- package/prompts/reviewer.md +0 -129
- package/prompts/writer.md +0 -134
- /package/dist/{agents-HYRWRHRX.js.map → agents-V2ZOIACP.js.map} +0 -0
- /package/dist/{chunk-F53U6JQG.js.map → chunk-CQJVM34R.js.map} +0 -0
- /package/dist/{chunk-7JUX4ADQ.js.map → chunk-IZT6RBHS.js.map} +0 -0
- /package/dist/{chunk-HJ3WREGP.js.map → chunk-U5DLLWIC.js.map} +0 -0
- /package/dist/{chunk-ODJAAJGZ.js.map → chunk-ZZLLOAI6.js.map} +0 -0
- /package/dist/{config-F7FKEQ7F.js.map → config-KH3JUMG6.js.map} +0 -0
- /package/dist/{doctor-37UH3HT5.js.map → doctor-ENJT665Z.js.map} +0 -0
- /package/dist/{hook-4SVX446M.js.map → hook-2NP3UE7U.js.map} +0 -0
- /package/dist/{uninstall-C62ZOK32.js.map → paths-O5CZADP2.js.map} +0 -0
- /package/dist/{update-2UGOFN5C.js.map → process-KFSLENL3.js.map} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/indexer/schema.ts","../src/indexer/index.ts","../src/slug.ts","../src/topics/yaml.ts","../src/indexer/frontmatter.ts","../src/indexer/freshness.ts","../src/indexer/paths.ts","../src/indexer/topics-yaml.ts","../src/indexer/wikilinks.ts"],"sourcesContent":["import Database from \"better-sqlite3\";\n\n/**\n * Schema DDL, applied on every open. All statements are `CREATE ... IF NOT\n * EXISTS` so this is idempotent — handy when the file already exists but\n * was written by an older version, and tolerable because the schema is\n * append-only (new tables don't collide).\n *\n * Departures from the raw spec, explained:\n * - `page_topics.topic_slug` has no FK to `topics(slug)`. Topics are\n * created lazily when a page declares them; a strict FK would force us\n * to upsert topic rows before the page rows, which doesn't buy us\n * anything in slice 2 and locks us out of slice 3's \"no explicit topic\n * registration needed\" behavior.\n * - `wikilinks.target_slug` / `cross_wiki_links.target_slug` also have\n * no FK — these can be intentionally broken (unwritten target page),\n * and `almanac health` will surface them in slice 3.\n *\n * `file_refs` carries TWO forms of each path:\n * - `path` — normalized + lowercased, used for GLOB/equality\n * queries (`--mentions`). Stable across casing\n * choices on macOS/Windows.\n * - `original_path` — as-written (normalized slashes, no `./`, trailing\n * `/` for dirs), preserving the author's casing.\n * Used for filesystem stats (dead-refs on\n * case-sensitive filesystems like Linux) and for\n * user-facing display (`almanac info`).\n *\n * See also: `SCHEMA_VERSION` below and the migration logic in `openIndex`.\n */\nconst SCHEMA_DDL = `\nCREATE TABLE IF NOT EXISTS pages (\n slug TEXT PRIMARY KEY,\n title TEXT,\n file_path TEXT NOT NULL,\n content_hash TEXT NOT NULL,\n updated_at INTEGER NOT NULL,\n archived_at INTEGER,\n superseded_by TEXT\n);\n\nCREATE TABLE IF NOT EXISTS topics (\n slug TEXT PRIMARY KEY,\n title TEXT,\n description TEXT\n);\n\nCREATE TABLE IF NOT EXISTS page_topics (\n page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,\n topic_slug TEXT NOT NULL,\n PRIMARY KEY (page_slug, topic_slug)\n);\n\nCREATE TABLE IF NOT EXISTS topic_parents (\n child_slug TEXT NOT NULL,\n parent_slug TEXT NOT NULL,\n PRIMARY KEY (child_slug, parent_slug),\n CHECK (child_slug != parent_slug)\n);\n\nCREATE TABLE IF NOT EXISTS file_refs (\n page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,\n path TEXT NOT NULL,\n original_path TEXT NOT NULL,\n is_dir INTEGER NOT NULL,\n PRIMARY KEY (page_slug, path)\n);\nCREATE INDEX IF NOT EXISTS idx_file_refs_path ON file_refs(path);\n\nCREATE TABLE IF NOT EXISTS wikilinks (\n source_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,\n target_slug TEXT NOT NULL,\n PRIMARY KEY (source_slug, target_slug)\n);\n\nCREATE TABLE IF NOT EXISTS cross_wiki_links (\n source_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,\n target_wiki TEXT NOT NULL,\n target_slug TEXT NOT NULL,\n PRIMARY KEY (source_slug, target_wiki, target_slug)\n);\n\n-- NOTE: virtual FTS5 table — ON DELETE CASCADE from pages does NOT apply.\n-- The indexer must explicitly DELETE FROM fts_pages whenever it removes\n-- or replaces a page row, or we leak orphaned FTS rows.\nCREATE VIRTUAL TABLE IF NOT EXISTS fts_pages USING fts5(slug, title, content);\n`;\n\n/**\n * Bump this whenever the schema changes in a backwards-incompatible way.\n * On open we compare the stored `user_version` against this constant; if\n * it's lower, we drop the affected tables so the next `runIndexer` can\n * rebuild them. Full reindex is cheap (everything lives on disk as\n * markdown), so \"drop + recreate\" is simpler than ALTER TABLE migrations.\n *\n * Version history:\n * 1 — initial slice-2 schema\n * 2 — slice-3-review: added `file_refs.original_path`\n */\nconst SCHEMA_VERSION = 2;\n\n/**\n * Open `index.db` and apply the schema. Foreign keys are off by default in\n * SQLite; we turn them on per-connection so the ON DELETE CASCADE on\n * `pages` actually fires when we delete stale rows during incremental\n * reindex.\n *\n * We don't wrap this open in a transaction — `CREATE ... IF NOT EXISTS` is\n * safe to run repeatedly and the FTS5 virtual-table creation is already\n * atomic.\n *\n * Migration: if the DB was created by an older schema (`user_version` <\n * `SCHEMA_VERSION`), we drop the tables whose shape changed and let the\n * CREATE IF NOT EXISTS below rebuild them. The next `runIndexer` repopulates\n * from the filesystem — cheap and avoids the ALTER TABLE dance.\n */\nexport function openIndex(dbPath: string): Database.Database {\n const db = new Database(dbPath);\n // WAL journal mode is persistent — once set, it's recorded in the DB\n // header and survives close/open cycles. Check first and only switch if\n // we're not already there; this avoids a redundant pragma write on every\n // query command.\n const mode = db.pragma(\"journal_mode\", { simple: true });\n if (typeof mode !== \"string\" || mode.toLowerCase() !== \"wal\") {\n db.pragma(\"journal_mode = WAL\");\n }\n db.pragma(\"foreign_keys = ON\");\n\n const rawVersion = db.pragma(\"user_version\", { simple: true });\n const currentVersion = typeof rawVersion === \"number\" ? rawVersion : 0;\n if (currentVersion < SCHEMA_VERSION) {\n // Drop tables whose shape changed. `file_refs` got `original_path`\n // as of v2; easiest to drop it entirely so CREATE IF NOT EXISTS\n // runs with the new definition. Pages/topics/links are untouched.\n db.exec(\"DROP TABLE IF EXISTS file_refs\");\n // The indexer's fast-path skips pages whose content_hash matches,\n // which means a migration-dropped `file_refs` wouldn't get\n // repopulated until a page changed. Clear the hash column so the\n // next reindex treats every page as changed and rebuilds its\n // file_refs/wikilinks/cross-wiki rows. Table may not exist yet on\n // a brand-new DB, so swallow errors.\n try {\n db.exec(\"UPDATE pages SET content_hash = ''\");\n } catch {\n // pages table didn't exist yet; the upcoming CREATE IF NOT EXISTS\n // takes care of a fresh install.\n }\n db.pragma(`user_version = ${SCHEMA_VERSION}`);\n }\n\n db.exec(SCHEMA_DDL);\n return db;\n}\n","import { createHash } from \"node:crypto\";\nimport { existsSync, statSync } from \"node:fs\";\nimport { readFile, utimes } from \"node:fs/promises\";\nimport { basename, join } from \"node:path\";\n\nimport fg from \"fast-glob\";\nimport type Database from \"better-sqlite3\";\n\nimport { toKebabCase } from \"../slug.js\";\nimport { titleCase } from \"../topics/yaml.js\";\nimport { firstH1, parseFrontmatter } from \"./frontmatter.js\";\nimport {\n PAGES_GLOB,\n pagesNewerThan,\n topicsYamlNewerThan,\n} from \"./freshness.js\";\nimport {\n normalizePath,\n normalizePathPreservingCase,\n looksLikeDir,\n} from \"./paths.js\";\nimport { openIndex } from \"./schema.js\";\nimport { applyTopicsYaml, TOPICS_YAML_FILENAME } from \"./topics-yaml.js\";\nimport { extractWikilinks } from \"./wikilinks.js\";\n\nexport interface IndexContext {\n /** Absolute path to the repo root (the dir containing `.almanac/`). */\n repoRoot: string;\n}\n\nexport interface IndexResult {\n /** Pages parsed or re-parsed during this run. Zero when the DB was already up to date. */\n changed: number;\n /** Pages present in the DB before this run but missing from disk. */\n removed: number;\n /**\n * Pages on disk at the end of this run — i.e. files that made it all the\n * way through to the index. Skipped files (slug collisions, unreadable,\n * un-sluggable filenames) are NOT counted here. Use `filesSeen` for the\n * raw count of `.md` files encountered on disk.\n *\n * Alias retained for backwards-compat with existing tests/consumers; new\n * code should prefer `pagesIndexed` for clarity.\n */\n total: number;\n /** Pages that made it into the index. Same number as `total`. */\n pagesIndexed: number;\n /**\n * Count of `.md` files found under `pages/` before any filtering. Always\n * `>= pagesIndexed`; the difference is `filesSkipped`.\n */\n filesSeen: number;\n /**\n * Files dropped before making it into the index — slug collisions,\n * un-sluggable filenames, or filesystem races (deleted/unreadable mid-run).\n * Covered by stderr warnings when non-zero.\n */\n filesSkipped: number;\n}\n\n/**\n * The \"front door\" for query commands. Runs the indexer only if the DB is\n * missing or at least one page is newer than it. Meant to be cheap — the\n * common case is \"nothing changed, mtime check returns fast, we're done\".\n *\n * The spec is explicit: \"Reindex is implicit and invisible. If the user\n * didn't didn't explicitly run `reindex`, they shouldn't see reindex\n * output. Silent by default.\" So this function never writes to stdout;\n * warnings (slug collisions, bad frontmatter) still go to stderr.\n */\nexport async function ensureFreshIndex(ctx: IndexContext): Promise<IndexResult> {\n const almanacDir = join(ctx.repoRoot, \".almanac\");\n const dbPath = join(almanacDir, \"index.db\");\n const pagesDir = join(almanacDir, \"pages\");\n\n if (!existsSync(pagesDir)) {\n // No pages dir = nothing to index. Open/create the DB so downstream\n // queries can run against an empty schema rather than crashing on a\n // missing file.\n const db = openIndex(dbPath);\n db.close();\n return emptyResult();\n }\n\n if (\n !existsSync(dbPath) ||\n // Keep read-side freshness even when CLI/agent write paths eagerly\n // reindex: users can still change `.almanac/pages/` directly via\n // manual edits, git pulls, merges, or branch switches.\n pagesNewerThan(pagesDir, dbPath) ||\n topicsYamlNewerThan(almanacDir, dbPath)\n ) {\n return runIndexer(ctx);\n }\n return emptyResult();\n}\n\nfunction emptyResult(): IndexResult {\n return {\n changed: 0,\n removed: 0,\n total: 0,\n pagesIndexed: 0,\n filesSeen: 0,\n filesSkipped: 0,\n };\n}\n\n/**\n * Force a full reindex. Identical to `ensureFreshIndex` except it runs\n * the indexer unconditionally. Exposed for `almanac reindex`.\n */\nexport async function runIndexer(ctx: IndexContext): Promise<IndexResult> {\n const almanacDir = join(ctx.repoRoot, \".almanac\");\n const dbPath = join(almanacDir, \"index.db\");\n const pagesDir = join(almanacDir, \"pages\");\n\n const db = openIndex(dbPath);\n let result: IndexResult;\n try {\n result = await indexPagesInto(db, pagesDir);\n // After pages are indexed, reconcile the topics table against\n // `.almanac/topics.yaml` (if present). `indexPagesInto` has already\n // lazily inserted rows for every topic slug mentioned in page\n // frontmatter with a title-cased title; `applyTopicsYaml` now\n // promotes the declared title/description and rewrites parent edges\n // for those topics that live in the file.\n await applyTopicsYaml(db, join(almanacDir, TOPICS_YAML_FILENAME));\n } finally {\n db.close();\n }\n\n // Bump the DB mtime to \"now\" after a successful reindex (even a no-op\n // one). Otherwise, a page file with a future mtime (clock skew,\n // `git checkout` preserving source mtimes) would trigger `ensureFreshIndex`\n // on every query: the freshness check sees `page.mtime > db.mtime`,\n // reindex runs, finds no content-hash changes, and the DB mtime stays\n // stale — locking us into a reindex-on-every-query loop. Touching the\n // DB mtime makes the comparison monotonic.\n try {\n const now = new Date();\n await utimes(dbPath, now, now);\n } catch {\n // Touching mtime is a freshness optimization; failures here are\n // non-fatal and the reindex result is still correct.\n }\n return result;\n}\n\ninterface ExistingRow {\n slug: string;\n content_hash: string;\n file_path: string;\n}\n\nasync function indexPagesInto(\n db: Database.Database,\n pagesDir: string,\n): Promise<IndexResult> {\n const files = await fg(PAGES_GLOB, {\n cwd: pagesDir,\n absolute: false,\n onlyFiles: true,\n caseSensitiveMatch: true,\n });\n\n // Load the current state of the index into memory so we can diff against\n // what's on disk. This is cheap even at 10k pages (one INTEGER + two\n // short strings per row).\n const existingRows = db\n .prepare<[], ExistingRow>(\"SELECT slug, content_hash, file_path FROM pages\")\n .all();\n const existingBySlug = new Map<string, ExistingRow>();\n for (const row of existingRows) existingBySlug.set(row.slug, row);\n\n // First pass: decide what to do with each file on disk. We record the\n // intent here so the transaction below can run synchronously — mixing\n // async file reads into a better-sqlite3 transaction doesn't work\n // (transactions are sync).\n const planned: Array<{\n slug: string;\n title: string;\n filePath: string;\n fullPath: string;\n contentHash: string;\n updatedAt: number;\n archivedAt: number | null;\n supersededBy: string | null;\n topics: string[];\n frontmatterFiles: string[];\n wikilinks: ReturnType<typeof extractWikilinks>;\n content: string;\n }> = [];\n const seenSlugs = new Set<string>();\n let filesSkipped = 0;\n\n for (const rel of files) {\n const fullPath = join(pagesDir, rel);\n const base = basename(rel, \".md\");\n const slug = toKebabCase(base);\n if (slug.length === 0) {\n process.stderr.write(\n `almanac: skipping \"${rel}\" — filename has no slug-able characters\\n`,\n );\n filesSkipped++;\n continue;\n }\n if (slug !== base) {\n // Filename isn't already canonical kebab-case. Warn, but still\n // index under the canonical slug. `almanac health` (slice 3) will\n // surface these as a proper report.\n process.stderr.write(\n `almanac: warning — \"${rel}\" is not canonical; indexed as slug \"${slug}\"\\n`,\n );\n }\n if (seenSlugs.has(slug)) {\n // Two files slugify to the same slug. Keep the first, skip the\n // rest — health will flag this properly in slice 3.\n process.stderr.write(\n `almanac: warning — slug \"${slug}\" collides with an earlier file; skipping \"${rel}\"\\n`,\n );\n filesSkipped++;\n continue;\n }\n\n // `fast-glob` gave us the list in one shot, but by the time we stat\n // and read each file it can have been deleted, renamed, or swapped\n // (editors that save via rename-swap expose this briefly). A single\n // such race shouldn't tank the whole reindex — matches the malformed-\n // YAML behavior (\"one bad file doesn't stop the others\"). We narrow\n // to ENOENT/EACCES so genuine I/O failures (EIO, EMFILE, etc.) still\n // surface.\n let st: ReturnType<typeof statSync>;\n let raw: string;\n try {\n st = statSync(fullPath);\n raw = await readFile(fullPath, \"utf8\");\n } catch (err: unknown) {\n if (\n err instanceof Error &&\n \"code\" in err &&\n (err.code === \"ENOENT\" || err.code === \"EACCES\")\n ) {\n process.stderr.write(\n `almanac: skipping \"${rel}\" — ${err.message}\\n`,\n );\n filesSkipped++;\n continue;\n }\n throw err;\n }\n\n seenSlugs.add(slug);\n const updatedAt = Math.floor(st.mtimeMs / 1000);\n\n // Content-hash skip: if the hash matches what's in the DB and the\n // file path hasn't moved, we can leave this page's rows alone. This\n // is the fast-path for \"user ran a query; one page was touched\".\n const contentHash = hashContent(raw);\n const existing = existingBySlug.get(slug);\n if (\n existing !== undefined &&\n existing.content_hash === contentHash &&\n existing.file_path === fullPath\n ) {\n continue;\n }\n\n const fm = parseFrontmatter(raw);\n const title = fm.title ?? firstH1(fm.body) ?? base;\n const links = extractWikilinks(fm.body);\n\n planned.push({\n slug,\n title,\n filePath: rel,\n fullPath,\n contentHash,\n updatedAt,\n archivedAt: fm.archived_at,\n supersededBy: fm.superseded_by,\n topics: fm.topics,\n frontmatterFiles: fm.files,\n wikilinks: links,\n content: fm.body,\n });\n }\n\n // Compute deletions: anything in the DB whose slug isn't on disk\n // anymore (or whose file slugifies to a different slug now).\n const toDelete: string[] = [];\n for (const slug of existingBySlug.keys()) {\n if (!seenSlugs.has(slug)) toDelete.push(slug);\n }\n\n const deleteByPage = db.prepare<[string]>(\"DELETE FROM pages WHERE slug = ?\");\n const deleteFtsByPage = db.prepare<[string]>(\n \"DELETE FROM fts_pages WHERE slug = ?\",\n );\n\n const replacePage = db.prepare<\n [string, string, string, string, number, number | null, string | null]\n >(\n `INSERT INTO pages (slug, title, file_path, content_hash, updated_at, archived_at, superseded_by)\n VALUES (?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT(slug) DO UPDATE SET\n title = excluded.title,\n file_path = excluded.file_path,\n content_hash = excluded.content_hash,\n updated_at = excluded.updated_at,\n archived_at = excluded.archived_at,\n superseded_by = excluded.superseded_by`,\n );\n\n const deletePageTopics = db.prepare<[string]>(\n \"DELETE FROM page_topics WHERE page_slug = ?\",\n );\n const insertPageTopic = db.prepare<[string, string]>(\n \"INSERT OR IGNORE INTO page_topics (page_slug, topic_slug) VALUES (?, ?)\",\n );\n // Seed ad-hoc topics with a title-cased default. If the topic is\n // later declared in `.almanac/topics.yaml`, `applyTopicsYaml` will\n // promote the title/description to whatever the file says. We set the\n // title here (rather than leaving NULL) so `topics list` and\n // `health --topic` have a display name even before a user writes to\n // topics.yaml.\n const insertTopic = db.prepare<[string, string]>(\n \"INSERT OR IGNORE INTO topics (slug, title) VALUES (?, ?)\",\n );\n\n const deleteFileRefs = db.prepare<[string]>(\n \"DELETE FROM file_refs WHERE page_slug = ?\",\n );\n const insertFileRef = db.prepare<[string, string, string, number]>(\n \"INSERT OR IGNORE INTO file_refs (page_slug, path, original_path, is_dir) VALUES (?, ?, ?, ?)\",\n );\n\n const deleteWikilinks = db.prepare<[string]>(\n \"DELETE FROM wikilinks WHERE source_slug = ?\",\n );\n const insertWikilink = db.prepare<[string, string]>(\n \"INSERT OR IGNORE INTO wikilinks (source_slug, target_slug) VALUES (?, ?)\",\n );\n\n const deleteXwiki = db.prepare<[string]>(\n \"DELETE FROM cross_wiki_links WHERE source_slug = ?\",\n );\n const insertXwiki = db.prepare<[string, string, string]>(\n \"INSERT OR IGNORE INTO cross_wiki_links (source_slug, target_wiki, target_slug) VALUES (?, ?, ?)\",\n );\n\n const insertFts = db.prepare<[string, string, string]>(\n \"INSERT INTO fts_pages (slug, title, content) VALUES (?, ?, ?)\",\n );\n\n const apply = db.transaction(() => {\n for (const slug of toDelete) {\n // `fts_pages` is an FTS5 virtual table — FK cascades do NOT propagate\n // into it, so we must delete FTS rows explicitly before relying on\n // `DELETE FROM pages` to cascade-clean the four real tables\n // (page_topics, file_refs, wikilinks, cross_wiki_links). If this\n // explicit delete ever gets removed, orphaned FTS rows will show up\n // as phantom search hits pointing at non-existent slugs.\n deleteFtsByPage.run(slug);\n deleteByPage.run(slug); // CASCADE cleans page_topics, file_refs, wikilinks, cross_wiki_links\n }\n\n for (const p of planned) {\n // page_topics/file_refs/wikilinks/cross_wiki_links all cascade on\n // delete, so the cleanest \"replace\" story is: delete-then-insert\n // the per-page rows under the same transaction. Doing it this way\n // (rather than `ON CONFLICT DO UPDATE` per row) keeps the logic\n // uniform and makes \"remove a topic from frontmatter\" work.\n deletePageTopics.run(p.slug);\n deleteFileRefs.run(p.slug);\n deleteWikilinks.run(p.slug);\n deleteXwiki.run(p.slug);\n // Same virtual-table reason as the deletion branch above — FTS5\n // rows do not cascade, so clean them by hand before reinserting.\n deleteFtsByPage.run(p.slug);\n\n replacePage.run(\n p.slug,\n p.title,\n p.fullPath,\n p.contentHash,\n p.updatedAt,\n p.archivedAt,\n p.supersededBy,\n );\n\n for (const topic of p.topics) {\n const topicSlug = toKebabCase(topic);\n if (topicSlug.length === 0) continue;\n insertTopic.run(topicSlug, titleCase(topicSlug));\n insertPageTopic.run(p.slug, topicSlug);\n }\n\n // Frontmatter `files:` — normalize each entry, inferring directness\n // from its trailing slash. Authors who write `src/payments` (no\n // trailing slash) are asserting a file; this matches how `[[...]]`\n // classifies the same string. We store both the lowercased form\n // (for `--mentions` GLOB queries) and the casing-preserving form\n // (for dead-ref `existsSync` on case-sensitive filesystems).\n for (const raw of p.frontmatterFiles) {\n const isDir = looksLikeDir(raw);\n const path = normalizePath(raw, isDir);\n const originalPath = normalizePathPreservingCase(raw, isDir);\n if (path.length === 0) continue;\n insertFileRef.run(p.slug, path, originalPath, isDir ? 1 : 0);\n }\n\n // Inline `[[...]]` extracted from body.\n for (const ref of p.wikilinks) {\n switch (ref.kind) {\n case \"page\":\n insertWikilink.run(p.slug, ref.target);\n break;\n case \"file\":\n insertFileRef.run(p.slug, ref.path, ref.originalPath, 0);\n break;\n case \"folder\":\n insertFileRef.run(p.slug, ref.path, ref.originalPath, 1);\n break;\n case \"xwiki\":\n insertXwiki.run(p.slug, ref.wiki, ref.target);\n break;\n }\n }\n\n insertFts.run(p.slug, p.title, p.content);\n }\n });\n apply();\n\n const pagesIndexed = seenSlugs.size;\n return {\n changed: planned.length,\n removed: toDelete.length,\n total: pagesIndexed,\n pagesIndexed,\n filesSeen: files.length,\n filesSkipped,\n };\n}\n\nfunction hashContent(raw: string): string {\n return createHash(\"sha256\").update(raw).digest(\"hex\");\n}\n","/**\n * Canonical kebab-case slugifier used across the codebase.\n *\n * One function, three callers:\n * - `registry/index.ts` — wiki name slugs (both auto-derived and\n * user-supplied via `--name`)\n * - `indexer/index.ts` — page filename → slug and topic → slug\n * - `indexer/wikilinks.ts` — wikilink target → slug for resolution\n *\n * All three want the same behavior: lowercased, non-alphanumeric runs\n * collapse to a single hyphen, leading/trailing hyphens trimmed. Keeping\n * this in one place avoids a class of bug where an unusual input (e.g.\n * `Checkout_Flow`) produces different slugs depending on which layer\n * slugified it.\n *\n * Rules:\n * - Lowercase\n * - Non-alphanumeric runs collapse to a single hyphen\n * - Leading/trailing hyphens trimmed\n */\nexport function toKebabCase(input: string): string {\n return input\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n}\n","import { existsSync } from \"node:fs\";\nimport { mkdir, readFile, rename, writeFile } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\n\nimport yaml from \"js-yaml\";\n\nimport { toKebabCase } from \"../slug.js\";\n\n/**\n * One entry in `.almanac/topics.yaml` — the source of truth for topic\n * metadata (title, description, DAG parents). Pages are still the source\n * of truth for which pages belong to which topics; this file only holds\n * the topic-level attributes.\n *\n * `slug` is the canonical kebab-case key used everywhere downstream\n * (SQLite `topics.slug`, page frontmatter `topics:` entries, wikilink\n * targets). `title` is the human-readable name the user typed at create\n * time. `description` is a free-form one-liner (or null when unset).\n * `parents` is the DAG edge list — kept as an array of slugs rather than\n * a nested structure so round-tripping stays boring and a user eyeballing\n * the file can see the full graph.\n */\nexport interface TopicEntry {\n slug: string;\n title: string;\n description: string | null;\n parents: string[];\n}\n\nexport interface TopicsFile {\n topics: TopicEntry[];\n}\n\n/**\n * Load `.almanac/topics.yaml` into a `TopicsFile`. A missing file is not\n * an error — it's the first-run state, which we treat as \"no topic\n * metadata, only whatever the pages declare in frontmatter\". Malformed\n * YAML IS an error; we surface it rather than silently clobbering the\n * user's committed source of truth.\n *\n * The return shape is always normalized — callers don't have to guard\n * for missing `topics` key, wrong types, or absent `parents` arrays.\n */\nexport async function loadTopicsFile(path: string): Promise<TopicsFile> {\n if (!existsSync(path)) {\n return { topics: [] };\n }\n let raw: string;\n try {\n raw = await readFile(path, \"utf8\");\n } catch (err: unknown) {\n if (isNodeError(err) && err.code === \"ENOENT\") {\n return { topics: [] };\n }\n throw err;\n }\n\n const trimmed = raw.trim();\n if (trimmed.length === 0) {\n return { topics: [] };\n }\n\n let parsed: unknown;\n try {\n parsed = yaml.load(raw);\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : String(err);\n throw new Error(`topics.yaml at ${path} is not valid YAML: ${message}`);\n }\n\n if (parsed === null || parsed === undefined) {\n return { topics: [] };\n }\n\n if (typeof parsed !== \"object\" || Array.isArray(parsed)) {\n throw new Error(`topics.yaml at ${path} must be a mapping`);\n }\n\n const obj = parsed as Record<string, unknown>;\n const rawTopics = obj.topics;\n if (rawTopics === undefined || rawTopics === null) {\n return { topics: [] };\n }\n if (!Array.isArray(rawTopics)) {\n throw new Error(`topics.yaml at ${path} — \"topics\" must be a list`);\n }\n\n const topics: TopicEntry[] = [];\n for (const item of rawTopics) {\n if (typeof item !== \"object\" || item === null || Array.isArray(item)) {\n continue;\n }\n const entry = item as Record<string, unknown>;\n const slugRaw = entry.slug;\n if (typeof slugRaw !== \"string\" || slugRaw.trim().length === 0) continue;\n const slug = toKebabCase(slugRaw);\n if (slug.length === 0) continue;\n const title =\n typeof entry.title === \"string\" && entry.title.trim().length > 0\n ? entry.title.trim()\n : titleCase(slug);\n const description =\n typeof entry.description === \"string\" &&\n entry.description.trim().length > 0\n ? entry.description.trim()\n : null;\n const parents: string[] = [];\n if (Array.isArray(entry.parents)) {\n for (const p of entry.parents) {\n if (typeof p === \"string\" && p.trim().length > 0) {\n const ps = toKebabCase(p);\n if (ps.length > 0 && ps !== slug && !parents.includes(ps)) {\n parents.push(ps);\n }\n }\n }\n }\n topics.push({ slug, title, description, parents });\n }\n\n return { topics };\n}\n\n/**\n * Write a `TopicsFile` atomically — tmp file + rename, same pattern as\n * the registry. A half-written topics.yaml would corrupt the user's\n * committed source of truth, so we never write in place.\n *\n * Ordering: topics are sorted by slug for stable diffs. Parents within\n * each entry stay in the order the caller passed them (semantically an\n * ordered list — topics.yaml is the place a user can visibly reason\n * about \"primary parent first\", even though SQLite treats them as a\n * set).\n *\n * We emit a leading comment so first-time readers know the file is\n * edited by the CLI and what its role is.\n */\nexport async function writeTopicsFile(\n path: string,\n file: TopicsFile,\n): Promise<void> {\n const sorted = [...file.topics].sort((a, b) => a.slug.localeCompare(b.slug));\n const doc = {\n topics: sorted.map((t) => {\n // Emit all four keys in a stable order: slug, title, description,\n // parents. description is emitted as `null` in YAML when unset so\n // the schema stays consistent across entries (js-yaml renders the\n // literal word `null`, not the `~` shorthand).\n return {\n slug: t.slug,\n title: t.title,\n description: t.description,\n parents: t.parents,\n };\n }),\n };\n\n const header =\n `# .almanac/topics.yaml — source of truth for topic metadata.\\n` +\n `# Managed by \\`almanac topics\\` commands. User-added comments\\n` +\n `# between entries will be stripped on the next write (js-yaml\\n` +\n `# doesn't round-trip comments). Edit at your own risk — or use the\\n` +\n `# CLI (\\`almanac topics create|link|describe|rename|delete\\`)\\n` +\n `# which preserves the structure correctly.\\n`;\n const body = yaml.dump(doc, {\n lineWidth: 100,\n noRefs: true,\n sortKeys: false,\n });\n const content = `${header}${body}`;\n const tmpPath = `${path}.tmp`;\n // mkdir parent in case `.almanac/` vanished (shouldn't, but cheap insurance)\n const parent = dirname(path);\n if (!existsSync(parent)) {\n await mkdir(parent, { recursive: true });\n }\n await writeFile(tmpPath, content, \"utf8\");\n await rename(tmpPath, path);\n}\n\n/**\n * Look up a topic by slug. Returns `null` when the slug is absent —\n * callers distinguish \"declared in topics.yaml\" from \"ad-hoc (only\n * appears in page frontmatter)\" based on this.\n */\nexport function findTopic(file: TopicsFile, slug: string): TopicEntry | null {\n for (const t of file.topics) {\n if (t.slug === slug) return t;\n }\n return null;\n}\n\n/**\n * Ensure a topic entry exists. If missing, inserts a minimal entry with\n * title-cased title and null description. Returns the (possibly new)\n * entry. Used by `tag`, `topics create` (with `--parent auto-creating`),\n * and `topics link` (auto-creating child/parent on demand).\n */\nexport function ensureTopic(file: TopicsFile, slug: string): TopicEntry {\n const existing = findTopic(file, slug);\n if (existing !== null) return existing;\n const entry: TopicEntry = {\n slug,\n title: titleCase(slug),\n description: null,\n parents: [],\n };\n file.topics.push(entry);\n return entry;\n}\n\n/**\n * Convert a slug back to a human-ish title: `auth-flow` → `Auth Flow`.\n * Used as the fallback title when the caller didn't provide one\n * (auto-creation paths, ad-hoc slugs coming from page frontmatter).\n */\nexport function titleCase(slug: string): string {\n if (slug.length === 0) return slug;\n return slug\n .split(\"-\")\n .filter((s) => s.length > 0)\n .map((s) => `${s[0]?.toUpperCase() ?? \"\"}${s.slice(1)}`)\n .join(\" \");\n}\n\nfunction isNodeError(err: unknown): err is NodeJS.ErrnoException {\n return err instanceof Error && \"code\" in err;\n}\n","import yaml from \"js-yaml\";\n\nexport interface Frontmatter {\n title?: string;\n topics: string[];\n files: string[];\n archived_at: number | null;\n superseded_by: string | null;\n supersedes: string | null;\n /**\n * The body of the file with frontmatter removed, for FTS5 and H1 fallback.\n * Always populated even when the file has no frontmatter.\n */\n body: string;\n}\n\n/**\n * Pull YAML frontmatter off the top of a markdown file and coerce the\n * relevant fields. Unknown fields are tolerated silently — the wiki should\n * accept fields we don't understand yet without spewing warnings at the\n * user (future slices might consume them).\n *\n * Failure modes:\n * - No frontmatter at all → `{ topics: [], files: [], ..., body: raw }`.\n * This is legal; a heading + prose is a valid page.\n * - Malformed YAML → warning to stderr, treated as \"no frontmatter\". We\n * choose not to throw so a single bad file doesn't tank a reindex.\n *\n * Note on `archived_at`: authors write this as a YAML date (`2026-04-15`),\n * which `js-yaml` parses to a JS `Date`. We also tolerate ISO-8601 strings\n * and raw numbers. Everything else gets dropped (treated as \"not\n * archived\"). Storing epoch seconds keeps `--since`/`--stale`/`archived`\n * arithmetic trivial at query time.\n */\nexport function parseFrontmatter(raw: string): Frontmatter {\n const empty: Frontmatter = {\n topics: [],\n files: [],\n archived_at: null,\n superseded_by: null,\n supersedes: null,\n body: raw,\n };\n\n // Frontmatter fence MUST start on line 1 — a `---` partway through the\n // document is just a horizontal rule. Be strict about the opening delim\n // so we don't accidentally strip section headers.\n if (!raw.startsWith(\"---\")) {\n return empty;\n }\n\n // Tolerate either Unix or Windows line endings. We read the first line\n // explicitly to confirm it's only `---` (no trailing content).\n const match = raw.match(/^---\\r?\\n([\\s\\S]*?)\\r?\\n---\\r?\\n?([\\s\\S]*)$/);\n if (match === null) {\n return empty;\n }\n\n const yamlBody = match[1] ?? \"\";\n const body = match[2] ?? \"\";\n\n let parsed: unknown;\n try {\n parsed = yaml.load(yamlBody);\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : String(err);\n process.stderr.write(`almanac: malformed frontmatter (${message})\\n`);\n return empty;\n }\n\n if (parsed === null || parsed === undefined) {\n return { ...empty, body };\n }\n if (typeof parsed !== \"object\" || Array.isArray(parsed)) {\n // Someone wrote a YAML scalar or list as the document root — not a\n // mapping, so no fields for us to extract. Treat as empty but keep the\n // post-fence body so FTS5 still gets content.\n return { ...empty, body };\n }\n\n const obj = parsed as Record<string, unknown>;\n\n return {\n title: coerceString(obj.title),\n topics: coerceStringArray(obj.topics),\n files: coerceStringArray(obj.files),\n archived_at: coerceEpochSeconds(obj.archived_at),\n superseded_by: coerceString(obj.superseded_by) ?? null,\n supersedes: coerceString(obj.supersedes) ?? null,\n body,\n };\n}\n\n/**\n * H1 fallback for title when frontmatter has none.\n *\n * Only considers the first 40 lines of the body — any real wiki page has\n * its H1 near the top. NOTE: `String.prototype.split(sep, limit)` still\n * splits the whole string internally and then truncates; it's not an\n * early-bail iteration. For the multi-megabyte files we might see in\n * practice this is still cheap (one regex pass, no allocation per-line\n * beyond the 40 we keep), so we favor the clearer code over hand-rolled\n * line iteration.\n */\nexport function firstH1(body: string): string | undefined {\n const lines = body.split(/\\r?\\n/, 40);\n for (const line of lines) {\n const m = line.match(/^#\\s+(.+?)\\s*#*\\s*$/);\n if (m !== null) {\n return m[1];\n }\n }\n return undefined;\n}\n\nfunction coerceString(v: unknown): string | undefined {\n if (typeof v === \"string\" && v.trim().length > 0) return v.trim();\n return undefined;\n}\n\nfunction coerceStringArray(v: unknown): string[] {\n if (!Array.isArray(v)) return [];\n const out: string[] = [];\n for (const item of v) {\n if (typeof item === \"string\" && item.trim().length > 0) {\n out.push(item.trim());\n }\n }\n return out;\n}\n\n/**\n * Coerce a frontmatter `archived_at` value (YAML Date, ISO string, or raw\n * epoch number) into epoch seconds. Returns `null` for anything we can't\n * make sense of — pages with an unrecognizable `archived_at` are treated as\n * active rather than silently marked archived, which is the safer default.\n */\nfunction coerceEpochSeconds(v: unknown): number | null {\n if (v instanceof Date) {\n return Math.floor(v.getTime() / 1000);\n }\n if (typeof v === \"number\" && Number.isFinite(v)) {\n return Math.floor(v);\n }\n if (typeof v === \"string\" && v.trim().length > 0) {\n const t = Date.parse(v.trim());\n if (!Number.isNaN(t)) {\n return Math.floor(t / 1000);\n }\n }\n return null;\n}\n","import { existsSync, statSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\nimport fg from \"fast-glob\";\n\n// Glob is relative to `.almanac/pages/`, so this is every markdown page\n// beneath pages without repeating the `pages/` prefix.\nexport const PAGES_GLOB = \"**/*.md\";\n\n/**\n * Return true if any page file has an mtime strictly greater than the\n * index DB's mtime. This is intentionally cheap: `fast-glob` with\n * `stats: true` gives us mtimes without a second `stat` round-trip, and\n * the synchronous walk keeps the decision path simple for CLI entrypoints.\n */\nexport function pagesNewerThan(pagesDir: string, dbPath: string): boolean {\n let dbMtime: number;\n try {\n dbMtime = statSync(dbPath).mtimeMs;\n } catch {\n return true;\n }\n\n const entries = fg.sync(PAGES_GLOB, {\n cwd: pagesDir,\n absolute: true,\n onlyFiles: true,\n stats: true,\n }) as Array<{ path: string; stats?: { mtimeMs: number } }>;\n\n for (const entry of entries) {\n const mtime = entry.stats?.mtimeMs;\n if (mtime !== undefined && mtime > dbMtime) return true;\n }\n return false;\n}\n\n/**\n * Return true if `topics.yaml` has an mtime strictly greater than the\n * index DB's mtime. Missing `topics.yaml` is legal: it means \"no topic\n * metadata yet\", not \"the index is stale\".\n */\nexport function topicsYamlNewerThan(\n almanacDir: string,\n dbPath: string,\n): boolean {\n const path = join(almanacDir, \"topics.yaml\");\n if (!existsSync(path)) return false;\n let dbMtime: number;\n try {\n dbMtime = statSync(dbPath).mtimeMs;\n } catch {\n return true;\n }\n try {\n const st = statSync(path);\n return st.mtimeMs > dbMtime;\n } catch {\n return false;\n }\n}\n","/**\n * Path normalization for the file/folder references stored in `file_refs`\n * and for the query input passed to `--mentions`.\n *\n * The same function runs over both sides so a value written at index time\n * and a value looked up at query time compare byte-for-byte. If this ever\n * drifts between writers and readers, `--mentions` starts silently missing\n * matches — apply one canonicalization, not two.\n *\n * Rules (from the spec, Correctness):\n * - Lowercase (macOS filesystems are case-insensitive, so the wiki treats\n * `Src/Checkout/` and `src/checkout/` as the same path)\n * - Forward slashes only (never backslashes from Windows-authored content)\n * - No leading `./`\n * - Collapse redundant slashes (`src//checkout/` → `src/checkout/`)\n * - Trailing `/` iff the caller says it's a directory\n *\n * The `isDir` flag is a signal carried alongside the path — we don't infer\n * it from the raw string here, because frontmatter `files:` entries and the\n * inline `[[...]]` classifier both decide directness themselves and pass\n * the answer in. Having one place decide and one place normalize keeps the\n * directory inference rule testable in isolation.\n */\nexport function normalizePath(raw: string, isDir: boolean): string {\n const normalized = normalizeShape(raw, isDir);\n return normalized.toLowerCase();\n}\n\n/**\n * Normalize shape without lowercasing — preserves the author's casing.\n * Used to store `original_path` in `file_refs` so dead-ref checks on\n * case-sensitive filesystems (Linux, `git` checkouts with core.ignorecase\n * false) stat the actual path on disk rather than a lowercased alias.\n *\n * Everything else about the result is identical to `normalizePath`:\n * forward slashes, no `./`, no duplicate slashes, trailing `/` iff\n * `isDir`. The ONLY difference is the final `.toLowerCase()` is skipped.\n */\nexport function normalizePathPreservingCase(raw: string, isDir: boolean): string {\n return normalizeShape(raw, isDir);\n}\n\nfunction normalizeShape(raw: string, isDir: boolean): string {\n let s = raw.trim();\n\n // Windows-style backslashes → forward slashes. We never want to store\n // backslashes; a path authored on Windows and checked in should match\n // the same path authored on macOS.\n s = s.replace(/\\\\+/g, \"/\");\n\n // Drop a leading `./` — it's syntactic noise and authors inconsistently\n // include it. `./src/checkout/` and `src/checkout/` must hash equal.\n while (s.startsWith(\"./\")) s = s.slice(2);\n\n // Collapse any run of slashes to a single slash. This also normalizes\n // `src//checkout/` and accidental doubled slashes from string concat.\n s = s.replace(/\\/+/g, \"/\");\n\n // Strip any trailing slashes before re-applying the directory marker —\n // this way we don't care if the caller fed us `src/checkout` or\n // `src/checkout/` as a directory; we impose our own rule.\n s = s.replace(/\\/+$/, \"\");\n\n if (isDir) {\n // Directories ALWAYS end with a trailing slash. This is what lets the\n // GLOB queries distinguish `src/checkout/` (the directory) from\n // `src/checkout` (a file with no extension) without ambiguity.\n return `${s}/`;\n }\n return s;\n}\n\n/**\n * Infer `isDir` from the raw string the author wrote. Only used at the\n * point of parsing frontmatter `files:` entries and inline `[[...]]`\n * references — everywhere else, `isDir` is already known from context.\n *\n * Rule: trailing `/` (after backslash normalization) means directory.\n */\nexport function looksLikeDir(raw: string): boolean {\n const s = raw.trim().replace(/\\\\+/g, \"/\");\n return s.endsWith(\"/\");\n}\n","import { existsSync } from \"node:fs\";\n\nimport type Database from \"better-sqlite3\";\n\nimport { loadTopicsFile } from \"../topics/yaml.js\";\n\nexport const TOPICS_YAML_FILENAME = \"topics.yaml\";\n\n/**\n * Apply the contents of `.almanac/topics.yaml` to SQLite.\n *\n * Called at the tail of every reindex. For each entry in the file we\n * upsert a row into `topics` and rewrite that topic's parent edges.\n *\n * Important invariants:\n * - Missing `topics.yaml` is a no-op. Absence is legal and means \"no\n * topic metadata yet\".\n * - Topics mentioned only in page frontmatter are legal ad-hoc topics.\n * Do not delete them just because they are absent from `topics.yaml`.\n * - Stale topic rows are pruned only after upserting declared topics and\n * collecting current `page_topics`, so `health` does not flag topics\n * that were removed from every page after a rename/delete.\n */\nexport async function applyTopicsYaml(\n db: Database.Database,\n topicsYamlPath: string,\n): Promise<void> {\n if (!existsSync(topicsYamlPath)) return;\n let file;\n try {\n file = await loadTopicsFile(topicsYamlPath);\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : String(err);\n process.stderr.write(`almanac: ${message}\\n`);\n return;\n }\n\n const upsertTopic = db.prepare<[string, string, string | null]>(\n `INSERT INTO topics (slug, title, description) VALUES (?, ?, ?)\n ON CONFLICT(slug) DO UPDATE SET\n title = excluded.title,\n description = excluded.description`,\n );\n const clearParents = db.prepare<[string]>(\n \"DELETE FROM topic_parents WHERE child_slug = ?\",\n );\n const insertParent = db.prepare<[string, string]>(\n \"INSERT OR IGNORE INTO topic_parents (child_slug, parent_slug) VALUES (?, ?)\",\n );\n\n // Declared means either explicitly present in topics.yaml or currently\n // referenced by page frontmatter. Anything outside this set is stale.\n const declared = new Set<string>();\n for (const t of file.topics) declared.add(t.slug);\n const adHoc = db\n .prepare<[], { topic_slug: string }>(\n \"SELECT DISTINCT topic_slug FROM page_topics\",\n )\n .all();\n for (const r of adHoc) declared.add(r.topic_slug);\n\n const apply = db.transaction(() => {\n for (const t of file.topics) {\n upsertTopic.run(t.slug, t.title, t.description);\n clearParents.run(t.slug);\n for (const parent of t.parents) {\n if (parent === t.slug) continue;\n insertParent.run(t.slug, parent);\n }\n }\n\n // Prune stale topic rows + any edges attached to them last, after\n // the upserts above have promoted declared slugs.\n const existing = db\n .prepare<[], { slug: string }>(\"SELECT slug FROM topics\")\n .all();\n const deleteTopic = db.prepare<[string]>(\"DELETE FROM topics WHERE slug = ?\");\n const deleteEdgesByChild = db.prepare<[string]>(\n \"DELETE FROM topic_parents WHERE child_slug = ?\",\n );\n const deleteEdgesByParent = db.prepare<[string]>(\n \"DELETE FROM topic_parents WHERE parent_slug = ?\",\n );\n for (const r of existing) {\n if (declared.has(r.slug)) continue;\n deleteEdgesByChild.run(r.slug);\n deleteEdgesByParent.run(r.slug);\n deleteTopic.run(r.slug);\n }\n });\n apply();\n}\n","import { toKebabCase } from \"../slug.js\";\nimport {\n looksLikeDir,\n normalizePath,\n normalizePathPreservingCase,\n} from \"./paths.js\";\n\n/**\n * One parsed `[[...]]` reference from a page body. Classification is\n * deterministic and content-based — see `classifyWikilink` below.\n *\n * Callers dispatch on `kind`:\n * - `page` → row in `wikilinks`\n * - `file` → row in `file_refs` with `is_dir = 0`\n * - `folder` → row in `file_refs` with `is_dir = 1`\n * - `xwiki` → row in `cross_wiki_links`\n *\n * File/folder refs carry TWO forms of the path:\n * - `path` — lowercased (for `--mentions` lookups)\n * - `originalPath` — as-written (for filesystem stats on case-sensitive\n * systems and for user-facing display)\n */\nexport type WikilinkRef =\n | { kind: \"page\"; target: string }\n | { kind: \"file\"; path: string; originalPath: string }\n | { kind: \"folder\"; path: string; originalPath: string }\n | { kind: \"xwiki\"; wiki: string; target: string };\n\n/**\n * Rules from the spec (\"Classification rules\"), applied in order:\n *\n * 1. Contains `:` before any `/` → cross-wiki reference (`wiki:slug`)\n * 2. Contains `/` → file or folder reference\n * - Trailing `/` = folder\n * - Otherwise = file\n * 3. Otherwise → page slug wikilink\n *\n * Edge cases the test suite pins down:\n * - `[[a:b/c]]` → xwiki (colon is before the slash, rule 1 wins)\n * - `[[src/a:b]]` → file (slash is before the colon, rule 2 wins)\n * - `[[./x]]` → the leading `./` is stripped by `normalizePath`,\n * so this lands in `file_refs` as `x`. A bare `./x`\n * with no inner slash would classify as a file.\n * - `[[foo|display]]`→ Obsidian-style display text is stripped; we key\n * on the target only. A future slice could surface\n * display text in `almanac info`.\n */\nexport function classifyWikilink(raw: string): WikilinkRef | null {\n // Strip Obsidian-style `|display` suffix — we don't index display text\n // in slice 2, but we want the classifier to see the real target.\n const pipe = raw.indexOf(\"|\");\n let body = pipe === -1 ? raw : raw.slice(0, pipe);\n body = body.trim();\n if (body.length === 0) return null;\n\n const firstColon = body.indexOf(\":\");\n const firstSlash = body.indexOf(\"/\");\n\n // Rule 1: cross-wiki, `wiki:slug`. Only if the colon comes before any\n // slash — otherwise `src/urls.ts:42` (hypothetical) would wrongly\n // classify as xwiki.\n if (firstColon !== -1 && (firstSlash === -1 || firstColon < firstSlash)) {\n const wiki = body.slice(0, firstColon).trim();\n const target = body.slice(firstColon + 1).trim();\n if (wiki.length === 0 || target.length === 0) return null;\n return { kind: \"xwiki\", wiki, target };\n }\n\n // Rule 2: file or folder. The `/` may be anywhere including trailing.\n if (firstSlash !== -1) {\n const isDir = looksLikeDir(body);\n const path = normalizePath(body, isDir);\n const originalPath = normalizePathPreservingCase(body, isDir);\n if (path.length === 0) return null;\n return isDir\n ? { kind: \"folder\", path, originalPath }\n : { kind: \"file\", path, originalPath };\n }\n\n // Rule 3: page slug wikilink. Authors might write `Checkout Flow` or\n // `Checkout_Flow` by accident — slugify defensively so backlinks still\n // resolve in those cases.\n const target = toKebabCase(body);\n if (target.length === 0) return null;\n return { kind: \"page\", target };\n}\n\n/**\n * Walk a markdown body and pull every `[[...]]` reference. We scan the\n * whole body rather than try to skip code blocks — the spec is explicit:\n * \"Prose outside `[[...]]` is just prose. No backtick-path heuristics, no\n * false positives from code blocks or log output.\" A `[[foo]]` inside a\n * fenced code block is still a wikilink. Authors who genuinely need a\n * literal `[[x]]` in code can escape one of the brackets.\n */\nexport function extractWikilinks(body: string): WikilinkRef[] {\n const out: WikilinkRef[] = [];\n const re = /\\[\\[([^\\]\\n]+)\\]\\]/g;\n let m: RegExpExecArray | null;\n while ((m = re.exec(body)) !== null) {\n const ref = classifyWikilink(m[1] ?? \"\");\n if (ref !== null) out.push(ref);\n }\n return out;\n}\n"],"mappings":"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","names":["existsSync","statSync","readFile","join","fg","yaml","existsSync","existsSync","existsSync","target","join","existsSync","fg","statSync","readFile"]}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
-
CODEX_INSTRUCTIONS_END,
|
|
4
|
-
CODEX_INSTRUCTIONS_START,
|
|
5
3
|
IMPORT_LINE
|
|
6
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-ZUQN5Y3K.js";
|
|
7
5
|
import {
|
|
8
6
|
runHookUninstall
|
|
9
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-447U3GQJ.js";
|
|
10
8
|
|
|
11
9
|
// src/commands/uninstall.ts
|
|
12
10
|
import { existsSync } from "fs";
|
|
@@ -21,7 +19,6 @@ async function runUninstall(options = {}) {
|
|
|
21
19
|
const isTTY = options.isTTY ?? process.stdin.isTTY === true;
|
|
22
20
|
const interactive = isTTY && options.yes !== true;
|
|
23
21
|
const claudeDir = options.claudeDir ?? path.join(homedir(), ".claude");
|
|
24
|
-
const codexDir = options.codexDir ?? path.join(homedir(), ".codex");
|
|
25
22
|
out.write("\n");
|
|
26
23
|
let removeHook = true;
|
|
27
24
|
if (options.keepHook === true) {
|
|
@@ -53,12 +50,12 @@ async function runUninstall(options = {}) {
|
|
|
53
50
|
} else if (interactive) {
|
|
54
51
|
removeGuides = await confirm(
|
|
55
52
|
out,
|
|
56
|
-
"Remove
|
|
53
|
+
"Remove the guides + CLAUDE.md import line?",
|
|
57
54
|
true
|
|
58
55
|
);
|
|
59
56
|
}
|
|
60
57
|
if (removeGuides) {
|
|
61
|
-
const summary = await removeGuideFiles(claudeDir
|
|
58
|
+
const summary = await removeGuideFiles(claudeDir);
|
|
62
59
|
if (summary.anyChanges) {
|
|
63
60
|
out.write(
|
|
64
61
|
` ${BLUE}\u25C7${RST} Guides removed (${summary.filesTouched.join(", ")})
|
|
@@ -78,7 +75,7 @@ async function runUninstall(options = {}) {
|
|
|
78
75
|
`);
|
|
79
76
|
return { stdout: "", stderr: "", exitCode: 0 };
|
|
80
77
|
}
|
|
81
|
-
async function removeGuideFiles(claudeDir
|
|
78
|
+
async function removeGuideFiles(claudeDir) {
|
|
82
79
|
const touched = [];
|
|
83
80
|
const mini = path.join(claudeDir, "codealmanac.md");
|
|
84
81
|
const ref = path.join(claudeDir, "codealmanac-reference.md");
|
|
@@ -104,26 +101,6 @@ async function removeGuideFiles(claudeDir, codexDir) {
|
|
|
104
101
|
}
|
|
105
102
|
}
|
|
106
103
|
}
|
|
107
|
-
for (const agentsFile of [
|
|
108
|
-
path.join(codexDir, "AGENTS.md"),
|
|
109
|
-
path.join(codexDir, "AGENTS.override.md")
|
|
110
|
-
]) {
|
|
111
|
-
if (!existsSync(agentsFile)) continue;
|
|
112
|
-
const existing = await readFile(agentsFile, "utf8");
|
|
113
|
-
const { changed, body } = removeManagedBlock(
|
|
114
|
-
existing,
|
|
115
|
-
CODEX_INSTRUCTIONS_START,
|
|
116
|
-
CODEX_INSTRUCTIONS_END
|
|
117
|
-
);
|
|
118
|
-
if (!changed) continue;
|
|
119
|
-
if (body.trim().length === 0) {
|
|
120
|
-
await rm(agentsFile, { force: true });
|
|
121
|
-
touched.push(`${path.basename(agentsFile)} (deleted)`);
|
|
122
|
-
} else {
|
|
123
|
-
await writeFile(agentsFile, body, "utf8");
|
|
124
|
-
touched.push(path.basename(agentsFile));
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
104
|
return { anyChanges: touched.length > 0, filesTouched: touched };
|
|
128
105
|
}
|
|
129
106
|
function removeImportLine(contents) {
|
|
@@ -141,18 +118,6 @@ function removeImportLine(contents) {
|
|
|
141
118
|
body = body.replace(/\n\n\n+/g, "\n\n");
|
|
142
119
|
return { changed: true, body };
|
|
143
120
|
}
|
|
144
|
-
function removeManagedBlock(contents, start, end) {
|
|
145
|
-
const startIndex = contents.indexOf(start);
|
|
146
|
-
const endIndex = contents.indexOf(end);
|
|
147
|
-
if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) {
|
|
148
|
-
return { changed: false, body: contents };
|
|
149
|
-
}
|
|
150
|
-
const afterEnd = endIndex + end.length;
|
|
151
|
-
let body = `${contents.slice(0, startIndex)}${contents.slice(afterEnd)}`;
|
|
152
|
-
body = body.replace(/\n\n\n+/g, "\n\n");
|
|
153
|
-
body = body.replace(/^\n+/, "");
|
|
154
|
-
return { changed: true, body };
|
|
155
|
-
}
|
|
156
121
|
function confirm(out, question, defaultYes) {
|
|
157
122
|
return new Promise((resolve) => {
|
|
158
123
|
const hint = defaultYes ? "[Y/n]" : "[y/N]";
|
|
@@ -175,7 +140,6 @@ function confirm(out, question, defaultYes) {
|
|
|
175
140
|
|
|
176
141
|
export {
|
|
177
142
|
runUninstall,
|
|
178
|
-
removeImportLine
|
|
179
|
-
removeManagedBlock
|
|
143
|
+
removeImportLine
|
|
180
144
|
};
|
|
181
|
-
//# sourceMappingURL=chunk-
|
|
145
|
+
//# sourceMappingURL=chunk-BQY5L3DL.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/commands/uninstall.ts"],"sourcesContent":["import { existsSync } from \"node:fs\";\nimport { readFile, rm, writeFile } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport path from \"node:path\";\n\nimport { runHookUninstall } from \"./hook.js\";\nimport { IMPORT_LINE } from \"./setup.js\";\n\n/**\n * `almanac uninstall` — the reverse of `setup`.\n *\n * Idempotent and order-insensitive: each step is a no-op if that\n * artifact was never installed. We remove exactly the things setup added,\n * nothing else:\n *\n * 1. The `@~/.claude/codealmanac.md` line from `~/.claude/CLAUDE.md`.\n * Other content stays untouched. If removing our line leaves the\n * file empty, we delete the file so our fingerprint doesn't persist\n * as zero bytes.\n * 2. The guide files `~/.claude/codealmanac.md` and\n * `~/.claude/codealmanac-reference.md`.\n * 3. The SessionEnd hook entry (delegated to `runHookUninstall`, which\n * already knows how to leave foreign entries alone).\n *\n * Flags:\n * --yes skip confirmations; remove everything\n * --keep-hook leave the hook alone\n * --keep-guides leave the guides + CLAUDE.md import alone\n *\n * Non-interactive (no TTY) → behaves as if `--yes` was passed. Same\n * contract as `setup`.\n */\n\nexport interface UninstallOptions {\n yes?: boolean;\n keepHook?: boolean;\n keepGuides?: boolean;\n\n // ─── Injection points ────────────────────────────────────────────\n settingsPath?: string;\n hookScriptPath?: string;\n claudeDir?: string;\n isTTY?: boolean;\n stdout?: NodeJS.WritableStream;\n}\n\nexport interface UninstallResult {\n stdout: string;\n stderr: string;\n exitCode: number;\n}\n\nconst BLUE = \"\\x1b[38;5;75m\";\nconst DIM = \"\\x1b[2m\";\nconst RST = \"\\x1b[0m\";\n\nexport async function runUninstall(\n options: UninstallOptions = {},\n): Promise<UninstallResult> {\n const out = options.stdout ?? process.stdout;\n const isTTY =\n options.isTTY ?? (process.stdin.isTTY === true);\n const interactive = isTTY && options.yes !== true;\n const claudeDir = options.claudeDir ?? path.join(homedir(), \".claude\");\n\n out.write(\"\\n\");\n\n // Hook removal.\n let removeHook = true;\n if (options.keepHook === true) {\n removeHook = false;\n } else if (interactive) {\n removeHook = await confirm(\n out,\n \"Remove the SessionEnd hook from ~/.claude/settings.json?\",\n true,\n );\n }\n if (removeHook) {\n const res = await runHookUninstall({\n settingsPath: options.settingsPath,\n hookScriptPath: options.hookScriptPath,\n });\n if (res.exitCode !== 0) {\n return { stdout: \"\", stderr: res.stderr, exitCode: res.exitCode };\n }\n out.write(` ${BLUE}\\u25c7${RST} ${res.stdout.trim()}\\n`);\n } else {\n out.write(` ${DIM}\\u25cb Hook kept${RST}\\n`);\n }\n\n // Guide + import removal.\n let removeGuides = true;\n if (options.keepGuides === true) {\n removeGuides = false;\n } else if (interactive) {\n removeGuides = await confirm(\n out,\n \"Remove the guides + CLAUDE.md import line?\",\n true,\n );\n }\n if (removeGuides) {\n const summary = await removeGuideFiles(claudeDir);\n if (summary.anyChanges) {\n out.write(\n ` ${BLUE}\\u25c7${RST} Guides removed (${summary.filesTouched.join(\", \")})\\n`,\n );\n } else {\n out.write(` ${DIM}\\u25cb Guides not installed${RST}\\n`);\n }\n } else {\n out.write(` ${DIM}\\u25cb Guides kept${RST}\\n`);\n }\n\n out.write(`\\n ${BLUE}\\u25c7${RST} ${BLUE}Uninstall complete${RST}\\n\\n`);\n\n return { stdout: \"\", stderr: \"\", exitCode: 0 };\n}\n\ninterface RemoveGuidesResult {\n anyChanges: boolean;\n filesTouched: string[];\n}\n\nasync function removeGuideFiles(\n claudeDir: string,\n): Promise<RemoveGuidesResult> {\n const touched: string[] = [];\n\n const mini = path.join(claudeDir, \"codealmanac.md\");\n const ref = path.join(claudeDir, \"codealmanac-reference.md\");\n const claudeMd = path.join(claudeDir, \"CLAUDE.md\");\n\n if (existsSync(mini)) {\n await rm(mini, { force: true });\n touched.push(\"codealmanac.md\");\n }\n if (existsSync(ref)) {\n await rm(ref, { force: true });\n touched.push(\"codealmanac-reference.md\");\n }\n\n if (existsSync(claudeMd)) {\n const existing = await readFile(claudeMd, \"utf8\");\n const { changed, body } = removeImportLine(existing);\n if (changed) {\n // If the file is now content-free, delete it outright so our\n // installation leaves no trace. A user who was using CLAUDE.md\n // before we touched it still has their content; only the case\n // where CLAUDE.md contained nothing but our line gets cleaned up.\n if (body.trim().length === 0) {\n await rm(claudeMd, { force: true });\n touched.push(\"CLAUDE.md (deleted)\");\n } else {\n await writeFile(claudeMd, body, \"utf8\");\n touched.push(\"CLAUDE.md\");\n }\n }\n }\n\n return { anyChanges: touched.length > 0, filesTouched: touched };\n}\n\n/**\n * Remove the import line from a CLAUDE.md body. Match is line-anchored\n * (trimmed equality) so we don't munge a line that happens to include\n * the token as part of a longer string. Returns the unchanged body (and\n * `changed: false`) if the line isn't present — this is what makes the\n * command safe to run repeatedly.\n */\nexport function removeImportLine(contents: string): {\n changed: boolean;\n body: string;\n} {\n const eol = contents.includes(\"\\r\\n\") ? \"\\r\\n\" : \"\\n\";\n const lines = contents.split(/\\r?\\n/);\n\n const indices: number[] = [];\n for (let i = 0; i < lines.length; i++) {\n if (lines[i]!.trim() === IMPORT_LINE) indices.push(i);\n }\n if (indices.length === 0) return { changed: false, body: contents };\n\n // Remove the line(s). Iterate from the end so earlier indices stay\n // valid as we splice.\n for (let i = indices.length - 1; i >= 0; i--) {\n lines.splice(indices[i]!, 1);\n }\n\n let body = lines.join(eol);\n\n // Cleanup: collapse any double-blank that our removal created at the\n // spot the line used to live. A best-effort tidy — we don't try to\n // normalize the whole file.\n body = body.replace(/\\n\\n\\n+/g, \"\\n\\n\");\n\n return { changed: true, body };\n}\n\nfunction confirm(\n out: NodeJS.WritableStream,\n question: string,\n defaultYes: boolean,\n): Promise<boolean> {\n return new Promise((resolve) => {\n const hint = defaultYes ? \"[Y/n]\" : \"[y/N]\";\n out.write(` ${BLUE}\\u25c6${RST} ${question} ${DIM}${hint}${RST} `);\n\n let buf = \"\";\n const onData = (chunk: Buffer): void => {\n buf += chunk.toString(\"utf8\");\n const nl = buf.indexOf(\"\\n\");\n if (nl === -1) return;\n process.stdin.removeListener(\"data\", onData);\n process.stdin.pause();\n\n const answer = buf.slice(0, nl).trim().toLowerCase();\n const accepted =\n answer.length === 0\n ? defaultYes\n : answer === \"y\" || answer === \"yes\";\n resolve(accepted);\n };\n\n process.stdin.resume();\n process.stdin.on(\"data\", onData);\n });\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
getGlobalAlmanacDir
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-IZT6RBHS.js";
|
|
5
5
|
|
|
6
6
|
// src/update/semver.ts
|
|
7
7
|
function parse(v) {
|
|
@@ -183,4 +183,4 @@ export {
|
|
|
183
183
|
writeState,
|
|
184
184
|
checkForUpdate
|
|
185
185
|
};
|
|
186
|
-
//# sourceMappingURL=chunk-
|
|
186
|
+
//# sourceMappingURL=chunk-CQJVM34R.js.map
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/agent/providers/claude/auth.ts
|
|
4
|
+
import { spawn, spawnSync } from "child_process";
|
|
5
|
+
import { createRequire } from "module";
|
|
6
|
+
import { dirname, join } from "path";
|
|
7
|
+
var AUTH_TIMEOUT_MS = 1e4;
|
|
8
|
+
function resolveClaudeExecutable() {
|
|
9
|
+
const result = spawnSync("sh", ["-lc", "command -v claude"], {
|
|
10
|
+
encoding: "utf8"
|
|
11
|
+
});
|
|
12
|
+
if (result.status !== 0) return void 0;
|
|
13
|
+
const found = result.stdout.trim().split("\n")[0]?.trim();
|
|
14
|
+
return found !== void 0 && found.length > 0 ? found : void 0;
|
|
15
|
+
}
|
|
16
|
+
function resolveCliJsPath() {
|
|
17
|
+
const require2 = createRequire(import.meta.url);
|
|
18
|
+
const entry = require2.resolve("@anthropic-ai/claude-agent-sdk");
|
|
19
|
+
return join(dirname(entry), "cli.js");
|
|
20
|
+
}
|
|
21
|
+
var defaultSpawnCli = (args) => {
|
|
22
|
+
const command = resolveClaudeExecutable() ?? "claude";
|
|
23
|
+
const child = spawn(command, args, {
|
|
24
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
25
|
+
});
|
|
26
|
+
return child;
|
|
27
|
+
};
|
|
28
|
+
var legacySdkSpawnCli = (args) => {
|
|
29
|
+
const cliPath = resolveCliJsPath();
|
|
30
|
+
const child = spawn(process.execPath, [cliPath, ...args], {
|
|
31
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
32
|
+
});
|
|
33
|
+
return child;
|
|
34
|
+
};
|
|
35
|
+
async function checkClaudeAuth(spawnCli = defaultSpawnCli) {
|
|
36
|
+
if (spawnCli === defaultSpawnCli) {
|
|
37
|
+
const status = await checkClaudeAuthWith(defaultSpawnCli);
|
|
38
|
+
if (status.loggedIn) return status;
|
|
39
|
+
return await checkClaudeAuthWith(legacySdkSpawnCli);
|
|
40
|
+
}
|
|
41
|
+
return await checkClaudeAuthWith(spawnCli);
|
|
42
|
+
}
|
|
43
|
+
async function checkClaudeAuthWith(spawnCli) {
|
|
44
|
+
let child;
|
|
45
|
+
try {
|
|
46
|
+
child = spawnCli(["auth", "status", "--json"]);
|
|
47
|
+
} catch {
|
|
48
|
+
return { loggedIn: false };
|
|
49
|
+
}
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
let stdout = "";
|
|
52
|
+
let stderr = "";
|
|
53
|
+
let settled = false;
|
|
54
|
+
const settle = (value) => {
|
|
55
|
+
if (settled) return;
|
|
56
|
+
settled = true;
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
resolve(value);
|
|
59
|
+
};
|
|
60
|
+
const timer = setTimeout(() => {
|
|
61
|
+
try {
|
|
62
|
+
child.kill("SIGTERM");
|
|
63
|
+
} catch {
|
|
64
|
+
}
|
|
65
|
+
settle({ loggedIn: false });
|
|
66
|
+
}, AUTH_TIMEOUT_MS);
|
|
67
|
+
child.stdout.on("data", (data) => {
|
|
68
|
+
stdout += data.toString();
|
|
69
|
+
});
|
|
70
|
+
child.stderr.on("data", (data) => {
|
|
71
|
+
stderr += data.toString();
|
|
72
|
+
});
|
|
73
|
+
child.on("error", () => {
|
|
74
|
+
settle({ loggedIn: false });
|
|
75
|
+
});
|
|
76
|
+
child.on("close", (code) => {
|
|
77
|
+
if (code !== 0 && stdout.trim().length === 0) {
|
|
78
|
+
void stderr;
|
|
79
|
+
settle({ loggedIn: false });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
settle(parseClaudeAuthStatus(stdout.trim()));
|
|
84
|
+
} catch {
|
|
85
|
+
settle({ loggedIn: false });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function parseClaudeAuthStatus(raw) {
|
|
91
|
+
const parsed = JSON.parse(raw);
|
|
92
|
+
const loggedIn = parsed.loggedIn === true;
|
|
93
|
+
const out = { loggedIn };
|
|
94
|
+
if (typeof parsed.email === "string") out.email = parsed.email;
|
|
95
|
+
if (typeof parsed.subscriptionType === "string") {
|
|
96
|
+
out.subscriptionType = parsed.subscriptionType;
|
|
97
|
+
}
|
|
98
|
+
if (typeof parsed.authMethod === "string") {
|
|
99
|
+
out.authMethod = parsed.authMethod;
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
var UNAUTHENTICATED_MESSAGE = "not authenticated to Claude.\n\nOption 1 \u2014 use your Claude subscription (Pro/Max):\n claude auth login --claudeai\n\nOption 2 \u2014 use a pay-per-token API key:\n Get one at https://console.anthropic.com\n export ANTHROPIC_API_KEY=sk-ant-...\n\nVerify with: claude auth status";
|
|
104
|
+
async function assertClaudeAuth(spawnCli = defaultSpawnCli) {
|
|
105
|
+
const status = await checkClaudeAuth(spawnCli);
|
|
106
|
+
if (status.loggedIn) {
|
|
107
|
+
return status;
|
|
108
|
+
}
|
|
109
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
110
|
+
if (apiKey !== void 0 && apiKey.length > 0) {
|
|
111
|
+
return { loggedIn: true, authMethod: "apiKey" };
|
|
112
|
+
}
|
|
113
|
+
const err = new Error(UNAUTHENTICATED_MESSAGE);
|
|
114
|
+
err.code = "CLAUDE_AUTH_MISSING";
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export {
|
|
119
|
+
resolveClaudeExecutable,
|
|
120
|
+
checkClaudeAuth,
|
|
121
|
+
UNAUTHENTICATED_MESSAGE,
|
|
122
|
+
assertClaudeAuth
|
|
123
|
+
};
|
|
124
|
+
//# sourceMappingURL=chunk-FUBE6KCO.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/agent/providers/claude/auth.ts"],"sourcesContent":["import { spawn, spawnSync, type ChildProcess } from \"node:child_process\";\nimport { createRequire } from \"node:module\";\nimport { dirname, join } from \"node:path\";\n\nimport type { SpawnCliFn, SpawnedProcess } from \"../../types.js\";\n\n/**\n * Claude auth gate — accepts either an active Claude subscription login\n * OR an `ANTHROPIC_API_KEY` environment variable.\n *\n * Claude Code owns subscription OAuth credentials. Users who are logged in\n * via `claude auth login --claudeai` should be able to run agent-backed jobs\n * without exporting an API key. Conversely, users on pay-per-token API keys\n * shouldn't be required to go through the OAuth flow.\n *\n * Current Claude Agent SDK packages no longer ship the old private\n * `cli.js` entrypoint, so the primary probe is the public Claude Code CLI:\n * `claude auth status --json`. We keep the SDK `cli.js` probe as a legacy\n * fallback for older SDK layouts.\n */\n\nexport interface ClaudeAuthStatus {\n loggedIn: boolean;\n email?: string;\n subscriptionType?: string;\n authMethod?: string;\n}\n\nconst AUTH_TIMEOUT_MS = 10_000;\n\n/**\n * Resolve the installed Claude Code executable from PATH. The Agent SDK can\n * accept this path via `pathToClaudeCodeExecutable`, and the auth probe uses\n * the same binary so CodeAlmanac agrees with `claude auth status`.\n */\nexport function resolveClaudeExecutable(): string | undefined {\n const result = spawnSync(\"sh\", [\"-lc\", \"command -v claude\"], {\n encoding: \"utf8\",\n });\n if (result.status !== 0) return undefined;\n const found = result.stdout.trim().split(\"\\n\")[0]?.trim();\n return found !== undefined && found.length > 0 ? found : undefined;\n}\n\n/**\n * Resolve legacy `cli.js` from older `@anthropic-ai/claude-agent-sdk`\n * installs. SDK 0.2.129+ no longer ships this file; callers must treat\n * failure as expected and fall back to the public `claude` binary.\n */\nfunction resolveCliJsPath(): string {\n const require = createRequire(import.meta.url);\n const entry = require.resolve(\"@anthropic-ai/claude-agent-sdk\");\n return join(dirname(entry), \"cli.js\");\n}\n\n/**\n * Default subprocess spawner for production use — invokes the installed\n * Claude Code CLI.\n */\nexport const defaultSpawnCli: SpawnCliFn = (args: string[]) => {\n const command = resolveClaudeExecutable() ?? \"claude\";\n const child = spawn(command, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n return child as unknown as SpawnedProcess;\n};\n\nexport const legacySdkSpawnCli: SpawnCliFn = (args: string[]) => {\n const cliPath = resolveCliJsPath();\n const child = spawn(process.execPath, [cliPath, ...args], {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n return child as unknown as SpawnedProcess;\n};\n\n/**\n * Check whether the user is authenticated via Claude subscription OAuth.\n *\n * Spawns `claude auth status --json`, falling back to the legacy SDK CLI\n * layout when available. On any failure (spawn error, non-JSON stdout,\n * non-zero exit, timeout) we return `{ loggedIn: false }` rather than\n * propagating the error — the caller will fall back to the\n * `ANTHROPIC_API_KEY` path and, if that's also missing, produce a clean\n * two-option error message.\n *\n * The 10s timeout guards against the CLI hanging on a broken network or\n * keychain prompt. In practice `auth status` is a cheap local read.\n */\nexport async function checkClaudeAuth(\n spawnCli: SpawnCliFn = defaultSpawnCli,\n): Promise<ClaudeAuthStatus> {\n if (spawnCli === defaultSpawnCli) {\n const status = await checkClaudeAuthWith(defaultSpawnCli);\n if (status.loggedIn) return status;\n return await checkClaudeAuthWith(legacySdkSpawnCli);\n }\n return await checkClaudeAuthWith(spawnCli);\n}\n\nasync function checkClaudeAuthWith(\n spawnCli: SpawnCliFn,\n): Promise<ClaudeAuthStatus> {\n let child: SpawnedProcess;\n try {\n child = spawnCli([\"auth\", \"status\", \"--json\"]);\n } catch {\n return { loggedIn: false };\n }\n\n return new Promise<ClaudeAuthStatus>((resolve) => {\n let stdout = \"\";\n let stderr = \"\";\n let settled = false;\n\n const settle = (value: ClaudeAuthStatus): void => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n resolve(value);\n };\n\n const timer = setTimeout(() => {\n try {\n child.kill(\"SIGTERM\");\n } catch {\n // Kill can fail if the process already exited; nothing we can do.\n }\n settle({ loggedIn: false });\n }, AUTH_TIMEOUT_MS);\n\n child.stdout.on(\"data\", (data) => {\n stdout += data.toString();\n });\n child.stderr.on(\"data\", (data) => {\n stderr += data.toString();\n });\n\n child.on(\"error\", () => {\n settle({ loggedIn: false });\n });\n\n child.on(\"close\", (code) => {\n // The SDK writes `{\"loggedIn\": false, ...}` to stdout with a zero\n // exit code when the user isn't signed in, so we only reject on\n // non-zero + empty stdout. An empty stdout with zero exit (shouldn't\n // happen in practice) also fails safely to `loggedIn: false`.\n if (code !== 0 && stdout.trim().length === 0) {\n // `stderr` isn't surfaced to the user here — the caller's error\n // message covers both auth paths — but it would be captured by\n // `stderr` if we ever wanted to log it for debugging.\n void stderr;\n settle({ loggedIn: false });\n return;\n }\n try {\n settle(parseClaudeAuthStatus(stdout.trim()));\n } catch {\n settle({ loggedIn: false });\n }\n });\n });\n}\n\nfunction parseClaudeAuthStatus(raw: string): ClaudeAuthStatus {\n const parsed = JSON.parse(raw) as Record<string, unknown>;\n const loggedIn = parsed.loggedIn === true;\n const out: ClaudeAuthStatus = { loggedIn };\n if (typeof parsed.email === \"string\") out.email = parsed.email;\n if (typeof parsed.subscriptionType === \"string\") {\n out.subscriptionType = parsed.subscriptionType;\n }\n if (typeof parsed.authMethod === \"string\") {\n out.authMethod = parsed.authMethod;\n }\n return out;\n}\n\n/**\n * Human-readable error when neither auth path is available. The text is\n * deliberately verbose — users hitting this wall for the first time\n * deserve both options in front of them, not a terse hint.\n */\nexport const UNAUTHENTICATED_MESSAGE =\n \"not authenticated to Claude.\\n\\n\" +\n \"Option 1 — use your Claude subscription (Pro/Max):\\n\" +\n \" claude auth login --claudeai\\n\\n\" +\n \"Option 2 — use a pay-per-token API key:\\n\" +\n \" Get one at https://console.anthropic.com\\n\" +\n \" export ANTHROPIC_API_KEY=sk-ant-...\\n\\n\" +\n \"Verify with: claude auth status\";\n\n/**\n * Assert that at least one auth path is satisfied. Prefers subscription\n * auth (fewer surprises for Claude Pro/Max users) but accepts\n * `ANTHROPIC_API_KEY` as a fallback. On failure throws with\n * `code = \"CLAUDE_AUTH_MISSING\"` so callers can distinguish this from\n * other errors if they ever want to.\n *\n * Returns the resolved auth status so callers that want to display the\n * logged-in email in a preamble can do so without a second subprocess.\n */\nexport async function assertClaudeAuth(\n spawnCli: SpawnCliFn = defaultSpawnCli,\n): Promise<ClaudeAuthStatus> {\n const status = await checkClaudeAuth(spawnCli);\n if (status.loggedIn) {\n return status;\n }\n const apiKey = process.env.ANTHROPIC_API_KEY;\n if (apiKey !== undefined && apiKey.length > 0) {\n // Signal to callers that we're on the API-key path. Not \"loggedIn\"\n // in the OAuth sense, but the SDK will pick up the env var and\n // succeed — so we return a status that tells callers the\n // gate is open.\n return { loggedIn: true, authMethod: \"apiKey\" };\n }\n const err = new Error(UNAUTHENTICATED_MESSAGE);\n (err as { code?: string }).code = \"CLAUDE_AUTH_MISSING\";\n throw err;\n}\n\n// Internal re-export — helps keep the public type surface minimal while\n// still letting tests import the `ChildProcess` shape when needed.\nexport type { ChildProcess };\nexport type { SpawnCliFn, SpawnedProcess };\n"],"mappings":"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","names":["require"]}
|