get-tbd 0.2.2 → 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.
- package/README.md +38 -6
- package/dist/bin.mjs +4036 -1074
- package/dist/bin.mjs.map +1 -1
- package/dist/cli.mjs +2505 -1585
- package/dist/cli.mjs.map +1 -1
- package/dist/{config-BJz1m9eN.mjs → config-B1w3pAkY.mjs} +142 -279
- package/dist/config-B1w3pAkY.mjs.map +1 -0
- package/dist/config-DXhifxOw.mjs +3 -0
- package/dist/doc-cache-CamtXfi4.mjs +1035 -0
- package/dist/doc-cache-CamtXfi4.mjs.map +1 -0
- package/dist/doc-cache-CiBwpDTf.mjs +3 -0
- package/dist/doc-fork-BMjqzfAs.mjs +3 -0
- package/dist/doc-fork-CoPi1G1N.mjs +304 -0
- package/dist/doc-fork-CoPi1G1N.mjs.map +1 -0
- package/dist/docref-C7g0sjvL.mjs +167 -0
- package/dist/docref-C7g0sjvL.mjs.map +1 -0
- package/dist/docs/README.md +38 -6
- package/dist/docs/SKILL.md +21 -5
- package/dist/docs/guidelines/backward-compatibility-rules.md +1 -0
- package/dist/docs/guidelines/bun-monorepo-patterns.md +1 -0
- package/dist/docs/guidelines/cli-agent-skill-patterns.md +228 -52
- package/dist/docs/guidelines/commit-conventions.md +1 -0
- package/dist/docs/guidelines/common-doc-guidelines.md +1 -0
- package/dist/docs/guidelines/convex-limits-best-practices.md +1 -0
- package/dist/docs/guidelines/convex-rules.md +1 -0
- package/dist/docs/guidelines/electron-app-development-patterns.md +1 -0
- package/dist/docs/guidelines/error-handling-rules.md +4 -0
- package/dist/docs/guidelines/general-coding-rules.md +3 -1
- package/dist/docs/guidelines/general-comment-rules.md +3 -1
- package/dist/docs/guidelines/general-eng-agent-principles.md +127 -0
- package/dist/docs/guidelines/general-tdd-guidelines.md +7 -0
- package/dist/docs/guidelines/general-testing-rules.md +5 -0
- package/dist/docs/guidelines/golden-testing-guidelines.md +1 -0
- package/dist/docs/guidelines/pnpm-monorepo-patterns.md +1 -0
- package/dist/docs/guidelines/python-cli-patterns.md +5 -0
- package/dist/docs/guidelines/python-modern-guidelines.md +4 -0
- package/dist/docs/guidelines/python-rules.md +7 -0
- package/dist/docs/guidelines/release-notes-guidelines.md +1 -0
- package/dist/docs/guidelines/supply-chain-hardening.md +3 -2
- package/dist/docs/guidelines/tbd-sync-troubleshooting.md +25 -2
- package/dist/docs/guidelines/typescript-cli-tool-rules.md +4 -3
- package/dist/docs/guidelines/typescript-code-coverage.md +7 -4
- package/dist/docs/guidelines/typescript-rules.md +8 -9
- package/dist/docs/guidelines/typescript-sorting-patterns.md +2 -1
- package/dist/docs/guidelines/typescript-yaml-handling-rules.md +6 -5
- package/dist/docs/references/docmap-format.md +64 -0
- package/dist/docs/references/docref-format.md +71 -0
- package/dist/docs/shortcuts/standard/checkout-third-party-repo.md +17 -4
- package/dist/docs/shortcuts/standard/new-shortcut.md +17 -3
- package/dist/docs/shortcuts/standard/plan-implementation-with-beads.md +1 -1
- package/dist/docs/shortcuts/standard/setup-github-cli.md +4 -1
- package/dist/docs/shortcuts/standard/suggest-upstream-improvements.md +69 -0
- package/dist/docs/shortcuts/standard/sync-failure-recovery.md +1 -1
- package/dist/docs/shortcuts/standard/welcome-user.md +28 -0
- package/dist/docs/shortcuts/system/shortcut-explanation.md +16 -1
- package/dist/docs/shortcuts/system/skill-baseline.md +21 -5
- package/dist/docs/tbd-design.md +144 -3
- package/dist/docs/tbd-docs.md +169 -5
- package/dist/docs/tbd-prime.md +0 -1
- package/dist/docs/templates/qa-playbook.md +3 -1
- package/dist/docs/templates/research-brief.md +2 -2
- package/dist/fork-manifest-ByU7U2do.mjs +253 -0
- package/dist/fork-manifest-ByU7U2do.mjs.map +1 -0
- package/dist/fork-manifest-C7lGRq-6.mjs +3 -0
- package/dist/{id-mapping-mtoSP9Qt.mjs → id-mapping-D0iitY-F.mjs} +1 -1
- package/dist/{id-mapping-687_UEsy.mjs → id-mapping-DAIeLKzm.mjs} +8 -200
- package/dist/id-mapping-DAIeLKzm.mjs.map +1 -0
- package/dist/index.d.mts +90 -13
- package/dist/index.mjs +3 -3
- package/dist/lockfile-BR0laSDy.mjs +198 -0
- package/dist/lockfile-BR0laSDy.mjs.map +1 -0
- package/dist/paths-C1DpnZJW.mjs +405 -0
- package/dist/paths-C1DpnZJW.mjs.map +1 -0
- package/dist/{schemas-f0EcuAVu.mjs → schemas-lCwRk30L.mjs} +19 -6
- package/dist/schemas-lCwRk30L.mjs.map +1 -0
- package/dist/{src-BpvcrLnq.mjs → src-CxKOynr1.mjs} +3 -3
- package/dist/src-CxKOynr1.mjs.map +1 -0
- package/dist/tbd +4036 -1074
- package/dist/yaml-utils-BPy991by.mjs.map +1 -1
- package/package.json +1 -1
- package/dist/config-BJz1m9eN.mjs.map +0 -1
- package/dist/config-DlCUMyCG.mjs +0 -3
- package/dist/docs/guidelines/general-eng-assistant-rules.md +0 -59
- package/dist/id-mapping-687_UEsy.mjs.map +0 -1
- package/dist/schemas-f0EcuAVu.mjs.map +0 -1
- package/dist/src-BpvcrLnq.mjs.map +0 -1
|
@@ -0,0 +1,1035 @@
|
|
|
1
|
+
import { d as updateLocalState, f as writeConfig, l as readLocalState, n as findTbdRoot, s as readConfig } from "./config-B1w3pAkY.mjs";
|
|
2
|
+
import { F as gitSafeEnv, S as TBD_DOCS_DIR, a as CHARS_PER_TOKEN } from "./paths-C1DpnZJW.mjs";
|
|
3
|
+
import { a as tryParseDocRef } from "./docref-C7g0sjvL.mjs";
|
|
4
|
+
import matter from "gray-matter";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { access, mkdir, readFile, readdir, rm } from "node:fs/promises";
|
|
7
|
+
import { basename, dirname, join } from "node:path";
|
|
8
|
+
import { writeFile } from "atomically";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import prettyBytes from "pretty-bytes";
|
|
12
|
+
import prettyMs from "pretty-ms";
|
|
13
|
+
|
|
14
|
+
//#region src/file/github-fetch.ts
|
|
15
|
+
/**
|
|
16
|
+
* GitHub URL utilities and content fetching with `gh` CLI fallback.
|
|
17
|
+
*
|
|
18
|
+
* Consolidates all GitHub-specific URL handling and fetching logic:
|
|
19
|
+
* - GitHub blob URL → raw URL conversion
|
|
20
|
+
* - Direct HTTP fetch with timeout
|
|
21
|
+
* - Fallback to `gh api` on 403 errors (common in restricted environments)
|
|
22
|
+
*
|
|
23
|
+
* This module is reusable across doc-sync, doc-add, and any future code
|
|
24
|
+
* that needs to fetch content from GitHub.
|
|
25
|
+
*/
|
|
26
|
+
const execFileAsync = promisify(execFile);
|
|
27
|
+
/**
|
|
28
|
+
* Regular expression to match GitHub blob URLs.
|
|
29
|
+
*
|
|
30
|
+
* Matches patterns like:
|
|
31
|
+
* https://github.com/{owner}/{repo}/blob/{ref}/{path}
|
|
32
|
+
*/
|
|
33
|
+
const GITHUB_BLOB_RE = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/;
|
|
34
|
+
/**
|
|
35
|
+
* Regular expression to match raw.githubusercontent.com URLs.
|
|
36
|
+
*
|
|
37
|
+
* Matches patterns like:
|
|
38
|
+
* https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{path}
|
|
39
|
+
*/
|
|
40
|
+
const RAW_GITHUB_RE = /^https?:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/;
|
|
41
|
+
/**
|
|
42
|
+
* Convert a GitHub blob URL to a raw.githubusercontent.com URL.
|
|
43
|
+
*
|
|
44
|
+
* If the URL is already a raw URL or not a GitHub blob URL, returns it unchanged.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* githubBlobToRawUrl('https://github.com/org/repo/blob/main/docs/file.md')
|
|
48
|
+
* // => 'https://raw.githubusercontent.com/org/repo/main/docs/file.md'
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* githubBlobToRawUrl('https://raw.githubusercontent.com/org/repo/main/docs/file.md')
|
|
52
|
+
* // => 'https://raw.githubusercontent.com/org/repo/main/docs/file.md' (unchanged)
|
|
53
|
+
*/
|
|
54
|
+
function githubBlobToRawUrl(url) {
|
|
55
|
+
const match = GITHUB_BLOB_RE.exec(url);
|
|
56
|
+
if (!match) return url;
|
|
57
|
+
const [, owner, repo, ref, path] = match;
|
|
58
|
+
return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${path}`;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Parse a raw.githubusercontent.com URL into its components.
|
|
62
|
+
*
|
|
63
|
+
* @returns The parsed components, or null if not a raw GitHub URL
|
|
64
|
+
*/
|
|
65
|
+
function parseRawGitHubUrl(url) {
|
|
66
|
+
const match = RAW_GITHUB_RE.exec(url);
|
|
67
|
+
if (!match) return null;
|
|
68
|
+
return {
|
|
69
|
+
owner: match[1],
|
|
70
|
+
repo: match[2],
|
|
71
|
+
ref: match[3],
|
|
72
|
+
path: match[4]
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** Default timeout for URL fetches in milliseconds */
|
|
76
|
+
const DEFAULT_FETCH_TIMEOUT = 3e4;
|
|
77
|
+
/**
|
|
78
|
+
* Fetch content from a URL via direct HTTP.
|
|
79
|
+
*
|
|
80
|
+
* @throws If the request fails or returns a non-OK status
|
|
81
|
+
*/
|
|
82
|
+
async function directFetch(url, options) {
|
|
83
|
+
const timeout = options?.timeout ?? DEFAULT_FETCH_TIMEOUT;
|
|
84
|
+
const controller = new AbortController();
|
|
85
|
+
const timer = setTimeout(() => {
|
|
86
|
+
controller.abort();
|
|
87
|
+
}, timeout);
|
|
88
|
+
try {
|
|
89
|
+
const response = await fetch(url, {
|
|
90
|
+
signal: controller.signal,
|
|
91
|
+
headers: {
|
|
92
|
+
"User-Agent": "get-tbd/1.0",
|
|
93
|
+
Accept: "text/plain, text/markdown, */*"
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
97
|
+
return await response.text();
|
|
98
|
+
} finally {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Fetch content from a GitHub raw URL using `gh api`.
|
|
104
|
+
*
|
|
105
|
+
* Converts raw.githubusercontent.com URLs to GitHub API content endpoints
|
|
106
|
+
* and decodes the base64-encoded response.
|
|
107
|
+
*
|
|
108
|
+
* @throws If the URL is not a GitHub URL or gh CLI fails
|
|
109
|
+
*/
|
|
110
|
+
async function ghCliFetch(rawUrl) {
|
|
111
|
+
const parsed = parseRawGitHubUrl(rawUrl);
|
|
112
|
+
if (parsed) try {
|
|
113
|
+
const { stdout } = await execFileAsync("gh", [
|
|
114
|
+
"api",
|
|
115
|
+
`/repos/${parsed.owner}/${parsed.repo}/contents/${parsed.path}?ref=${parsed.ref}`,
|
|
116
|
+
"--jq",
|
|
117
|
+
".content",
|
|
118
|
+
"-H",
|
|
119
|
+
"Accept: application/vnd.github.v3+json"
|
|
120
|
+
]);
|
|
121
|
+
const base64Content = stdout.trim();
|
|
122
|
+
return Buffer.from(base64Content, "base64").toString("utf-8");
|
|
123
|
+
} catch (error) {
|
|
124
|
+
throw new Error(`Failed to fetch via gh CLI: ${error instanceof Error ? error.message : String(error)}`);
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const { stdout } = await execFileAsync("gh", ["api", rawUrl]);
|
|
128
|
+
return stdout;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
throw new Error(`Failed to fetch via gh CLI: ${error instanceof Error ? error.message : String(error)}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Fetch content from a URL, falling back to `gh` CLI on 403 errors.
|
|
135
|
+
*
|
|
136
|
+
* GitHub.com and raw.githubusercontent.com may block requests from
|
|
137
|
+
* certain environments (e.g., CI runners, corporate proxies). When
|
|
138
|
+
* a 403 is received, we retry using `gh api` which authenticates
|
|
139
|
+
* via the user's GitHub CLI token.
|
|
140
|
+
*
|
|
141
|
+
* This function also auto-converts GitHub blob URLs to raw URLs.
|
|
142
|
+
*
|
|
143
|
+
* @returns The fetched content and whether gh CLI was used
|
|
144
|
+
* @throws If both direct fetch and gh CLI fallback fail
|
|
145
|
+
*/
|
|
146
|
+
async function fetchWithGhFallback(url, options) {
|
|
147
|
+
const rawUrl = githubBlobToRawUrl(url);
|
|
148
|
+
try {
|
|
149
|
+
return {
|
|
150
|
+
content: await directFetch(rawUrl, options),
|
|
151
|
+
usedGhCli: false
|
|
152
|
+
};
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (!(error instanceof Error && error.message.includes("HTTP 403"))) throw error;
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
content: await ghCliFetch(rawUrl),
|
|
158
|
+
usedGhCli: true
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Resolve a git docref (github:o/r@ref//path, gitlab:...) to a raw-content URL.
|
|
163
|
+
* The ref is required for sync determinism; callers enforce that.
|
|
164
|
+
*/
|
|
165
|
+
function gitDocRefToRawUrl(ref) {
|
|
166
|
+
const rev = ref.ref ?? "main";
|
|
167
|
+
if (ref.host === "gitlab") return `https://gitlab.com/${ref.owner}/${ref.repo}/-/raw/${rev}/${ref.path}`;
|
|
168
|
+
return `https://raw.githubusercontent.com/${ref.owner}/${ref.repo}/${rev}/${ref.path}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
//#endregion
|
|
172
|
+
//#region src/file/doc-sync.ts
|
|
173
|
+
/**
|
|
174
|
+
* DocSync - Sync documentation files from configured sources.
|
|
175
|
+
*
|
|
176
|
+
* Syncs docs from internal bundled sources and external URLs to .tbd/docs/.
|
|
177
|
+
*
|
|
178
|
+
* See: docs/project/specs/active/plan-2026-01-26-configurable-doc-cache-sync.md
|
|
179
|
+
*/
|
|
180
|
+
/**
|
|
181
|
+
* Group docs_cache.files entries by source root: one group per git repo+ref
|
|
182
|
+
* (so failures isolate per source and revisions are captured once), with
|
|
183
|
+
* internal, local, and ad-hoc URL entries in origin-keyed groups.
|
|
184
|
+
*/
|
|
185
|
+
function groupSourceEntries(config) {
|
|
186
|
+
const groups = /* @__PURE__ */ new Map();
|
|
187
|
+
for (const [destPath, sourceStr] of Object.entries(config)) {
|
|
188
|
+
let key = "url";
|
|
189
|
+
let gitSource;
|
|
190
|
+
if (sourceStr.startsWith("internal:")) key = "internal";
|
|
191
|
+
else {
|
|
192
|
+
const parsed = tryParseDocRef(sourceStr);
|
|
193
|
+
if (parsed?.kind === "git") {
|
|
194
|
+
key = `${parsed.host}:${parsed.owner}/${parsed.repo}@${parsed.ref ?? "main"}`;
|
|
195
|
+
gitSource = {
|
|
196
|
+
host: parsed.host,
|
|
197
|
+
owner: parsed.owner,
|
|
198
|
+
repo: parsed.repo,
|
|
199
|
+
ref: parsed.ref
|
|
200
|
+
};
|
|
201
|
+
} else if (parsed?.kind === "local") key = "local";
|
|
202
|
+
else try {
|
|
203
|
+
key = new URL(sourceStr).origin;
|
|
204
|
+
} catch {
|
|
205
|
+
key = "url";
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
let group = groups.get(key);
|
|
209
|
+
if (!group) {
|
|
210
|
+
group = {
|
|
211
|
+
key,
|
|
212
|
+
gitSource,
|
|
213
|
+
entries: []
|
|
214
|
+
};
|
|
215
|
+
groups.set(key, group);
|
|
216
|
+
}
|
|
217
|
+
group.entries.push([destPath, sourceStr]);
|
|
218
|
+
}
|
|
219
|
+
return [...groups.values()];
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Capture the current revision of a git source's ref (one ls-remote per
|
|
223
|
+
* group), for provenance. Best-effort: offline or missing git returns null.
|
|
224
|
+
*/
|
|
225
|
+
async function captureGitRevision(src) {
|
|
226
|
+
const repoUrl = src.host === "gitlab" ? `https://gitlab.com/${src.owner}/${src.repo}.git` : `https://github.com/${src.owner}/${src.repo}.git`;
|
|
227
|
+
try {
|
|
228
|
+
const { execFile } = await import("node:child_process");
|
|
229
|
+
const { promisify } = await import("node:util");
|
|
230
|
+
const sha = (await promisify(execFile)("git", [
|
|
231
|
+
"ls-remote",
|
|
232
|
+
repoUrl,
|
|
233
|
+
src.ref ?? "HEAD"
|
|
234
|
+
], {
|
|
235
|
+
timeout: 1e4,
|
|
236
|
+
env: gitSafeEnv()
|
|
237
|
+
})).stdout.split(" ")[0]?.trim();
|
|
238
|
+
return sha && /^[0-9a-f]{40}$/.test(sha) ? sha : null;
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/** Prefix for internal bundled doc sources */
|
|
244
|
+
const INTERNAL_SOURCE_PREFIX = "internal:";
|
|
245
|
+
/**
|
|
246
|
+
* Syncs documentation files from configured sources.
|
|
247
|
+
*
|
|
248
|
+
* Supports:
|
|
249
|
+
* - Internal bundled docs (using internal: prefix)
|
|
250
|
+
* - External URLs (raw.githubusercontent.com, etc.)
|
|
251
|
+
*/
|
|
252
|
+
var DocSync = class {
|
|
253
|
+
docsDir;
|
|
254
|
+
/**
|
|
255
|
+
* Create a new DocSync instance.
|
|
256
|
+
*
|
|
257
|
+
* @param tbdRoot - The tbd project root directory (parent of .tbd/)
|
|
258
|
+
* @param config - The doc_cache configuration mapping dest paths to sources
|
|
259
|
+
*/
|
|
260
|
+
constructor(tbdRoot, config) {
|
|
261
|
+
this.tbdRoot = tbdRoot;
|
|
262
|
+
this.config = config;
|
|
263
|
+
this.docsDir = join(tbdRoot, TBD_DOCS_DIR);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Parse a source string into a DocSource.
|
|
267
|
+
*
|
|
268
|
+
* @example
|
|
269
|
+
* parseSource('internal:shortcuts/standard/code-review-and-commit.md')
|
|
270
|
+
* // => { type: 'internal', location: 'shortcuts/standard/code-review-and-commit.md' }
|
|
271
|
+
*
|
|
272
|
+
* @example
|
|
273
|
+
* parseSource('https://raw.githubusercontent.com/org/repo/main/file.md')
|
|
274
|
+
* // => { type: 'url', location: 'https://...' }
|
|
275
|
+
*/
|
|
276
|
+
parseSource(source) {
|
|
277
|
+
if (source.startsWith(INTERNAL_SOURCE_PREFIX)) return {
|
|
278
|
+
type: "internal",
|
|
279
|
+
location: source.slice(9)
|
|
280
|
+
};
|
|
281
|
+
const parsed = tryParseDocRef(source);
|
|
282
|
+
if (parsed?.kind === "git") return {
|
|
283
|
+
type: "url",
|
|
284
|
+
location: gitDocRefToRawUrl(parsed)
|
|
285
|
+
};
|
|
286
|
+
if (parsed?.kind === "local") return {
|
|
287
|
+
type: "local",
|
|
288
|
+
location: parsed.path
|
|
289
|
+
};
|
|
290
|
+
return {
|
|
291
|
+
type: "url",
|
|
292
|
+
location: source
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Fetch content from a source.
|
|
297
|
+
*
|
|
298
|
+
* @throws If the source cannot be fetched
|
|
299
|
+
*/
|
|
300
|
+
async fetchContent(source) {
|
|
301
|
+
if (source.type === "internal") return this.fetchInternalContent(source.location);
|
|
302
|
+
if (source.type === "local") return await readFile(join(this.tbdRoot, source.location), "utf-8");
|
|
303
|
+
return this.fetchUrlContent(source.location);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Fetch content from an internal bundled doc.
|
|
307
|
+
*/
|
|
308
|
+
async fetchInternalContent(location) {
|
|
309
|
+
const basePaths = getDocsBasePath();
|
|
310
|
+
for (const basePath of basePaths) {
|
|
311
|
+
const fullPath = join(basePath, location);
|
|
312
|
+
try {
|
|
313
|
+
await access(fullPath);
|
|
314
|
+
return await readFile(fullPath, "utf-8");
|
|
315
|
+
} catch {}
|
|
316
|
+
}
|
|
317
|
+
throw new Error(`Internal doc not found: ${location}`);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Fetch content from a URL (with gh CLI fallback on 403).
|
|
321
|
+
*/
|
|
322
|
+
async fetchUrlContent(url) {
|
|
323
|
+
const { content } = await fetchWithGhFallback(url);
|
|
324
|
+
return content;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Get the current state of the docs directory.
|
|
328
|
+
* Returns a set of relative paths that exist in .tbd/docs/.
|
|
329
|
+
*/
|
|
330
|
+
async getCurrentState() {
|
|
331
|
+
const paths = /* @__PURE__ */ new Set();
|
|
332
|
+
try {
|
|
333
|
+
await access(this.docsDir);
|
|
334
|
+
} catch {
|
|
335
|
+
return paths;
|
|
336
|
+
}
|
|
337
|
+
await this.scanDirectory(this.docsDir, "", paths);
|
|
338
|
+
return paths;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Recursively scan a directory and add all .md file paths to the set.
|
|
342
|
+
*/
|
|
343
|
+
async scanDirectory(baseDir, prefix, paths) {
|
|
344
|
+
try {
|
|
345
|
+
const dirEntries = await readdir(baseDir, { withFileTypes: true });
|
|
346
|
+
for (const entry of dirEntries) {
|
|
347
|
+
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
348
|
+
if (entry.isDirectory()) await this.scanDirectory(join(baseDir, entry.name), relativePath, paths);
|
|
349
|
+
else if (entry.isFile() && entry.name.endsWith(".md")) paths.add(relativePath);
|
|
350
|
+
}
|
|
351
|
+
} catch {}
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Sync all docs from config to .tbd/docs/.
|
|
355
|
+
*
|
|
356
|
+
* - Downloads/copies new docs
|
|
357
|
+
* - Updates docs whose source has changed
|
|
358
|
+
* - Removes docs no longer in config
|
|
359
|
+
*
|
|
360
|
+
* @param options - Sync options (dryRun, silent)
|
|
361
|
+
*/
|
|
362
|
+
async sync(options = {}) {
|
|
363
|
+
const result = {
|
|
364
|
+
sourceRevisions: {},
|
|
365
|
+
added: [],
|
|
366
|
+
updated: [],
|
|
367
|
+
removed: [],
|
|
368
|
+
errors: [],
|
|
369
|
+
success: true
|
|
370
|
+
};
|
|
371
|
+
const currentPaths = await this.getCurrentState();
|
|
372
|
+
const configPaths = new Set(Object.keys(this.config));
|
|
373
|
+
const groups = groupSourceEntries(this.config);
|
|
374
|
+
const failedGroups = /* @__PURE__ */ new Set();
|
|
375
|
+
for (const group of groups) if (group.gitSource) {
|
|
376
|
+
const revision = await captureGitRevision(group.gitSource);
|
|
377
|
+
if (revision) result.sourceRevisions[group.key] = revision;
|
|
378
|
+
}
|
|
379
|
+
for (const group of groups) for (const [destPath, sourceStr] of group.entries) {
|
|
380
|
+
if (failedGroups.has(group.key)) {
|
|
381
|
+
result.errors.push({
|
|
382
|
+
path: destPath,
|
|
383
|
+
error: `skipped: source group ${group.key} unreachable`
|
|
384
|
+
});
|
|
385
|
+
result.success = false;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
const source = this.parseSource(sourceStr);
|
|
390
|
+
const content = await this.fetchContent(source);
|
|
391
|
+
const fullPath = join(this.docsDir, destPath);
|
|
392
|
+
let exists = false;
|
|
393
|
+
let existingContent = "";
|
|
394
|
+
try {
|
|
395
|
+
existingContent = await readFile(fullPath, "utf-8");
|
|
396
|
+
exists = true;
|
|
397
|
+
} catch {}
|
|
398
|
+
if (!exists) {
|
|
399
|
+
if (!options.dryRun) {
|
|
400
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
401
|
+
await writeFile(fullPath, content);
|
|
402
|
+
}
|
|
403
|
+
result.added.push(destPath);
|
|
404
|
+
} else if (existingContent !== content) {
|
|
405
|
+
if (!options.dryRun) await writeFile(fullPath, content);
|
|
406
|
+
result.updated.push(destPath);
|
|
407
|
+
}
|
|
408
|
+
} catch (err) {
|
|
409
|
+
const message = err.message;
|
|
410
|
+
result.errors.push({
|
|
411
|
+
path: destPath,
|
|
412
|
+
error: message
|
|
413
|
+
});
|
|
414
|
+
result.success = false;
|
|
415
|
+
if (group.gitSource && /fetch|network|ENOTFOUND|ETIMEDOUT|ECONNREFUSED|HTTP/i.test(message)) failedGroups.add(group.key);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
for (const existingPath of currentPaths) if (!configPaths.has(existingPath)) try {
|
|
419
|
+
if (!options.dryRun) await rm(join(this.docsDir, existingPath));
|
|
420
|
+
result.removed.push(existingPath);
|
|
421
|
+
} catch (err) {
|
|
422
|
+
result.errors.push({
|
|
423
|
+
path: existingPath,
|
|
424
|
+
error: `Failed to remove: ${err.message}`
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
return result;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Get a status of what would change without actually making changes.
|
|
431
|
+
*/
|
|
432
|
+
async status() {
|
|
433
|
+
return this.sync({ dryRun: true });
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
/**
|
|
437
|
+
* Get base docs paths (with fallbacks for development).
|
|
438
|
+
* Matches the logic in setup.ts.
|
|
439
|
+
*/
|
|
440
|
+
function getDocsBasePath() {
|
|
441
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
442
|
+
return [join(__dirname, "docs"), join(__dirname, "..", "..", "docs")];
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Generate default doc_cache config by scanning bundled docs.
|
|
446
|
+
*
|
|
447
|
+
* This creates a config entry for each .md file found in the bundled
|
|
448
|
+
* docs directories (shortcuts, guidelines, templates).
|
|
449
|
+
*
|
|
450
|
+
* @returns A doc_cache config mapping destination paths to internal: sources
|
|
451
|
+
*/
|
|
452
|
+
async function generateDefaultDocCacheConfig() {
|
|
453
|
+
const config = {};
|
|
454
|
+
const basePaths = getDocsBasePath();
|
|
455
|
+
let docsDir = null;
|
|
456
|
+
for (const path of basePaths) try {
|
|
457
|
+
await access(path);
|
|
458
|
+
docsDir = path;
|
|
459
|
+
break;
|
|
460
|
+
} catch {}
|
|
461
|
+
if (!docsDir) return config;
|
|
462
|
+
const scanDirs = [
|
|
463
|
+
{
|
|
464
|
+
subdir: "shortcuts/system",
|
|
465
|
+
prefix: "shortcuts/system"
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
subdir: "shortcuts/standard",
|
|
469
|
+
prefix: "shortcuts/standard"
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
subdir: "guidelines",
|
|
473
|
+
prefix: "guidelines"
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
subdir: "templates",
|
|
477
|
+
prefix: "templates"
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
subdir: "references",
|
|
481
|
+
prefix: "references"
|
|
482
|
+
}
|
|
483
|
+
];
|
|
484
|
+
config[`references/tbd-docs.md`] = `${INTERNAL_SOURCE_PREFIX}tbd-docs.md`;
|
|
485
|
+
config[`references/tbd-design.md`] = `${INTERNAL_SOURCE_PREFIX}tbd-design.md`;
|
|
486
|
+
for (const { subdir, prefix } of scanDirs) {
|
|
487
|
+
const fullDir = join(docsDir, subdir);
|
|
488
|
+
try {
|
|
489
|
+
const entries = await readdir(fullDir, { withFileTypes: true });
|
|
490
|
+
for (const entry of entries) if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
491
|
+
const relativePath = `${prefix}/${entry.name}`;
|
|
492
|
+
config[relativePath] = `${INTERNAL_SOURCE_PREFIX}${relativePath}`;
|
|
493
|
+
}
|
|
494
|
+
} catch {}
|
|
495
|
+
}
|
|
496
|
+
return config;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Merge user's doc_cache config with default bundled docs.
|
|
500
|
+
*
|
|
501
|
+
* This ensures:
|
|
502
|
+
* - New bundled docs from tbd updates are added to existing configs
|
|
503
|
+
* - User's custom sources (URLs, etc.) are preserved
|
|
504
|
+
* - User's overrides of bundled docs are respected
|
|
505
|
+
*
|
|
506
|
+
* @param userConfig - The user's existing doc_cache config (may be undefined/empty)
|
|
507
|
+
* @param defaults - The default config from generateDefaultDocCacheConfig()
|
|
508
|
+
* @returns Merged config with defaults as base, user config overlaid
|
|
509
|
+
*/
|
|
510
|
+
function mergeDocCacheConfig(userConfig, defaults) {
|
|
511
|
+
return {
|
|
512
|
+
...defaults,
|
|
513
|
+
...userConfig
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Check if docs are stale based on last sync time and configured hours.
|
|
518
|
+
*
|
|
519
|
+
* @param lastSyncAt - ISO timestamp of last sync (or undefined if never synced)
|
|
520
|
+
* @param autoSyncHours - Hours between auto-syncs (0 = disabled)
|
|
521
|
+
* @returns true if docs should be synced
|
|
522
|
+
*/
|
|
523
|
+
function isDocsStale(lastSyncAt, autoSyncHours) {
|
|
524
|
+
if (autoSyncHours <= 0) return false;
|
|
525
|
+
if (!lastSyncAt) return true;
|
|
526
|
+
const lastSync = new Date(lastSyncAt).getTime();
|
|
527
|
+
return (Date.now() - lastSync) / (1e3 * 60 * 60) >= autoSyncHours;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Check if an internal bundled doc exists.
|
|
531
|
+
*
|
|
532
|
+
* @param location - The internal doc path (without 'internal:' prefix)
|
|
533
|
+
* @returns true if the doc exists in any of the bundled doc paths
|
|
534
|
+
*/
|
|
535
|
+
async function internalDocExists(location) {
|
|
536
|
+
const basePaths = getDocsBasePath();
|
|
537
|
+
for (const basePath of basePaths) {
|
|
538
|
+
const fullPath = join(basePath, location);
|
|
539
|
+
try {
|
|
540
|
+
await access(fullPath);
|
|
541
|
+
return true;
|
|
542
|
+
} catch {}
|
|
543
|
+
}
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Prune entries from config that point to non-existent internal sources.
|
|
548
|
+
*
|
|
549
|
+
* This handles the case where a bundled doc is removed in a tbd update -
|
|
550
|
+
* the stale config entry is automatically cleaned up.
|
|
551
|
+
*
|
|
552
|
+
* @param config - The doc_cache config to prune
|
|
553
|
+
* @returns Object with pruned config and list of removed entries
|
|
554
|
+
*/
|
|
555
|
+
async function pruneStaleInternals(config) {
|
|
556
|
+
const result = {};
|
|
557
|
+
const pruned = [];
|
|
558
|
+
for (const [dest, source] of Object.entries(config)) {
|
|
559
|
+
if (source.startsWith(INTERNAL_SOURCE_PREFIX)) {
|
|
560
|
+
if (!await internalDocExists(source.slice(9))) {
|
|
561
|
+
pruned.push(dest);
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
result[dest] = source;
|
|
566
|
+
}
|
|
567
|
+
return {
|
|
568
|
+
config: result,
|
|
569
|
+
pruned
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Deep equality check for config objects.
|
|
574
|
+
*/
|
|
575
|
+
function configsEqual(a, b) {
|
|
576
|
+
const keysA = Object.keys(a).sort();
|
|
577
|
+
const keysB = Object.keys(b).sort();
|
|
578
|
+
if (keysA.length !== keysB.length) return false;
|
|
579
|
+
for (const key of keysA) if (!Object.hasOwn(b, key) || a[key] !== b[key]) return false;
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Sync docs with merged defaults and auto-pruning.
|
|
584
|
+
*
|
|
585
|
+
* This is the single entry point for all doc sync operations that need
|
|
586
|
+
* to pick up new bundled docs from tbd upgrades.
|
|
587
|
+
*
|
|
588
|
+
* Steps:
|
|
589
|
+
* 1. Read current config
|
|
590
|
+
* 2. Generate defaults from bundled docs
|
|
591
|
+
* 3. Merge: defaults as base, user config overlays
|
|
592
|
+
* 4. Prune entries with missing internal sources
|
|
593
|
+
* 5. Sync files to .tbd/docs/
|
|
594
|
+
* 6. Write config if changed
|
|
595
|
+
* 7. Update last_doc_sync_at in state
|
|
596
|
+
*
|
|
597
|
+
* @param tbdRoot - The tbd project root directory
|
|
598
|
+
* @param options - Sync options (quiet, dryRun)
|
|
599
|
+
* @returns Sync result with added/updated/removed/pruned counts
|
|
600
|
+
*/
|
|
601
|
+
async function syncDocsWithDefaults(tbdRoot, options = {}) {
|
|
602
|
+
const config = await readConfig(tbdRoot);
|
|
603
|
+
const originalFiles = config.docs_cache?.files ?? {};
|
|
604
|
+
const { config: prunedConfig, pruned } = await pruneStaleInternals(mergeDocCacheConfig(originalFiles, await generateDefaultDocCacheConfig()));
|
|
605
|
+
const syncResult = await new DocSync(tbdRoot, prunedConfig).sync({ dryRun: options.dryRun });
|
|
606
|
+
const configChanged = !configsEqual(prunedConfig, originalFiles);
|
|
607
|
+
if (configChanged && !options.dryRun) {
|
|
608
|
+
const lookupPath = config.docs_cache?.lookup_path ?? [".tbd/docs/shortcuts/system", ".tbd/docs/shortcuts/standard"];
|
|
609
|
+
config.docs_cache = {
|
|
610
|
+
...config.docs_cache,
|
|
611
|
+
lookup_path: lookupPath,
|
|
612
|
+
files: prunedConfig
|
|
613
|
+
};
|
|
614
|
+
await writeConfig(tbdRoot, config);
|
|
615
|
+
}
|
|
616
|
+
if (!options.dryRun) await updateLocalState(tbdRoot, { last_doc_sync_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
617
|
+
return {
|
|
618
|
+
added: syncResult.added,
|
|
619
|
+
updated: syncResult.updated,
|
|
620
|
+
removed: syncResult.removed,
|
|
621
|
+
pruned,
|
|
622
|
+
configChanged,
|
|
623
|
+
errors: syncResult.errors,
|
|
624
|
+
success: syncResult.success
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
//#endregion
|
|
629
|
+
//#region src/lib/format-utils.ts
|
|
630
|
+
/**
|
|
631
|
+
* Human-readable formatting utilities for tbd CLI.
|
|
632
|
+
*
|
|
633
|
+
* Uses sindresorhus libraries for consistent, well-tested formatting:
|
|
634
|
+
* - pretty-bytes: Byte sizes (1337 -> "1.34 kB")
|
|
635
|
+
* - pretty-ms: Durations (3600000 -> "1h")
|
|
636
|
+
*/
|
|
637
|
+
/**
|
|
638
|
+
* Estimate token count from text content.
|
|
639
|
+
* Uses CHARS_PER_TOKEN (~3.5) for markdown/code content.
|
|
640
|
+
*/
|
|
641
|
+
function estimateTokens(text) {
|
|
642
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Format token count: "~1.2k tok" or "~450 tok"
|
|
646
|
+
*/
|
|
647
|
+
function formatTokens(tokens) {
|
|
648
|
+
if (tokens >= 1e3) return `~${(tokens / 1e3).toFixed(1)}k tok`;
|
|
649
|
+
return `~${tokens} tok`;
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Format doc size for list display: "(1.8 kB, ~450 tok)"
|
|
653
|
+
*/
|
|
654
|
+
function formatDocSize(sizeBytes, approxTokens) {
|
|
655
|
+
return `(${prettyBytes(sizeBytes)}, ${formatTokens(approxTokens)})`;
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Format relative time from Date: "2d ago", "3h ago", "5m ago"
|
|
659
|
+
* Uses compact format for concise display.
|
|
660
|
+
*/
|
|
661
|
+
function formatTimeAgo(date) {
|
|
662
|
+
const ms = Date.now() - date.getTime();
|
|
663
|
+
if (ms < 0) return "just now";
|
|
664
|
+
if (ms < 6e4) return "just now";
|
|
665
|
+
return `${prettyMs(ms, { compact: true })} ago`;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Format relative time from ISO timestamp string.
|
|
669
|
+
* Returns null if timestamp is invalid.
|
|
670
|
+
*/
|
|
671
|
+
function formatTimestampAgo(timestamp) {
|
|
672
|
+
if (!timestamp) return null;
|
|
673
|
+
const date = new Date(timestamp);
|
|
674
|
+
if (isNaN(date.getTime())) return null;
|
|
675
|
+
return formatTimeAgo(date);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
//#endregion
|
|
679
|
+
//#region src/file/doc-cache.ts
|
|
680
|
+
/**
|
|
681
|
+
* DocCache - Path-ordered markdown document cache with lookup.
|
|
682
|
+
*
|
|
683
|
+
* Provides document lookups for the `tbd shortcut` command, supporting
|
|
684
|
+
* both exact matching by filename and fuzzy matching against metadata.
|
|
685
|
+
*
|
|
686
|
+
* Also provides auto-sync functionality when docs are stale (per spec).
|
|
687
|
+
*
|
|
688
|
+
* See: docs/project/specs/active/plan-2026-01-22-doc-cache-abstraction.md
|
|
689
|
+
* See: docs/project/specs/active/plan-2026-01-26-configurable-doc-cache-sync.md
|
|
690
|
+
*/
|
|
691
|
+
/** Score for exact filename match (with or without .md extension) */
|
|
692
|
+
const SCORE_EXACT_MATCH = 1;
|
|
693
|
+
/** Score when query is a prefix of the filename */
|
|
694
|
+
const SCORE_PREFIX_MATCH = .9;
|
|
695
|
+
/** Score when filename contains all query words */
|
|
696
|
+
const SCORE_CONTAINS_ALL = .8;
|
|
697
|
+
/** Base score for partial word matches (multiplied by matched/total ratio) */
|
|
698
|
+
const SCORE_PARTIAL_BASE = .7;
|
|
699
|
+
/** Minimum score threshold to return a fuzzy match result */
|
|
700
|
+
const SCORE_MIN_THRESHOLD = .5;
|
|
701
|
+
/**
|
|
702
|
+
* Path-ordered markdown document cache.
|
|
703
|
+
*
|
|
704
|
+
* Loads all .md files from configured paths in order, with earlier paths
|
|
705
|
+
* taking precedence (like shell $PATH). Supports exact lookup by filename
|
|
706
|
+
* and fuzzy search across filename + frontmatter metadata.
|
|
707
|
+
*/
|
|
708
|
+
var DocCache = class {
|
|
709
|
+
/** Active docs (first occurrence of each name) */
|
|
710
|
+
docs = [];
|
|
711
|
+
/** All docs including shadowed ones */
|
|
712
|
+
allDocs = [];
|
|
713
|
+
/** Track names we've seen for shadow detection */
|
|
714
|
+
seenNames = /* @__PURE__ */ new Set();
|
|
715
|
+
/** Whether the cache has been loaded */
|
|
716
|
+
loaded = false;
|
|
717
|
+
/**
|
|
718
|
+
* Create a new DocCache.
|
|
719
|
+
*
|
|
720
|
+
* @param paths - Ordered array of directory paths to search (relative to baseDir)
|
|
721
|
+
* @param baseDir - Base directory for resolving relative paths (default: cwd)
|
|
722
|
+
*/
|
|
723
|
+
constructor(paths, baseDir = process.cwd()) {
|
|
724
|
+
this.paths = paths;
|
|
725
|
+
this.baseDir = baseDir;
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Load all documents from configured paths.
|
|
729
|
+
*
|
|
730
|
+
* Reads all .md files from each path in order. Documents with the same
|
|
731
|
+
* name in later paths are shadowed (tracked but not returned by default).
|
|
732
|
+
*
|
|
733
|
+
* If auto-sync is enabled and docs are stale, triggers a sync first.
|
|
734
|
+
*
|
|
735
|
+
* @param options - Load options (quiet: suppress auto-sync output)
|
|
736
|
+
*/
|
|
737
|
+
async load(options) {
|
|
738
|
+
if (this.loaded) return;
|
|
739
|
+
await this.checkAutoSync(options?.quiet ?? false);
|
|
740
|
+
for (const relativePath of this.paths) {
|
|
741
|
+
const dirPath = join(this.baseDir, relativePath);
|
|
742
|
+
await this.loadDirectory(dirPath, relativePath);
|
|
743
|
+
}
|
|
744
|
+
this.loaded = true;
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Check if docs are stale and auto-sync if needed.
|
|
748
|
+
* Respects the quiet option - only silent when explicitly requested.
|
|
749
|
+
*
|
|
750
|
+
* Uses syncDocsWithDefaults() to ensure auto-sync also picks up new bundled
|
|
751
|
+
* docs from tbd upgrades, not just existing config entries.
|
|
752
|
+
*
|
|
753
|
+
* @param quiet - If true, suppress sync output
|
|
754
|
+
*/
|
|
755
|
+
async checkAutoSync(quiet) {
|
|
756
|
+
try {
|
|
757
|
+
const tbdRoot = await findTbdRoot(this.baseDir);
|
|
758
|
+
if (!tbdRoot) return;
|
|
759
|
+
const config = await readConfig(tbdRoot);
|
|
760
|
+
const state = await readLocalState(tbdRoot);
|
|
761
|
+
const autoSyncHours = config.settings?.doc_auto_sync_hours ?? 24;
|
|
762
|
+
if (!isDocsStale(state.last_doc_sync_at, autoSyncHours)) return;
|
|
763
|
+
await syncDocsWithDefaults(tbdRoot, { quiet });
|
|
764
|
+
} catch {}
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Load documents from a single directory.
|
|
768
|
+
*/
|
|
769
|
+
async loadDirectory(dirPath, sourceDir) {
|
|
770
|
+
let entries;
|
|
771
|
+
try {
|
|
772
|
+
entries = await readdir(dirPath);
|
|
773
|
+
} catch {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
for (const entry of entries) {
|
|
777
|
+
if (!entry.endsWith(".md")) continue;
|
|
778
|
+
const filePath = join(dirPath, entry);
|
|
779
|
+
const name = basename(entry, ".md");
|
|
780
|
+
try {
|
|
781
|
+
const content = await readFile(filePath, "utf-8");
|
|
782
|
+
const doc = {
|
|
783
|
+
path: filePath,
|
|
784
|
+
name,
|
|
785
|
+
frontmatter: this.parseFrontmatterData(content),
|
|
786
|
+
content,
|
|
787
|
+
sourceDir,
|
|
788
|
+
sizeBytes: Buffer.byteLength(content, "utf-8"),
|
|
789
|
+
approxTokens: estimateTokens(content)
|
|
790
|
+
};
|
|
791
|
+
this.allDocs.push(doc);
|
|
792
|
+
if (!this.seenNames.has(name)) {
|
|
793
|
+
this.docs.push(doc);
|
|
794
|
+
this.seenNames.add(name);
|
|
795
|
+
}
|
|
796
|
+
} catch (error) {
|
|
797
|
+
console.warn(`Failed to load shortcut ${filePath}: ${error.message}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Parse YAML frontmatter from content and return typed data.
|
|
803
|
+
* Uses gray-matter for consistent frontmatter parsing.
|
|
804
|
+
*/
|
|
805
|
+
parseFrontmatterData(content) {
|
|
806
|
+
if (!matter.test(content)) return;
|
|
807
|
+
try {
|
|
808
|
+
const parsed = matter(content).data;
|
|
809
|
+
return {
|
|
810
|
+
title: typeof parsed.title === "string" ? parsed.title : void 0,
|
|
811
|
+
description: typeof parsed.description === "string" ? parsed.description : void 0,
|
|
812
|
+
category: typeof parsed.category === "string" ? parsed.category : void 0,
|
|
813
|
+
tags: Array.isArray(parsed.tags) ? parsed.tags.filter((t) => typeof t === "string") : void 0
|
|
814
|
+
};
|
|
815
|
+
} catch {
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Get a document by exact name match.
|
|
821
|
+
*
|
|
822
|
+
* @param name - Filename to match (with or without .md extension)
|
|
823
|
+
* @returns Match with score SCORE_EXACT_MATCH, or null if not found
|
|
824
|
+
*/
|
|
825
|
+
get(name) {
|
|
826
|
+
const lookupName = name.endsWith(".md") ? name.slice(0, -3) : name;
|
|
827
|
+
const doc = this.docs.find((d) => d.name === lookupName);
|
|
828
|
+
if (!doc) return null;
|
|
829
|
+
return {
|
|
830
|
+
doc,
|
|
831
|
+
score: SCORE_EXACT_MATCH
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Search for documents matching a query.
|
|
836
|
+
*
|
|
837
|
+
* Performs fuzzy matching against filename, title, and description.
|
|
838
|
+
* Returns matches sorted by score descending.
|
|
839
|
+
*
|
|
840
|
+
* @param query - Search query string
|
|
841
|
+
* @param limit - Maximum number of results (default: 10)
|
|
842
|
+
* @returns Array of matches sorted by score descending
|
|
843
|
+
*/
|
|
844
|
+
search(query, limit = 10) {
|
|
845
|
+
const matches = [];
|
|
846
|
+
for (const doc of this.docs) {
|
|
847
|
+
const score = this.calculateScore(doc, query);
|
|
848
|
+
if (score >= SCORE_MIN_THRESHOLD) matches.push({
|
|
849
|
+
doc,
|
|
850
|
+
score
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
matches.sort((a, b) => {
|
|
854
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
855
|
+
return a.doc.name.localeCompare(b.doc.name);
|
|
856
|
+
});
|
|
857
|
+
return matches.slice(0, limit);
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Calculate relevance score for a document against a query.
|
|
861
|
+
*/
|
|
862
|
+
calculateScore(doc, query) {
|
|
863
|
+
const queryLower = query.toLowerCase().trim();
|
|
864
|
+
if (queryLower.length === 0) return 0;
|
|
865
|
+
const nameLower = doc.name.toLowerCase();
|
|
866
|
+
const titleLower = doc.frontmatter?.title?.toLowerCase() ?? "";
|
|
867
|
+
const descLower = doc.frontmatter?.description?.toLowerCase() ?? "";
|
|
868
|
+
if (nameLower === queryLower) return SCORE_EXACT_MATCH;
|
|
869
|
+
if (nameLower.startsWith(queryLower)) return SCORE_PREFIX_MATCH;
|
|
870
|
+
const queryWords = queryLower.split(/\s+/).filter((w) => w.length > 0);
|
|
871
|
+
const searchableText = `${nameLower} ${titleLower} ${descLower}`;
|
|
872
|
+
if (queryWords.every((word) => searchableText.includes(word)) && queryWords.length > 0) return SCORE_CONTAINS_ALL;
|
|
873
|
+
const matchedWords = queryWords.filter((word) => searchableText.includes(word));
|
|
874
|
+
if (matchedWords.length > 0) return SCORE_PARTIAL_BASE * (matchedWords.length / queryWords.length);
|
|
875
|
+
return 0;
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* List all documents.
|
|
879
|
+
*
|
|
880
|
+
* @param includeAll - If true, include shadowed documents
|
|
881
|
+
* @returns Array of cached documents
|
|
882
|
+
*/
|
|
883
|
+
list(includeAll = false) {
|
|
884
|
+
return includeAll ? this.allDocs : this.docs;
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Check if a document is shadowed by an earlier path.
|
|
888
|
+
*
|
|
889
|
+
* @param doc - Document to check
|
|
890
|
+
* @returns True if this doc is shadowed (not the first with this name)
|
|
891
|
+
*/
|
|
892
|
+
isShadowed(doc) {
|
|
893
|
+
return this.docs.find((d) => d.name === doc.name) !== doc;
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Check if the cache has been loaded.
|
|
897
|
+
*/
|
|
898
|
+
isLoaded() {
|
|
899
|
+
return this.loaded;
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
/**
|
|
903
|
+
* Marker comments for shortcut directory section in skill files.
|
|
904
|
+
* Used for incremental updates without overwriting user content.
|
|
905
|
+
*/
|
|
906
|
+
const SHORTCUT_DIRECTORY_BEGIN = "<!-- BEGIN SHORTCUT DIRECTORY -->";
|
|
907
|
+
const SHORTCUT_DIRECTORY_END = "<!-- END SHORTCUT DIRECTORY -->";
|
|
908
|
+
const GUIDELINE_GROUPS = [
|
|
909
|
+
{
|
|
910
|
+
heading: "General engineering",
|
|
911
|
+
note: "Read all of these for any engineering work (writing or reviewing code).",
|
|
912
|
+
match: (n) => n.startsWith("general-") || n === "error-handling-rules" || n === "backward-compatibility-rules" || n === "commit-conventions" || n.includes("tdd") || n.includes("testing") || n.includes("golden")
|
|
913
|
+
},
|
|
914
|
+
{
|
|
915
|
+
heading: "TypeScript & JS ecosystem",
|
|
916
|
+
note: "Also load these when working in TypeScript or JavaScript.",
|
|
917
|
+
match: (n) => n.startsWith("typescript-") || n.endsWith("monorepo-patterns") || n.startsWith("electron-")
|
|
918
|
+
},
|
|
919
|
+
{
|
|
920
|
+
heading: "Python",
|
|
921
|
+
note: "Also load these when working in Python.",
|
|
922
|
+
match: (n) => n.startsWith("python-")
|
|
923
|
+
},
|
|
924
|
+
{
|
|
925
|
+
heading: "Convex",
|
|
926
|
+
note: "Also load these when working with Convex.",
|
|
927
|
+
match: (n) => n.startsWith("convex-")
|
|
928
|
+
},
|
|
929
|
+
{
|
|
930
|
+
heading: "Docs, process & tooling",
|
|
931
|
+
match: () => true
|
|
932
|
+
}
|
|
933
|
+
];
|
|
934
|
+
/**
|
|
935
|
+
* Build table rows from docs (shared helper for shortcuts and guidelines).
|
|
936
|
+
*/
|
|
937
|
+
function buildTableRows(docs, skipNames = []) {
|
|
938
|
+
const sortedDocs = [...docs].sort((a, b) => a.name.localeCompare(b.name));
|
|
939
|
+
const rows = [];
|
|
940
|
+
for (const doc of sortedDocs) {
|
|
941
|
+
if (skipNames.includes(doc.name)) continue;
|
|
942
|
+
const name = doc.name;
|
|
943
|
+
const escapedDescription = (doc.frontmatter?.description ?? "").replace(/\|/g, "\\|");
|
|
944
|
+
rows.push(`| ${name} | ${escapedDescription} |`);
|
|
945
|
+
}
|
|
946
|
+
return rows;
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Generate a formatted markdown directory of shortcuts and guidelines.
|
|
950
|
+
*
|
|
951
|
+
* The output includes:
|
|
952
|
+
* 1. Marker comments for incremental updates
|
|
953
|
+
* 2. Available Shortcuts section with name and description
|
|
954
|
+
* 3. Available Guidelines section, grouped by area (General engineering first, then
|
|
955
|
+
* language/framework groups), with name and description (if provided)
|
|
956
|
+
*
|
|
957
|
+
* @param shortcuts - Array of shortcut CachedDoc objects
|
|
958
|
+
* @param guidelines - Optional array of guideline CachedDoc objects
|
|
959
|
+
* @returns Formatted markdown string with shortcuts and guidelines directory
|
|
960
|
+
*
|
|
961
|
+
* @example
|
|
962
|
+
* const directory = generateShortcutDirectory(shortcutDocs, guidelineDocs);
|
|
963
|
+
* // Returns:
|
|
964
|
+
* // <!-- BEGIN SHORTCUT DIRECTORY -->
|
|
965
|
+
* // ## Available Shortcuts
|
|
966
|
+
* // | Name | Description |
|
|
967
|
+
* // | --- | --- |
|
|
968
|
+
* // | code-review-and-commit | Run pre-commit checks, review changes, and commit code |
|
|
969
|
+
* // ...
|
|
970
|
+
* // ## Available Guidelines
|
|
971
|
+
* // ### General engineering
|
|
972
|
+
* // | Name | Description |
|
|
973
|
+
* // | --- | --- |
|
|
974
|
+
* // | error-handling-rules | Rules for handling errors... |
|
|
975
|
+
* // ...
|
|
976
|
+
* // ### TypeScript & JS ecosystem
|
|
977
|
+
* // | typescript-rules | TypeScript coding rules and best practices |
|
|
978
|
+
* // ...
|
|
979
|
+
* // <!-- END SHORTCUT DIRECTORY -->
|
|
980
|
+
*/
|
|
981
|
+
function generateShortcutDirectory(shortcuts, guidelines = []) {
|
|
982
|
+
const lines = [SHORTCUT_DIRECTORY_BEGIN];
|
|
983
|
+
const shortcutRows = buildTableRows(shortcuts, [
|
|
984
|
+
"skill-baseline",
|
|
985
|
+
"skill-brief",
|
|
986
|
+
"skill-minimal",
|
|
987
|
+
"shortcut-explanation"
|
|
988
|
+
]);
|
|
989
|
+
lines.push("## Available Shortcuts");
|
|
990
|
+
lines.push("");
|
|
991
|
+
lines.push("Run `tbd shortcut <name>` to use any of these shortcuts:");
|
|
992
|
+
lines.push("");
|
|
993
|
+
if (shortcutRows.length === 0) lines.push("No shortcuts available. Create shortcuts in `.tbd/docs/shortcuts/standard/`.");
|
|
994
|
+
else {
|
|
995
|
+
lines.push("| Name | Description |");
|
|
996
|
+
lines.push("| --- | --- |");
|
|
997
|
+
lines.push(...shortcutRows);
|
|
998
|
+
}
|
|
999
|
+
if (guidelines.length > 0) {
|
|
1000
|
+
const grouped = GUIDELINE_GROUPS.map((group) => ({
|
|
1001
|
+
group,
|
|
1002
|
+
docs: []
|
|
1003
|
+
}));
|
|
1004
|
+
const catchAll = grouped[grouped.length - 1];
|
|
1005
|
+
for (const doc of guidelines) (grouped.find((g) => g.group.match(doc.name)) ?? catchAll)?.docs.push(doc);
|
|
1006
|
+
if (grouped.some((g) => g.docs.length > 0)) {
|
|
1007
|
+
lines.push("");
|
|
1008
|
+
lines.push("## Available Guidelines");
|
|
1009
|
+
lines.push("");
|
|
1010
|
+
lines.push("Run `tbd guidelines <name>` to apply any of these guidelines.");
|
|
1011
|
+
lines.push("Load the **General engineering** group first, then the language or framework group.");
|
|
1012
|
+
for (const { group, docs } of grouped) {
|
|
1013
|
+
const rows = buildTableRows(docs);
|
|
1014
|
+
if (rows.length === 0) continue;
|
|
1015
|
+
lines.push("");
|
|
1016
|
+
lines.push(`### ${group.heading}`);
|
|
1017
|
+
if (group.note) {
|
|
1018
|
+
lines.push("");
|
|
1019
|
+
lines.push(`*${group.note}*`);
|
|
1020
|
+
}
|
|
1021
|
+
lines.push("");
|
|
1022
|
+
lines.push("| Name | Description |");
|
|
1023
|
+
lines.push("| --- | --- |");
|
|
1024
|
+
lines.push(...rows);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
lines.push("");
|
|
1029
|
+
lines.push(SHORTCUT_DIRECTORY_END);
|
|
1030
|
+
return lines.join("\n");
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
//#endregion
|
|
1034
|
+
export { SCORE_PARTIAL_BASE as a, formatDocSize as c, fetchWithGhFallback as d, gitDocRefToRawUrl as f, SCORE_MIN_THRESHOLD as i, formatTimestampAgo as l, SCORE_CONTAINS_ALL as n, SCORE_PREFIX_MATCH as o, SCORE_EXACT_MATCH as r, generateShortcutDirectory as s, DocCache as t, syncDocsWithDefaults as u };
|
|
1035
|
+
//# sourceMappingURL=doc-cache-CamtXfi4.mjs.map
|