@docsworth/cli 0.0.0-stage → 0.2.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 (91) hide show
  1. package/LICENSE +55 -0
  2. package/README.md +186 -2
  3. package/dist/cli.d.ts +11 -0
  4. package/dist/cli.js +409 -0
  5. package/dist/core/acl.d.ts +28 -0
  6. package/dist/core/acl.js +102 -0
  7. package/dist/core/audit-log.d.ts +133 -0
  8. package/dist/core/audit-log.js +290 -0
  9. package/dist/core/blocks.d.ts +35 -0
  10. package/dist/core/blocks.js +417 -0
  11. package/dist/core/codeowners.d.ts +62 -0
  12. package/dist/core/codeowners.js +133 -0
  13. package/dist/core/config.d.ts +96 -0
  14. package/dist/core/config.js +131 -0
  15. package/dist/core/coverage.d.ts +25 -0
  16. package/dist/core/coverage.js +41 -0
  17. package/dist/core/duration.d.ts +20 -0
  18. package/dist/core/duration.js +60 -0
  19. package/dist/core/errors.d.ts +11 -0
  20. package/dist/core/errors.js +13 -0
  21. package/dist/core/frontmatter.d.ts +17 -0
  22. package/dist/core/frontmatter.js +40 -0
  23. package/dist/core/gate.d.ts +17 -0
  24. package/dist/core/gate.js +25 -0
  25. package/dist/core/github.d.ts +152 -0
  26. package/dist/core/github.js +234 -0
  27. package/dist/core/host.d.ts +125 -0
  28. package/dist/core/host.js +22 -0
  29. package/dist/core/identity.d.ts +39 -0
  30. package/dist/core/identity.js +127 -0
  31. package/dist/core/init.d.ts +25 -0
  32. package/dist/core/init.js +86 -0
  33. package/dist/core/lint.d.ts +17 -0
  34. package/dist/core/lint.js +115 -0
  35. package/dist/core/mcp/audit-transport.d.ts +16 -0
  36. package/dist/core/mcp/audit-transport.js +238 -0
  37. package/dist/core/mcp/server.d.ts +19 -0
  38. package/dist/core/mcp/server.js +207 -0
  39. package/dist/core/mcp/text.d.ts +14 -0
  40. package/dist/core/mcp/text.js +43 -0
  41. package/dist/core/mirror-view.d.ts +68 -0
  42. package/dist/core/mirror-view.js +367 -0
  43. package/dist/core/new-page.d.ts +52 -0
  44. package/dist/core/new-page.js +49 -0
  45. package/dist/core/policy.d.ts +10 -0
  46. package/dist/core/policy.js +30 -0
  47. package/dist/core/polish.d.ts +42 -0
  48. package/dist/core/polish.js +1000 -0
  49. package/dist/core/references.d.ts +52 -0
  50. package/dist/core/references.js +1242 -0
  51. package/dist/core/repo.d.ts +42 -0
  52. package/dist/core/repo.js +116 -0
  53. package/dist/core/schema.d.ts +46 -0
  54. package/dist/core/schema.js +49 -0
  55. package/dist/core/search.d.ts +24 -0
  56. package/dist/core/search.js +98 -0
  57. package/dist/core/serve/auth.d.ts +23 -0
  58. package/dist/core/serve/auth.js +38 -0
  59. package/dist/core/serve/server.d.ts +22 -0
  60. package/dist/core/serve/server.js +138 -0
  61. package/dist/core/serve/sessions.d.ts +39 -0
  62. package/dist/core/serve/sessions.js +130 -0
  63. package/dist/core/serve/token.d.ts +10 -0
  64. package/dist/core/serve/token.js +96 -0
  65. package/dist/core/service.d.ts +375 -0
  66. package/dist/core/service.js +815 -0
  67. package/dist/core/sla.d.ts +16 -0
  68. package/dist/core/sla.js +20 -0
  69. package/dist/core/stale.d.ts +16 -0
  70. package/dist/core/stale.js +23 -0
  71. package/dist/core/tool-registry.d.ts +31 -0
  72. package/dist/core/tool-registry.js +77 -0
  73. package/dist/core/url.d.ts +4 -0
  74. package/dist/core/url.js +31 -0
  75. package/dist/core/version.d.ts +22 -0
  76. package/dist/core/version.js +32 -0
  77. package/dist/core/writes.d.ts +58 -0
  78. package/dist/core/writes.js +113 -0
  79. package/dist/web/assets/index-BglLMIyN.css +1 -0
  80. package/dist/web/assets/index-DugFPoRu.js +25 -0
  81. package/dist/web/index.html +14 -0
  82. package/docs/format-spec.md +311 -0
  83. package/package.json +75 -4
  84. package/plugin/.claude-plugin/plugin.json +8 -0
  85. package/plugin/.mcp.json +8 -0
  86. package/plugin/bin/docsworth-mcp.mjs +46 -0
  87. package/plugin/skills/draft-runbook/SKILL.md +75 -0
  88. package/plugin/skills/polish-page/SKILL.md +64 -0
  89. package/plugin/skills/update-after-incident/SKILL.md +59 -0
  90. package/plugin/skills/verify-page/SKILL.md +46 -0
  91. package/templates/github/knowledge.yml +51 -0
