@docsworth/cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +60 -8
  2. package/dist/cli.js +52 -3
  3. package/dist/core/acl.d.ts +9 -0
  4. package/dist/core/acl.js +40 -6
  5. package/dist/core/adopt.d.ts +68 -0
  6. package/dist/core/adopt.js +665 -0
  7. package/dist/core/config.d.ts +6 -1
  8. package/dist/core/config.js +46 -2
  9. package/dist/core/edits.d.ts +13 -0
  10. package/dist/core/edits.js +20 -0
  11. package/dist/core/frontmatter.d.ts +12 -0
  12. package/dist/core/frontmatter.js +16 -7
  13. package/dist/core/gfm.d.ts +13 -0
  14. package/dist/core/gfm.js +244 -0
  15. package/dist/core/links.d.ts +82 -0
  16. package/dist/core/links.js +345 -0
  17. package/dist/core/lint.d.ts +1 -2
  18. package/dist/core/lint.js +40 -6
  19. package/dist/core/mcp/server.js +11 -2
  20. package/dist/core/printable.d.ts +4 -0
  21. package/dist/core/printable.js +16 -0
  22. package/dist/core/references.d.ts +38 -3
  23. package/dist/core/references.js +543 -41
  24. package/dist/core/repo.d.ts +4 -0
  25. package/dist/core/repo.js +3 -1
  26. package/dist/core/schema.d.ts +4 -0
  27. package/dist/core/schema.js +8 -6
  28. package/dist/core/search.d.ts +8 -0
  29. package/dist/core/search.js +34 -0
  30. package/dist/core/serve/server.d.ts +1 -2
  31. package/dist/core/serve/sessions.d.ts +5 -4
  32. package/dist/core/serve/sessions.js +102 -29
  33. package/dist/core/service.d.ts +118 -20
  34. package/dist/core/service.js +249 -91
  35. package/dist/core/stale.js +2 -1
  36. package/dist/core/tool-registry.js +6 -3
  37. package/dist/core/verify-pages.d.ts +19 -0
  38. package/dist/core/verify-pages.js +110 -0
  39. package/dist/core/version.d.ts +2 -2
  40. package/dist/core/version.js +2 -2
  41. package/dist/web/assets/index-8J-FUbb0.js +29 -0
  42. package/dist/web/assets/index-BGpAZzeM.css +1 -0
  43. package/dist/web/index.html +2 -2
  44. package/docs/format-spec.md +40 -2
  45. package/package.json +2 -2
  46. package/plugin/.claude-plugin/plugin.json +1 -1
  47. package/plugin/bin/docsworth-mcp.mjs +1 -1
  48. package/dist/web/assets/index-BglLMIyN.css +0 -1
  49. package/dist/web/assets/index-DugFPoRu.js +0 -25
package/README.md CHANGED
@@ -44,7 +44,8 @@ docsworth init --team platform # config, CODEOWNERS, root map, a guide
44
44
  docsworth lint . # 3 pages, 0 errors, 0 warnings
45
45
  ```
46
46
 
47
- Add `--ci` to `init` for a workflow that lints and compiles on every PR.
47
+ `init` reads your GitHub org from the repo's remote; in a repo without one yet, add
48
+ `--org <your-org>`. Add `--ci` for a workflow that lints and compiles on every PR.
48
49
 
49
50
  Install the Claude Code plugin from this repo's marketplace:
50
51
 
@@ -67,12 +68,54 @@ Start Claude Code in the knowledge repo, or set `DOCSWORTH_KNOWLEDGE_DIR` to its
67
68
  The server uses your own GitHub identity (`GITHUB_TOKEN`, `GH_TOKEN` or `gh auth