@@ -0,0 +1,234 @@
1
+ // Minimal GitHub REST client over fetch. Deliberately small: every call the server makes
2
+ // is listed here, which keeps the write path reviewable and lets tests inject a fake.
3
+ import { insecureUrl, urlForError } from "./url.js";
4
+ export class GitHubError extends Error {
5
+ status;
6
+ method;
7
+ path;
8
+ headers;
9
+ /** GitHub's rate limits: a 429, or a 403 that says so (primary or secondary limit). */
10
+ rateLimited;
11
+ /** Seconds GitHub asked to wait (`retry-after`, or until `x-ratelimit-reset`), if it said. */
12
+ retryAfter;
13
+ constructor(
14
+ /** The HTTP status; 0 when GitHub didn't answer (a network error or a timeout). */
15
+ status, method, path, message,
16
+ /** Response headers, e.g. `x-github-sso` on a 403 from an SSO-enforcing org. */
17
+ headers = null, now = Date.now()) {
18
+ const limited = status === 429 ||
19
+ // GitHub's secondary limits come as a 403 (or 429) with `retry-after`, its primary
20
+ // limit with `x-ratelimit-remaining: 0`; the message says so too.
21
+ (status === 403 && (headers?.get("x-ratelimit-remaining") === "0" || Boolean(headers?.has("retry-after")) || /rate limit/i.test(message)));
22
+ const after = Number(headers?.get("retry-after"));
23
+ const reset = Number(headers?.get("x-ratelimit-reset"));
24
+ const wait = Number.isFinite(after) && headers?.has("retry-after") ? after : limited && Number.isFinite(reset) && reset > 0 ? Math.max(0, Math.ceil(reset - now / 1000)) : null;
25
+ super(`GitHub ${method} ${path} → ${status || "no answer"}: ${message}${limited ? ` (rate limited${wait !== null ? `; retry in ${wait} s` : ""})` : ""}`);
26
+ this.status = status;
27
+ this.method = method;
28
+ this.path = path;
29
+ this.headers = headers;
30
+ this.rateLimited = limited;
31
+ this.retryAfter = wait;
32
+ }
33
+ }
34
+ // Reads are retried on what passes (no answer, a 5xx, a rate limit GitHub says lifts
35
+ // within a few seconds); writes never are, since a retried POST could open a second PR.
36
+ const ATTEMPTS = 3;
37
+ const BACKOFF_MS = [500, 1500];
38
+ const MAX_WAIT_S = 10;
39
+ const TIMEOUT_MS = 30_000;
40
+ export class GitHubClient {
41
+ token;
42
+ fetch;
43
+ baseUrl;
44
+ sleep;
45
+ now;
46
+ timeoutMs;
47
+ constructor(token, opts = {}) {
48
+ this.token = token;
49
+ this.fetch = opts.fetch ?? ((url, init) => fetch(url, init));
50
+ this.baseUrl = (opts.baseUrl ?? "https://api.github.com").replace(/\/$/, "");
51
+ this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
52
+ this.now = opts.now ?? Date.now;
53
+ this.timeoutMs = opts.timeoutMs ?? TIMEOUT_MS;
54
+ // Also reached from DOCSWORTH_GITHUB_API_URL, which no config schema sees.
55
+ const why = insecureUrl(this.baseUrl);
56
+ if (why)
57
+ throw new Error(`the GitHub API URL (${urlForError(this.baseUrl)}) ${why}`);
58
+ }
59
+ async request(method, path, body, accept = "application/vnd.github+json") {
60
+ const read = method === "GET" || method === "HEAD";
61
+ // Serialized once, outside the retry: a body that can't be (a fault here) throws as is.
62
+ const payload = body === undefined ? undefined : JSON.stringify(body);
63
+ for (let attempt = 1;; attempt++) {
64
+ let res;
65
+ let text;
66
+ try {
67
+ res = await this.fetch(`${this.baseUrl}${path}`, {
68
+ method,
69
+ headers: {
70
+ authorization: `Bearer ${this.token}`,
71
+ accept,
72
+ "x-github-api-version": "2022-11-28",
73
+ "user-agent": "docsworth",
74
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
75
+ },
76
+ body: payload,
77
+ signal: AbortSignal.timeout(this.timeoutMs),
78
+ });
79
+ text = await res.text();
80
+ }
81
+ catch (cause) {
82
+ // No answer: the connection failed (fetch's TypeError, or undici's "terminated" when
83
+ // it drops while the body is read), or nothing came within the timeout. Anything
84
+ // else is a fault here, not GitHub's, and isn't retried.
85
+ const timedOut = cause instanceof Error && (cause.name === "TimeoutError" || cause.name === "AbortError");
86
+ if (!timedOut && !(cause instanceof TypeError))
87
+ throw cause;
88
+ const err = new GitHubError(0, method, path, timedOut ? `no answer within ${this.timeoutMs / 1000} s` : `network error (${cause.message})`, null, this.now());
89
+ if (read && attempt < ATTEMPTS) {
90
+ await this.sleep(BACKOFF_MS[attempt - 1]);
91
+ continue;
92
+ }
93
+ throw err;
94
+ }
95
+ if (!res.ok) {
96
+ let message = text.slice(0, 300);
97
+ try {
98
+ message = JSON.parse(text).message ?? message;
99
+ }
100
+ catch {
101
+ /* non-JSON error body */
102
+ }
103
+ const err = new GitHubError(res.status, method, path, message, res.headers, this.now());
104
+ // A 5xx is retried on the backoff. A rate limit only when GitHub says it lifts
105
+ // within 10 s: without a time, retrying soon prolongs it (GitHub asks for a minute).
106
+ const wait = err.rateLimited ? (err.retryAfter !== null && err.retryAfter <= MAX_WAIT_S ? err.retryAfter * 1000 : null) : res.status >= 500 ? BACKOFF_MS[attempt - 1] : null;
107
+ if (read && wait !== null && attempt < ATTEMPTS) {
108
+ await this.sleep(wait);
109
+ continue;
110
+ }
111
+ throw err;
112
+ }
113
+ return { data: (text ? JSON.parse(text) : null), headers: res.headers };
114
+ }
115
+ }
116
+ async get(path) {
117
+ return (await this.request("GET", path)).data;
118
+ }
119
+ /** Follow `Link: rel="next"` pagination; `onHeaders` sees every page's headers. */
120
+ async paginate(path, onHeaders) {
121
+ const out = [];
122
+ let next = `${path}${path.includes("?") ? "&" : "?"}per_page=100`;
123
+ while (next) {
124
+ const { data, headers } = await this.request("GET", next);
125
+ onHeaders?.(headers);
126
+ out.push(...data);
127
+ const link = /<([^>]+)>;\s*rel="next"/.exec(headers.get("link") ?? "");
128
+ next = link ? link[1].replace(this.baseUrl, "") : null;
129
+ }
130
+ return out;
131
+ }
132
+ getUser() {
133
+ return this.get("/user");
134
+ }
135
+ getRepo(repo) {
136
+ return this.get(`/repos/${repo}`);
137
+ }
138
+ /**
139
+ * The caller's teams, and the ids of orgs GitHub left out because the token isn't
140
+ * authorized for their SAML SSO (`x-github-sso: partial-results; organizations=…`).
141
+ */
142
+ async listMyTeams() {
143
+ const withheld = new Set();
144
+ const teams = await this.paginate("/user/teams", (h) => {
145
+ for (const id of parseSsoHeader(h.get("x-github-sso")).organizations)
146
+ withheld.add(id);
147
+ });
148
+ return { teams, ssoWithheldOrgIds: [...withheld] };
149
+ }
150
+ /** Logins of a team's members (needs read:org, or members:read on a fine-grained token). */
151
+ async listTeamMembers(org, team) {
152
+ return (await this.paginate(`/orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(team)}/members`)).map((m) => m.login);
153
+ }
154
+ /**
155
+ * Does a team have write access to a repo? GitHub ignores a CODEOWNERS team without it.
156
+ * true/false when GitHub shows the permission; null on a 404, which GitHub gives both
157
+ * for "no access" and for a team this caller can't see, so it proves nothing.
158
+ */
159
+ async teamCanPush(org, team, repo) {
160
+ try {
161
+ const { data } = await this.request("GET", `/orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(team)}/repos/${repo}`, undefined, "application/vnd.github.v3.repository+json");
162
+ return Boolean(data?.permissions?.push);
163
+ }
164
+ catch (err) {
165
+ if (err instanceof GitHubError && err.status === 404)
166
+ return null;
167
+ throw err;
168
+ }
169
+ }
170
+ /** A user's permission on a repo: admin, maintain, write, triage, read or none. */
171
+ async getUserPermission(repo, login) {
172
+ return (await this.get(`/repos/${repo}/collaborators/${encodeURIComponent(login)}/permission`)).permission;
173
+ }
174
+ /** Ruleset rules that apply to a branch (readable with read access). */
175
+ getBranchRules(repo, branch) {
176
+ return this.get(`/repos/${repo}/rules/branches/${encodePath(branch)}`);
177
+ }
178
+ /** A ruleset, with `bypass_actors` when this token may see them (admins). */
179
+ getRuleset(repo, id) {
180
+ return this.get(`/repos/${repo}/rulesets/${id}`);
181
+ }
182
+ /** Classic branch protection (GitHub answers 403/404 to callers without admin). */
183
+ getBranchProtection(repo, branch) {
184
+ return this.get(`/repos/${repo}/branches/${encodePath(branch)}/protection`);
185
+ }
186
+ getFile(repo, path, ref) {
187
+ return this.get(`/repos/${repo}/contents/${encodePath(path)}?ref=${encodeURIComponent(ref)}`);
188
+ }
189
+ async getBranchSha(repo, branch) {
190
+ return (await this.get(`/repos/${repo}/git/ref/heads/${encodePath(branch)}`)).object.sha;
191
+ }
192
+ async createBranch(repo, branch, sha) {
193
+ await this.request("POST", `/repos/${repo}/git/refs`, { ref: `refs/heads/${branch}`, sha });
194
+ }
195
+ async deleteBranch(repo, branch) {
196
+ await this.request("DELETE", `/repos/${repo}/git/refs/heads/${encodePath(branch)}`);
197
+ }
198
+ /** Always pass `branch`: omitting it makes GitHub write to the default branch. */
199
+ async putFile(repo, path, args) {
200
+ await this.request("PUT", `/repos/${repo}/contents/${encodePath(path)}`, {
201
+ message: args.message,
202
+ content: Buffer.from(args.content, "utf8").toString("base64"),
203
+ sha: args.sha,
204
+ branch: args.branch,
205
+ });
206
+ }
207
+ /** An open PR from `branch` into `base` in the same repo, if one exists. */
208
+ async findPull(repo, branch, base) {
209
+ const owner = repo.split("/")[0];
210
+ const q = `head=${encodeURIComponent(`${owner}:${branch}`)}&base=${encodeURIComponent(base)}&state=open`;
211
+ return (await this.get(`/repos/${repo}/pulls?${q}`))[0] ?? null;
212
+ }
213
+ async createPull(repo, args) {
214
+ return (await this.request("POST", `/repos/${repo}/pulls`, args)).data;
215
+ }
216
+ }
217
+ /** `required; url=https://…` or `partial-results; organizations=1,2`. */
218
+ export function parseSsoHeader(value) {
219
+ if (!value)
220
+ return { required: false, url: null, organizations: [] };
221
+ const parts = value.split(";").map((p) => p.trim());
222
+ const field = (k) => parts.find((p) => p.startsWith(`${k}=`))?.slice(k.length + 1) ?? null;
223
+ return {
224
+ required: parts[0] === "required",
225
+ url: field("url"),
226
+ organizations: (field("organizations") ?? "").split(",").map((s) => s.trim()).filter(Boolean),
227
+ };
228
+ }
229
+ function encodePath(p) {
230
+ return p.split("/").map(encodeURIComponent).join("/");
231
+ }
232
+ export function decodeContent(file) {
233
+ return Buffer.from(file.content, file.encoding === "base64" ? "base64" : "utf8").toString("utf8");
234
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Everything Docsworth pro may use of core, and nothing else. Pro imports core only through
3
+ * this file, which a test holds to the list below: pro is another package, and this is the
4
+ * one entry point the CLI exports to it.
5
+ *
6
+ * HOST_API is the contract's version. Pro declares the version it was built for, and the
7
+ * CLI refuses a pro built for another, with a message naming both, rather than failing
8
+ * somewhere inside a command. The check is equality, so raise it whenever the contract
9
+ * changes at all: a name here added, removed or changed in meaning, or what the CLI and
10
+ * pro expect of each other (ProCommands, CommandContext, pro's entry points).
11
+ * 2: pro's commands behind ProCommands, and the names they use (pro-split step 2).
12
+ * 3: buildNewPage, NEW_PAGE_PREFIX and slugify: promote builds its page with core's builder,
13
+ * which draft_page shares.
14
+ */
15
+ import type { AuditActor, AuditLog, AuditSink } from "./audit-log.js";
16
+ import type { Config } from "./config.js";
17
+ import type { Coverage } from "./coverage.js";
18
+ import type { MirrorPage } from "./mirror-view.js";
19
+ import type { Repo } from "./repo.js";
20
+ import type { PageType } from "./schema.js";
21
+ export declare const HOST_API = 3;
22
+ export { canSee, type Identity, visiblePages } from "./acl.js";
23
+ export { type AuditActor, type AuditEvent, type AuditLog, type AuditSink, actorOf, resolveAuditDir, writeAll } from "./audit-log.js";
24
+ export { matchRule, resolveTeamOwner } from "./codeowners.js";
25
+ export { type Config, CONFIG_PATH, ConfigError } from "./config.js";
26
+ export { computeCoverage, type Coverage, type CoverageRow } from "./coverage.js";
27
+ export { formatDate, parseDuration, subtractDuration, utcToday } from "./duration.js";
28
+ export { isUserError, UserError } from "./errors.js";
29
+ export { splitFrontmatter } from "./frontmatter.js";
30
+ export { pageStatus } from "./gate.js";
31
+ export { type FetchLike, type GitHubClient, GitHubError } from "./github.js";
32
+ export { IdentityProvider, LOCAL_IDENTITY, resolveToken } from "./identity.js";
33
+ export { INSTRUCTIONS, readDescription, searchDescription } from "./mcp/text.js";
34
+ export { buildNewPage, NEW_PAGE_PREFIX, slugify } from "./new-page.js";
35
+ export { canonicalDir, loadMirror, lstatExists, MIRROR_SOURCES, type MirrorPage, type MirrorSource, mirrorRecordPath, ownedFiles, ownedSpaceFiles, readOwned, sha256, } from "./mirror-view.js";
36
+ export { isPage, type Page, parsePage, type Repo, resolveLink } from "./repo.js";
37
+ export { ACL_INTERNAL, type PageType } from "./schema.js";
38
+ export { SearchIndex, snippet, tokenize } from "./search.js";
39
+ export { KnowledgeService, ToolError } from "./service.js";
40
+ export { listStale, type StaleEntry } from "./stale.js";
41
+ export { insecureUrl, urlForError } from "./url.js";
42
+ export { openPagePr, type PrResult } from "./writes.js";
43
+ /** A source of mirrored pages (Confluence, Notion): pro's connectors implement it. */
44
+ export interface Connector {
45
+ readonly source: MirrorPage["source"];
46
+ readonly space: string;
47
+ pages(): AsyncIterable<MirrorPage>;
48
+ /** Pages the token can see but which carry read restrictions (never mirrored). */
49
+ readonly skippedRestricted: string[];
50
+ readonly requestCount: number;
51
+ }
52
+ /** The systems the benchmark compares. */
53
+ export type SystemName = "diy" | "ungated" | "gated";
54
+ /** What a pro command gets from the CLI that runs it. */
55
+ export interface CommandContext {
56
+ /** "Now" for every SLA computation (`--as-of` pins it). */
57
+ now(): Date;
58
+ repoAt(dir: string | undefined): Repo;
59
+ /** Who ran a local command, for audit events with no GitHub identity. */
60
+ cliActor(): AuditActor;
61
+ /** The repo's audit log: the core file sink, plus pro's HTTP sink if configured. */
62
+ openAuditLog(root: string, config: Config): Promise<AuditLog | null>;
63
+ /** The change happened but its outcome isn't in a required log: say so, exit 3. */
64
+ auditDegraded(err: unknown, what: string): void;
65
+ /** An environment variable the command can't run without (a ConfigError if unset). */
66
+ requireEnv(name: string): string;
67
+ }
68
+ /**
69
+ * Pro's commands. The CLI defines each one (its name, arguments, options and help, so help
70
+ * reads the same without pro) and calls these for what it does.
71
+ */
72
+ export interface ProCommands {
73
+ /** `coverage --html`: the dashboard for coverage the CLI computed. */
74
+ coverageHtml(ctx: CommandContext, coverage: Coverage, repo: Repo, file: string): Promise<void>;
75
+ digest(ctx: CommandContext, dir: string, opts: {
76
+ team?: string;
77
+ send?: boolean;
78
+ json?: boolean;
79
+ }): Promise<void>;
80
+ compile(ctx: CommandContext, dir: string, opts: {
81
+ out?: string;
82
+ check?: boolean;
83
+ markStale?: boolean;
84
+ dryRun?: boolean;
85
+ includeRestricted?: boolean;
86
+ }): Promise<void>;
87
+ mirror(ctx: CommandContext, dir: string, opts: {
88
+ source?: string;
89
+ }): Promise<void>;
90
+ mirrorPurge(ctx: CommandContext, dir: string, opts: {
91
+ source?: string;
92
+ }): Promise<void>;
93
+ promote(ctx: CommandContext, mirrorId: string, dir: string, opts: {
94
+ ownerTeam: string;
95
+ type: PageType;
96
+ id?: string;
97
+ dir?: string;
98
+ dryRun?: boolean;
99
+ }): Promise<void>;
100
+ benchRun(ctx: CommandContext, dir: string, opts: {
101
+ questions?: string;
102
+ out?: string;
103
+ systems?: string;
104
+ model: string;
105
+ limit?: number;
106
+ concurrency: number;
107
+ label: string;
108
+ }): Promise<void>;
109
+ benchScore(ctx: CommandContext, dir: string): Promise<void>;
110
+ audit(ctx: CommandContext, dir: string, opts: {
111
+ bench?: string;
112
+ out: string;
113
+ title: string;
114
+ }): Promise<void>;
115
+ accessReview(ctx: CommandContext, dir: string, opts: {
116
+ members?: boolean;
117
+ format: string;
118
+ }): Promise<void>;
119
+ usageReport(ctx: CommandContext, dir: string, opts: {
120
+ since: string;
121
+ auditDir?: string;
122
+ }): Promise<void>;
123
+ /** The HTTP audit sink, if `audit_http` is configured (null if it isn't). */
124
+ httpSinkFromRepo(root: string): Promise<AuditSink | null>;
125
+ }
@@ -0,0 +1,22 @@
1
+ export const HOST_API = 3;
2
+ export { canSee, visiblePages } from "./acl.js";
3
+ export { actorOf, resolveAuditDir, writeAll } from "./audit-log.js";
4
+ export { matchRule, resolveTeamOwner } from "./codeowners.js";
5
+ export { CONFIG_PATH, ConfigError } from "./config.js";
6
+ export { computeCoverage } from "./coverage.js";
7
+ export { formatDate, parseDuration, subtractDuration, utcToday } from "./duration.js";
8
+ export { isUserError, UserError } from "./errors.js";
9
+ export { splitFrontmatter } from "./frontmatter.js";
10
+ export { pageStatus } from "./gate.js";
11
+ export { GitHubError } from "./github.js";
12
+ export { IdentityProvider, LOCAL_IDENTITY, resolveToken } from "./identity.js";
13
+ export { INSTRUCTIONS, readDescription, searchDescription } from "./mcp/text.js";
14
+ export { buildNewPage, NEW_PAGE_PREFIX, slugify } from "./new-page.js";
15
+ export { canonicalDir, loadMirror, lstatExists, MIRROR_SOURCES, mirrorRecordPath, ownedFiles, ownedSpaceFiles, readOwned, sha256, } from "./mirror-view.js";
16
+ export { isPage, parsePage, resolveLink } from "./repo.js";
17
+ export { ACL_INTERNAL } from "./schema.js";
18
+ export { SearchIndex, snippet, tokenize } from "./search.js";
19
+ export { KnowledgeService, ToolError } from "./service.js";
20
+ export { listStale } from "./stale.js";
21
+ export { insecureUrl, urlForError } from "./url.js";
22
+ export { openPagePr } from "./writes.js";
@@ -0,0 +1,39 @@
1
+ import type { Identity } from "./acl.js";
2
+ import { UserError } from "./errors.js";
3
+ import type { Config } from "./config.js";
4
+ import { GitHubClient } from "./github.js";
5
+ export declare class IdentityError extends UserError {
6
+ }
7
+ /** GITHUB_TOKEN → GH_TOKEN → `gh auth token`, so the 5-minute quickstart needs no setup. */
8
+ export declare function resolveToken(env?: NodeJS.ProcessEnv): string | null;
9
+ export declare const LOCAL_IDENTITY: Identity;
10
+ /**
11
+ * GitHub App installation tokens (`ghs_`) act for an app, not a person, so they can't
12
+ * carry anyone's team ACLs or attribute an audit event. Personal tokens (classic or
13
+ * fine-grained), OAuth tokens (`gh auth token`) and GitHub App *user* tokens are fine.
14
+ */
15
+ export declare function refuseInstallationToken(token: string): void;
16
+ /**
17
+ * Resolves and caches the caller's identity.
18
+ *
19
+ * - github.repo set + token: identity from GitHub, cached for 5 minutes. Failure is
20
+ * fatal (fail closed): a silent fallback would hide that ACLs are not being checked.
21
+ * - github.repo set, no token: refuse. GitHub is the ACL authority for this repo.
22
+ * - no github.repo: local-only repo; filesystem access is the authority, so the caller
23
+ * sees `internal` pages, never team pages, and cannot write.
24
+ */
25
+ export declare class IdentityProvider {
26
+ private readonly config;
27
+ readonly github: GitHubClient | null;
28
+ private readonly clock;
29
+ private readonly log;
30
+ private cached;
31
+ /** policy.identity_cache_seconds: the window in which a removed member can still read. */
32
+ private readonly ttlMs;
33
+ constructor(config: Config, github: GitHubClient | null, clock?: () => number, log?: (msg: string) => void);
34
+ static fromEnv(config: Config, env?: NodeJS.ProcessEnv, clientOpts?: ConstructorParameters<typeof GitHubClient>[1]): IdentityProvider;
35
+ /** Cached identity for reads. */
36
+ get(): Promise<Identity>;
37
+ /** Re-resolve from GitHub now (writes always do this). */
38
+ fresh(): Promise<Identity>;
39
+ }
@@ -0,0 +1,127 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { UserError } from "./errors.js";
3
+ import { GitHubClient, GitHubError, parseSsoHeader } from "./github.js";
4
+ export class IdentityError extends UserError {
5
+ }
6
+ /** GITHUB_TOKEN → GH_TOKEN → `gh auth token`, so the 5-minute quickstart needs no setup. */
7
+ export function resolveToken(env = process.env) {
8
+ const fromEnv = env.GITHUB_TOKEN || env.GH_TOKEN;
9
+ if (fromEnv)
10
+ return fromEnv.trim();
11
+ if (env.DOCSWORTH_NO_GH_CLI)
12
+ return null;
13
+ const gh = spawnSync("gh", ["auth", "token"], { encoding: "utf8", timeout: 5000 });
14
+ const token = gh.status === 0 ? gh.stdout.trim() : "";
15
+ return token || null;
16
+ }
17
+ export const LOCAL_IDENTITY = { mode: "local", login: null, teams: new Set(), repoAccess: true, canWrite: false };
18
+ /**
19
+ * GitHub App installation tokens (`ghs_`) act for an app, not a person, so they can't
20
+ * carry anyone's team ACLs or attribute an audit event. Personal tokens (classic or
21
+ * fine-grained), OAuth tokens (`gh auth token`) and GitHub App *user* tokens are fine.
22
+ */
23
+ export function refuseInstallationToken(token) {
24
+ if (token.startsWith("ghs_"))
25
+ throw new IdentityError("this is a GitHub App installation token, which acts for no person; use your own token (`gh auth token`, a personal access token, or a GitHub App user token)");
26
+ }
27
+ /**
28
+ * Resolves and caches the caller's identity.
29
+ *
30
+ * - github.repo set + token: identity from GitHub, cached for 5 minutes. Failure is
31
+ * fatal (fail closed): a silent fallback would hide that ACLs are not being checked.
32
+ * - github.repo set, no token: refuse. GitHub is the ACL authority for this repo.
33
+ * - no github.repo: local-only repo; filesystem access is the authority, so the caller
34
+ * sees `internal` pages, never team pages, and cannot write.
35
+ */
36
+ export class IdentityProvider {
37
+ config;
38
+ github;
39
+ clock;
40
+ log;
41
+ cached = null;
42
+ /** policy.identity_cache_seconds: the window in which a removed member can still read. */
43
+ ttlMs;
44
+ constructor(config, github, clock = Date.now, log = (m) => process.stderr.write(`docsworth: ${m}\n`)) {
45
+ this.config = config;
46
+ this.github = github;
47
+ this.clock = clock;
48
+ this.log = log;
49
+ this.ttlMs = config.policy.identity_cache_seconds * 1000;
50
+ if (config.github.repo && !github) {
51
+ throw new IdentityError(`github.repo is ${config.github.repo}, so a GitHub token is required (set GITHUB_TOKEN or run \`gh auth login\`)`);
52
+ }
53
+ }
54
+ static fromEnv(config, env = process.env, clientOpts = {}) {
55
+ const token = config.github.repo ? resolveToken(env) : null;
56
+ if (token)
57
+ refuseInstallationToken(token);
58
+ const baseUrl = env.DOCSWORTH_GITHUB_API_URL || config.github.api_url;
59
+ return new IdentityProvider(config, token ? new GitHubClient(token, { baseUrl, ...clientOpts }) : null);
60
+ }
61
+ /** Cached identity for reads. */
62
+ async get() {
63
+ if (!this.github)
64
+ return LOCAL_IDENTITY;
65
+ if (this.cached && this.clock() - this.cached.at < this.ttlMs)
66
+ return this.cached.identity;
67
+ return this.fresh();
68
+ }
69
+ /** Re-resolve from GitHub now (writes always do this). */
70
+ async fresh() {
71
+ if (!this.github)
72
+ return LOCAL_IDENTITY;
73
+ const repo = this.config.github.repo;
74
+ try {
75
+ const [user, info] = await Promise.all([this.github.getUser(), this.github.getRepo(repo)]);
76
+ let teams = [];
77
+ try {
78
+ const mine = await this.github.listMyTeams();
79
+ teams = mine.teams.filter((t) => t.organization.login.toLowerCase() === this.config.github.org).map((t) => t.slug.toLowerCase());
80
+ const org = this.config.github.org;
81
+ // GitHub leaves out teams of orgs whose SSO this token isn't authorized for. Say so
82
+ // only when that is the configured org (known when it owns the repo).
83
+ const ownerIsOrg = info.owner?.login.toLowerCase() === org;
84
+ if (mine.ssoWithheldOrgIds.length && (!ownerIsOrg || mine.ssoWithheldOrgIds.includes(String(info.owner.id))))
85
+ this.log(ownerIsOrg
86
+ ? `this token isn't authorized for ${org}'s SAML SSO, so GitHub withheld its teams; team-ACL pages stay hidden until you authorize it`
87
+ : `GitHub withheld teams of org id(s) ${mine.ssoWithheldOrgIds.join(", ")} (SAML SSO not authorized for this token); if one is ${org}, team-ACL pages stay hidden`);
88
+ }
89
+ catch (err) {
90
+ // A rate limit isn't a missing scope: an identity with no teams would hide team
91
+ // pages for the whole cache window and blame the token.
92
+ if (!(err instanceof GitHubError) || err.status !== 403 || err.rateLimited)
93
+ throw err;
94
+ // SSO first: a 403 that carries `x-github-sso: required` isn't a missing scope.
95
+ // Rethrown, it fails closed below with the authorization link.
96
+ if (parseSsoHeader(err.headers?.get("x-github-sso") ?? null).required)
97
+ throw err;
98
+ this.log("token lacks read:org; team-ACL pages will stay hidden");
99
+ }
100
+ const identity = {
101
+ mode: "github",
102
+ login: user.login,
103
+ teams: new Set(teams),
104
+ repoAccess: true,
105
+ canWrite: Boolean(info.permissions?.push),
106
+ };
107
+ this.cached = { identity, at: this.clock() };
108
+ return identity;
109
+ }
110
+ catch (err) {
111
+ this.cached = null;
112
+ const sso = err instanceof GitHubError ? parseSsoHeader(err.headers?.get("x-github-sso") ?? null) : null;
113
+ if (sso?.required)
114
+ throw new IdentityError(`this token isn't authorized for ${this.config.github.org}'s SAML single sign-on${sso.url ? `; authorize it at ${sso.url} (the link expires in an hour)` : ""}, or use a fine-grained or GitHub App token`);
115
+ // A rate limit is a 403 too, and isn't "no access": say what it is and when it lifts.
116
+ if (err instanceof GitHubError && err.rateLimited)
117
+ throw new IdentityError(`GitHub's rate limit for this token is reached${err.retryAfter !== null ? `; it lifts in ${Math.ceil(err.retryAfter / 60)} min` : ""}, so access can't be checked until then`);
118
+ if (err instanceof GitHubError && err.status === 403 && /not accessible by integration/i.test(err.message) && err.path === "/user")
119
+ throw new IdentityError("this token can't identify a person (GitHub App installation token?); use your own token");
120
+ if (err instanceof GitHubError && (err.status === 404 || err.status === 403))
121
+ throw new IdentityError(`no access to ${repo} with this token (GitHub said ${err.status})`);
122
+ if (err instanceof GitHubError && err.status === 401)
123
+ throw new IdentityError("GitHub rejected the token (401)");
124
+ throw new IdentityError(`could not resolve identity from GitHub: ${err.message}`);
125
+ }
126
+ }
127
+ }
@@ -0,0 +1,25 @@
1
+ import { UserError } from "./errors.js";
2
+ export interface InitOptions {
3
+ org: string;
4
+ team: string;
5
+ repo?: string;
6
+ ci?: boolean;
7
+ now: Date;
8
+ /** The package the CI workflow runs; defaults to PUBLISHED_PACKAGE (tests pass one). */
9
+ package?: string;
10
+ }
11
+ export declare class InitError extends UserError {
12
+ }
13
+ export interface InitResult {
14
+ created: string[];
15
+ skipped: string[];
16
+ hints: string[];
17
+ }
18
+ /** owner/name from the origin remote, if it is a GitHub URL. */
19
+ export declare function githubRemote(root: string): {
20
+ owner: string;
21
+ name: string;
22
+ } | null;
23
+ /** `.knowledge/.gitignore`: local state that must never be committed. */
24
+ export declare const LOCAL_STATE_GITIGNORE = "# Written by Docsworth at runtime; never commit these.\nusage.jsonl\nmirror/\ncache/\naudit/\n";
25
+ export declare function initRepo(root: string, opts: InitOptions): InitResult;
@@ -0,0 +1,86 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { CODEOWNERS_LOCATIONS } from "./codeowners.js";
6
+ import { formatDate, utcToday } from "./duration.js";
7
+ import { UserError } from "./errors.js";
8
+ import { ownedPackage, PUBLISHED_PACKAGE } from "./version.js";
9
+ export class InitError extends UserError {
10
+ }
11
+ /** owner/name from the origin remote, if it is a GitHub URL. */
12
+ export function githubRemote(root) {
13
+ const r = spawnSync("git", ["-C", root, "remote", "get-url", "origin"], { encoding: "utf8" });
14
+ const m = /github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\s*$/.exec(r.stdout ?? "");
15
+ return m ? { owner: m[1], name: m[2] } : null;
16
+ }
17
+ function page(fm, body) {
18
+ return `---\n${Object.entries(fm).map(([k, v]) => `${k}: ${v}`).join("\n")}\n---\n${body}`;
19
+ }
20
+ /** `.knowledge/.gitignore`: local state that must never be committed. */
21
+ export const LOCAL_STATE_GITIGNORE = "# Written by Docsworth at runtime; never commit these.\nusage.jsonl\nmirror/\ncache/\naudit/\n";
22
+ export function initRepo(root, opts) {
23
+ // Before anything is written: a workflow naming a package we don't own would run a
24
+ // stranger's code in the customer's CI, with a token that can push.
25
+ const pkg = opts.package ?? PUBLISHED_PACKAGE;
26
+ if (opts.ci && !ownedPackage(pkg))
27
+ throw new InitError(`--ci would run \`${pkg}\`, which isn't a plain npm package and range we own (\`@scope/name@^1.2\`)`);
28
+ const today = formatDate(utcToday(opts.now));
29
+ const res = { created: [], skipped: [], hints: [] };
30
+ const write = (rel, content) => {
31
+ const abs = join(root, rel);
32
+ if (existsSync(abs))
33
+ return void res.skipped.push(rel);
34
+ mkdirSync(dirname(abs), { recursive: true });
35
+ writeFileSync(abs, content);
36
+ res.created.push(rel);
37
+ };
38
+ const base = { owner_team: opts.team, status: "canonical", last_verified: today };
39
+ write(".knowledge/config.yaml", [
40
+ "# Docsworth configuration. See docs/format-spec.md in the Docsworth repo.",
41
+ "format: 1",
42
+ "knowledge_root: knowledge",
43
+ "github:",
44
+ ` org: ${opts.org.toLowerCase()}`,
45
+ opts.repo ? ` repo: ${opts.repo}` : " # repo: owner/name # set to make GitHub the ACL authority and enable PR tools",
46
+ "sla:",
47
+ " due_soon_days: 14",
48
+ "usage_log: true",
49
+ "skills:",
50
+ " out_dir: skills",
51
+ "",
52
+ ].join("\n"));
53
+ // Runtime state the CLI and server write next to the config. None of it belongs in git:
54
+ // the usage log names pages people read, the mirror holds copies of the old wiki.
55
+ write(".knowledge/.gitignore", LOCAL_STATE_GITIGNORE);
56
+ const existingCodeowners = CODEOWNERS_LOCATIONS.find((l) => existsSync(join(root, l)));
57
+ if (existingCodeowners) {
58
+ res.skipped.push(existingCodeowners);
59
+ if (!/^\/?knowledge\//m.test(readFileSync(join(root, existingCodeowners), "utf8")))
60
+ res.hints.push(`add \`/knowledge/ @${opts.org}/${opts.team}\` to ${existingCodeowners}; every knowledge page needs exactly one owning team`);
61
+ }
62
+ else {
63
+ write("CODEOWNERS", `# Knowledge ownership: exactly one team per path (enforced by \`docsworth lint\`).\n/knowledge/ @${opts.org}/${opts.team}\n`);
64
+ }
65
+ write("knowledge/index.md", page({ title: "Engineering knowledge", id: "index", type: "guide", ...base, review_every: "90d", description: "Root map of this knowledge base. Agents read this first." }, "# Engineering knowledge\n\nStart here. Each page has one owning team and a review date; agents only cite pages that are canonical and inside their review window.\n\n## Guides\n- [Writing and verifying pages](guides/writing-pages.md)\n\nSee also the [glossary](glossary.md).\n"));
66
+ write("knowledge/glossary.md", page({ title: "Glossary", id: "glossary", type: "guide", ...base, review_every: "180d", description: "Acronyms and codenames." }, "# Glossary\n\n- **Canonical**: a page agents may cite, once it is verified within its review period.\n"));
67
+ write("knowledge/guides/writing-pages.md", page({ title: "Writing and verifying pages", id: "guide-writing-pages", type: "guide", ...base, review_every: "180d", description: "How to add, change and verify pages in this knowledge base." }, [
68
+ "# Writing and verifying pages",
69
+ "",
70
+ "## Adding a page",
71
+ "1. Create a markdown file under `knowledge/` with the frontmatter fields `title`, `id`, `type`, `status`, `owner_team`, `last_verified` and `review_every`.",
72
+ "2. Runbooks go in `knowledge/runbooks/` with the sections *When to use*, *Steps*, *Checks* and *Rollback*, so they compile into agent skills.",
73
+ "3. Run `docsworth lint .` and open a pull request; the owning team in CODEOWNERS reviews it.",
74
+ "",
75
+ "## Keeping pages citable",
76
+ "- A page is citable while `status: canonical` and today is on or before `last_verified + review_every`.",
77
+ "- To re-verify, ask Claude Code to `verify` the page (owners only), or edit `last_verified` in a PR.",
78
+ "- `docsworth stale .` lists overdue and unowned pages.",
79
+ "",
80
+ ].join("\n")));
81
+ if (opts.ci) {
82
+ const template = join(dirname(fileURLToPath(import.meta.url)), "../../templates/github/knowledge.yml");
83
+ write(".github/workflows/knowledge.yml", readFileSync(template, "utf8").replaceAll("__DOCSWORTH_PACKAGE__", pkg));
84
+ }
85
+ return res;
86
+ }