68
69
  token`), so your repo and team permissions decide what you see.
69
70
 
71
+ ## Adopting existing docs
72
+
73
+ Docs you already have, a Docusaurus site's say, become pages without moving a URL. With
74
+ them under `knowledge/` (or `knowledge_root` in `.knowledge/config.yaml` set to where they
75
+ are), after `init`:
76
+
77
+ ```bash
78
+ docsworth adopt --team docs # the frontmatter each page lacks, as drafts
79
+ docsworth lint .
80
+ ```
81
+
82
+ `adopt` gives each page the fields it lacks, and keeps everything it has:
83
+
84
+ - a page's `id` is its own, else its file name, a Docusaurus number prefix removed, so the
85
+ site's URLs stay where they are;
86
+ - its owner comes from CODEOWNERS, else `--team`;
87
+ - a route link like `/guides/setup` becomes a relative link to the file,
88
+ `../guides/setup.md`;
89
+ - keys a site generator keeps, such as `sidebar_position`, go in `frontmatter.extra_fields`;
90
+ - images a site serves from its `static/` directory (`/img/x.png`) get a hint naming the
91
+ `static_dirs` line to add, after which lint reads them there.
92
+
93
+ A page or link it can't settle stays as it is, listed, and lint fails it until it's fixed
94
+ by hand: a category index like `guides/index.md`, two pages with one file name, a link to
95
+ nothing. `--dry-run` reports and writes nothing.
96
+
97
+ Adopted pages are drafts, so agents cite none of them yet. Once a team has read its pages:
98
+
99
+ ```bash
100
+ docsworth verify knowledge/guides # canonical, verified today
101
+ ```
102
+
103
+ Both commands only change files. Commit them and open a pull request, which CODEOWNERS
104
+ sends to the owning team.
105
+
106
+ If the site stays up, set `site_url` (`https://docs.example.com/docs/`) in the config.
107
+ Then a link to a page someone can't see reads `[restricted page]` by its full URL on the
108
+ site, as it already does by its route. A link without an extension that names nothing
109
+ reads `[restricted page]` too: a route can't be told from a hidden page's.
110
+
70
111
  ## Commands
71
112
 
72
113
  | Command | What it does |
73
114
  | --- | --- |
74
115
  | `docsworth init [dir]` | Scaffold config, CODEOWNERS, `knowledge/index.md`, a glossary and a writing guide. Never overwrites. |
75
116
  | `docsworth lint [dir]` | Frontmatter, CODEOWNERS ownership, links and duplicate ids. Exit 1 on errors; overdue pages only warn. |
117
+ | `docsworth adopt [dir] [--team t]` | Existing pages get the frontmatter they lack, as drafts, and route links become `.md` links; lists what it can't settle. Working tree only; `--dry-run`. |
118
+ | `docsworth verify <paths…>` | The draft or canonical pages under the paths become canonical, verified today. Working tree only; `--dry-run`. |
76
119
  | `docsworth stale [dir]` | Pages past their review SLA or without a team owner. |
77
120
  | `docsworth coverage [dir] [--html f]` | Percentage team-owned and percentage verified within SLA, by team; optional static dashboard. |
78
121
  | `docsworth digest [dir] [--send]` | Weekly per-team digest: due, overdue, most read by agents. Prints unless `--send`. |
@@ -131,11 +174,20 @@ npm run docsworth -- --as-of 2026-11-02 audit examples/acme --out audit
131
174
  ## Browser extension
132
175
 
133
176
  A side panel for Chrome (and other Chromium browsers) over `docsworth serve`. From the panel
134
- you can search and read pages, or suggest an addition to one. From any page, right-click a
135
- selection and choose **Save selection to Docsworth**. In Gmail, a **Save to Docsworth**
136
- button beside a thread's subject opens the panel. The panel first shows the pages that may
137
- already cover the thread, then a draft page filled from it. Nothing is written until you
138
- open the draft PR. It isn't in the Chrome Web Store yet: build it and load it unpacked.
177
+ you can search and read pages, or suggest an addition to one.
178
+
179
+ **Saving what you read.**
180
+ - **From the menu:** on any page, right-click and choose **Save page to Docsworth**, or
181
+ select some text and choose **Save selection to Docsworth**.
182
+ - **From the panel:** **Save this page** and **Save the selection** read the tab you opened
183
+ the panel from with the toolbar button.
184
+ - **In Gmail:** a **Save to Docsworth** button beside a thread's subject.
185
+
186
+ The page's main content or the selection arrives as markdown, with its headings, links,
187
+ code blocks and tables, and without what the page hides or its navigation. The panel first
188
+ shows the pages that may already cover it, each with **Add the capture here**, then a draft
189
+ page filled from it, with its source. Nothing is written until you open the PR. The
190
+ extension isn't in the Chrome Web Store yet: build it and load it unpacked.
139
191
 
140
192
  ```bash
141
193
  npm run build -w @docsworth/extension
@@ -177,8 +229,8 @@ See [AGENTS.md](AGENTS.md), and [docs/releasing.md](docs/releasing.md) for relea
177
229
 
178
230
  ## Status
179
231
 
180
- v0.2. What's in it, and what hasn't been verified against the real thing yet, is in
181
- [the release notes](docs/release-notes/v0.2.0.md).
232
+ v0.3. What's in it, and what hasn't been verified against the real thing yet, is in
233
+ [the release notes](docs/release-notes/v0.3.0.md).
182
234
 
183
235
  ## License
184
236
 
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import { dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { Command, InvalidArgumentError } from "commander";
7
7
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { adoptRepo } from "./core/adopt.js";
8
9
  import { actorOf, createAuditLog, stateDir } from "./core/audit-log.js";
9
10
  import { CONFIG_PATH, ConfigError, loadConfig } from "./core/config.js";
10
11
  import { computeCoverage, formatCoverage } from "./core/coverage.js";
@@ -21,6 +22,8 @@ import { DEFAULT_PORT, startServer } from "./core/serve/server.js";
21
22
  import { loadToken } from "./core/serve/token.js";
22
23
  import { KnowledgeService } from "./core/service.js";
23
24
  import { listStale } from "./core/stale.js";
25
+ import { printable, printableJson } from "./core/printable.js";
26
+ import { verifyPages } from "./core/verify-pages.js";
24
27
  import { PACKAGE_VERSION } from "./core/version.js";
25
28
  import { existsSync, readFileSync } from "node:fs";
26
29
  import { parse as parseYaml } from "yaml";
@@ -172,9 +175,55 @@ program
172
175
  .option("--json", "machine-readable output")
173
176
  .action((dir, opts) => {
174
177
  const result = lintRepo(repoAt(dir), now());
175
- console.log(opts.json ? JSON.stringify(result, null, 2) : formatDiagnostics(result));
178
+ console.log(opts.json ? printableJson(result) : formatDiagnostics(result));
176
179
  process.exitCode = result.errors > 0 ? 1 : 0;
177
180
  });
181
+ program
182
+ .command("adopt")
183
+ .description("give existing markdown pages the frontmatter Docsworth needs, as drafts, and turn route links into .md links (working tree only)")
184
+ .argument("[dir]", "knowledge repo root", ".")
185
+ .option("--team <team>", "owning team for pages CODEOWNERS gives no single team")
186
+ .option("--review-every <period>", "review period for pages without one", "90d")
187
+ .option("--dry-run", "report what would change; write nothing")
188
+ .option("--json", "machine-readable output")
189
+ .action((dir, opts) => {
190
+ const r = adoptRepo(resolve(dir), { now: now(), team: opts.team, reviewEvery: opts.reviewEvery, dryRun: opts.dryRun });
191
+ if (opts.json)
192
+ return console.log(printableJson(r));
193
+ // Paths, links, keys and reasons quote what pages hold: printed escaped.
194
+ const say = (line) => console.log(printable(line));
195
+ for (const l of r.left)
196
+ say(`left ${l.path}: ${l.reason}`);
197
+ for (const l of r.linksLeft)
198
+ say(`link left ${l.path}: \`${l.link}\` ${l.why}`);
199
+ if (r.extraFields.length)
200
+ say(`config frontmatter.extra_fields gains ${r.extraFields.join(", ")}`);
201
+ for (const h of r.hints)
202
+ say(`hint ${h}`);
203
+ const n = (count, what) => `${count} ${what}${count === 1 ? "" : "s"}`;
204
+ console.log(`\n${r.dryRun ? "Would adopt" : "Adopted"} ${n(r.adopted.length, "page")} (${r.unchanged.length} unchanged, ${r.left.length} left); ` +
205
+ `${r.linksRewritten} ${r.linksRewritten === 1 ? "link" : "links"} rewritten, ${r.linksLeft.length} left.`);
206
+ console.log(r.dryRun
207
+ ? "Dry run: nothing written."
208
+ : `Nothing is committed. Review the diff, run \`docsworth lint ${dir}\`, and open a pull request: adopted pages are drafts, cited once their team runs \`docsworth verify\`.`);
209
+ });
210
+ program
211
+ .command("verify")
212
+ .description("verify pages: each draft or canonical page under the paths becomes canonical, verified today (working tree only)")
213
+ .argument("<paths...>", "pages, or directories of them")
214
+ .option("--dir <dir>", "knowledge repo root", ".")
215
+ .option("--dry-run", "report what would change; write nothing")
216
+ .option("--json", "machine-readable output")
217
+ .action((paths, opts) => {
218
+ // Paths are the shell's, relative to where it runs, whatever --dir says.
219
+ const r = verifyPages(resolve(opts.dir), paths.map((p) => resolve(p)), { now: now(), dryRun: opts.dryRun });
220
+ if (opts.json)
221
+ return console.log(printableJson(r));
222
+ for (const l of r.left)
223
+ console.log(printable(`left ${l.path}: ${l.reason}`));
224
+ console.log(`\n${r.dryRun ? "Would verify" : "Verified"} ${r.verified.length} page${r.verified.length === 1 ? "" : "s"} (${r.unchanged.length} already verified today, ${r.left.length} left).`);
225
+ console.log(r.dryRun ? "Dry run: nothing written." : "Nothing is committed: open a pull request, which the owning team reviews.");
226
+ });
178
227
  program
179
228
  .command("stale")
180
229
  .description("pages past their review SLA or without a team owner")
@@ -295,7 +344,6 @@ program
295
344
  const web = join(dirname(fileURLToPath(import.meta.url)), "web");
296
345
  const serving = await startServer({
297
346
  root,
298
- knowledgeRoot: config.knowledge_root,
299
347
  identity,
300
348
  who,
301
349
  audit,
@@ -400,7 +448,8 @@ const isMain = process.argv[1] && /(?:cli\.(?:ts|js)|docsworth)$/.test(process.a
400
448
  if (isMain) {
401
449
  program.parseAsync().catch(async (err) => {
402
450
  if (isUserError(err)) {
403
- console.error(`docsworth: ${err.message}`);
451
+ // A message can quote a path or a value a page holds: printed escaped.
452
+ console.error(`docsworth: ${printable(err.message)}`);
404
453
  // Exit 3 if the failure also left a change its required audit log doesn't show.
405
454
  process.exit(process.exitCode === EXIT_AUDIT_DEGRADED ? EXIT_AUDIT_DEGRADED : 2);
406
455
  }
@@ -26,3 +26,12 @@ export declare const RESTRICTED = "[restricted page]";
26
26
  * definitions, and HTML anchors.
27
27
  */
28
28
  export declare function redactLinks(body: string, fromPath: string, repo: Repo, who: Identity): string;
29
+ /** One pass of the sweep for `who` on page `fromPath`: `liberal`, every route naming nothing fails closed, a link or not. */
30
+ export declare function redactionSweep(fromPath: string, repo: Repo, who: Identity, gone?: Set<string>): (text: string, liberal: boolean) => string;
31
+ /**
32
+ * `sweep` run until it changes nothing. Replacing a line can change what the rest means (a
33
+ * fence gone, code is markdown, and its links are links), so a pass can leave work for the
34
+ * next. Past `passes`, one last `liberal` pass, where every route naming nothing fails
35
+ * closed, ends it.
36
+ */
37
+ export declare function untilStable(text: string, sweep: (text: string, liberal: boolean) => string, passes?: number): string;
package/dist/core/acl.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ACL_INTERNAL } from "./schema.js";
2
- import { destinationHides, namingLines, pageNames, replaceLines } from "./references.js";
2
+ import { definitionsIn, destinationHides, linkPaths, normalLabel, namingLines, pageNames, replaceLines } from "./references.js";
3
3
  export function canSee(page, who) {
4
4
  if (!who.repoAccess)
5
5
  return false;
@@ -38,8 +38,8 @@ const A_CLOSE = /<\/a\s*>/i;
38
38
  * path. Anything under the knowledge root that is not a loadable page fails closed: an
39
39
  * invalid page has no trustworthy ACL.
40
40
  */
41
- function hidesPage(dest, fromPath, repo, who, html = false) {
42
- return destinationHides(dest, fromPath, namesFor(repo, who), html);
41
+ function hidesPage(dest, fromPath, repo, who, html = false, links) {
42
+ return destinationHides(dest, fromPath, namesFor(repo, who), html, links);
43
43
  }
44
44
  /**
45
45
  * Rewrite every link to a page the caller cannot see, so a visible page (say the root
@@ -48,26 +48,60 @@ function hidesPage(dest, fromPath, repo, who, html = false) {
48
48
  * definitions, and HTML anchors.
49
49
  */
50
50
  export function redactLinks(body, fromPath, repo, who) {
51
- const hidden = (dest) => hidesPage(dest, fromPath, repo, who);
51
+ // A route naming nothing fails closed where CommonMark reads a link (references.ts).
52
+ const links = linkPaths(body);
53
+ const hidden = (dest) => hidesPage(dest, fromPath, repo, who, false, links);
52
54
  // Reference definitions first: remember which labels point at hidden pages.
53
55
  const hiddenLabels = new Set();
56
+ // Only the definition CommonMark puts in effect for a label (code review, round 9): a
57
+ // `]:` inside a paragraph defines nothing, and is left for the sweep.
58
+ const defs = definitionsIn(body);
54
59
  let out = body.replace(REF_DEF, (whole, label, dest) => {
55
60
  if (!hidden(dest))
56
61
  return whole;
62
+ const inEffect = defs?.get(normalLabel(label));
63
+ if (defs && (inEffect === undefined || !hidden(inEffect)))
64
+ return whole;
57
65
  hiddenLabels.add(label.trim().toLowerCase());
58
66
  return "";
59
67
  });
60
68
  if (hiddenLabels.size) {
61
69
  // [text][label], [label][] and the shortcut form [label].
62
70
  out = out.replace(/\[([^\][]{0,999})\]\[([^\][]{0,999})\]/g, (whole, text, label) => hiddenLabels.has((label || text).trim().toLowerCase()) ? RESTRICTED : whole);
63
- out = out.replace(/\[([^\][]{1,999})\](?![(\[:])/g, (whole, label) => (hiddenLabels.has(label.trim().toLowerCase()) ? RESTRICTED : whole));
71
+ // Not a label right after a `]`: that's a full reference whose text the form above
72
+ // couldn't read (it holds brackets: merge gate, PR 53, round 3), and the label alone
73
+ // would leave its text. The sweep takes those lines whole.
74
+ out = out.replace(/(?<!\])\[([^\][]{1,999})\](?![(\[:])/g, (whole, label) => (hiddenLabels.has(label.trim().toLowerCase()) ? RESTRICTED : whole));
64
75
  }
65
76
  out = out.replace(INLINE, (whole, _text, dest) => (hidden(dest) ? RESTRICTED : whole));
66
77
  out = redactAnchors(out, (href) => hidesPage(href, fromPath, repo, who, true));
67
78
  // The passes above keep the rest of a line, but read only the common forms. The sweep
68
79
  // after them fails closed: a line that still names a hidden page, in any form a reader
69
80
  // resolves, goes whole (references.ts).
70
- return replaceLines(out, namingLines(out, fromPath, namesFor(repo, who)), RESTRICTED);
81
+ // The labels the first pass took the definitions of: the sweep takes their references,
82
+ // which the forms above miss (`[ref]:` that can't start a definition is one).
83
+ return untilStable(out, redactionSweep(fromPath, repo, who, new Set(hiddenLabels)));
84
+ }
85
+ /** One pass of the sweep for `who` on page `fromPath`: `liberal`, every route naming nothing fails closed, a link or not. */
86
+ export function redactionSweep(fromPath, repo, who, gone = new Set()) {
87
+ const names = namesFor(repo, who);
88
+ // `gone`: the labels the first pass took the definitions of, whose references each pass
89
+ // takes too.
90
+ return (text, liberal) => replaceLines(text, namingLines(text, fromPath, names, 0, [], liberal, gone), RESTRICTED);
91
+ }
92
+ /**
93
+ * `sweep` run until it changes nothing. Replacing a line can change what the rest means (a
94
+ * fence gone, code is markdown, and its links are links), so a pass can leave work for the
95
+ * next. Past `passes`, one last `liberal` pass, where every route naming nothing fails
96
+ * closed, ends it.
97
+ */
98
+ export function untilStable(text, sweep, passes = 3) {
99
+ for (let pass = 1;; pass++) {
100
+ const next = sweep(text, pass > passes);
101
+ if (next === text || pass > passes)
102
+ return next;
103
+ text = next;
104
+ }
71
105
  }
72
106
  /** Each `<a href="…">…</a>` whose href is hidden, as the placeholder (its `</a>` within 4,000 characters). */
73
107
  function redactAnchors(text, hidden) {
@@ -0,0 +1,68 @@
1
+ import { type Config } from "./config.js";
2
+ import { UserError } from "./errors.js";
3
+ import { unprefixed } from "./links.js";
4
+ export { unprefixed };
5
+ export declare class AdoptError extends UserError {
6
+ }
7
+ export interface AdoptOptions {
8
+ now: Date;
9
+ /** The owning team where CODEOWNERS doesn't name exactly one. */
10
+ team?: string;
11
+ /** `review_every` for pages without one (default 90d). */
12
+ reviewEvery?: string;
13
+ /** Report, and write nothing. */
14
+ dryRun?: boolean;
15
+ }
16
+ export interface AdoptResult {
17
+ dryRun: boolean;
18
+ /** Pages adopt changed (or would, on a dry run). */
19
+ adopted: string[];
20
+ /** Pages it had nothing to change on. */
21
+ unchanged: string[];
22
+ /** Pages, and symbolic links, it left as they are, and why. */
23
+ left: {
24
+ path: string;
25
+ reason: string;
26
+ }[];
27
+ /** What the team still has to do: a CODEOWNERS line, say. */
28
+ hints: string[];
29
+ /** Route links rewritten to relative `.md` links (R2). */
30
+ linksRewritten: number;
31
+ /** Links lint fails that adopt couldn't rewrite, as written, and why. */
32
+ linksLeft: {
33
+ path: string;
34
+ link: string;
35
+ why: string;
36
+ }[];
37
+ /** Keys added to the config's `frontmatter.extra_fields`: the adopted pages' unknown keys (R3). */
38
+ extraFields: string[];
39
+ }
40
+ /** The repo, its config and its knowledge root, by real path, each checked to be inside the repo (R9). */
41
+ export interface RepoPaths {
42
+ repo: string;
43
+ config: Config;
44
+ configPath: string;
45
+ knowledge: string;
46
+ }
47
+ /** Whether `child` is `parent` or under it, by path components (so `/repo-other` isn't under `/repo`). */
48
+ export declare function within(parent: string, child: string): boolean;
49
+ /** R9's checks, before any page is read: the config and the knowledge root are inside the repo. */
50
+ export declare function repoPaths(root: string, Err?: new (m: string) => Error, verb?: string): RepoPaths;
51
+ /**
52
+ * Why a file with other hard links is left, or null: the other path could be anywhere,
53
+ * outside the repo too, and a write here would change the file there (R9).
54
+ */
55
+ export declare function hardLinked(abs: string, verb: string): string | null;
56
+ /** Why a symbolic link in the knowledge root is left. */
57
+ export declare const symlinkReason: (verb: string) => string;
58
+ /**
59
+ * The knowledge root's pages, as the loader finds them (directories starting with a dot
60
+ * skipped), by real path. It follows no symbolic link: the loader reads none, and a link is
61
+ * the one way a walk could leave the root (R9). A link that names a page or a directory is
62
+ * reported.
63
+ */
64
+ export declare function walk(dir: string, out: {
65
+ files: string[];
66
+ symlinks: string[];
67
+ }): void;
68
+ export declare function adoptRepo(root: string, opts: AdoptOptions): AdoptResult;