@thallylabs/mcp 0.7.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/LICENSE +21 -0
- package/README.md +44 -0
- package/dist/index.js +2393 -0
- package/dist/tools.d.ts +25 -0
- package/dist/tools.js +2354 -0
- package/dist/track.d.ts +140 -0
- package/dist/track.js +219 -0
- package/package.json +69 -0
package/dist/tools.js
ADDED
|
@@ -0,0 +1,2354 @@
|
|
|
1
|
+
// src/tools/create-project.ts
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
// src/lib/scaffold.ts
|
|
5
|
+
import { existsSync, mkdirSync, readdirSync, writeFileSync, readFileSync, cpSync } from "fs";
|
|
6
|
+
import { resolve, join } from "path";
|
|
7
|
+
import { execSync } from "child_process";
|
|
8
|
+
import { Readable, pipeline } from "stream";
|
|
9
|
+
import { promisify } from "util";
|
|
10
|
+
import tar from "tar";
|
|
11
|
+
var pipelineAsync = promisify(pipeline);
|
|
12
|
+
var TARBALL_URL = "https://codeload.github.com/thallylabs/thally/tar.gz/main";
|
|
13
|
+
var EXCLUDE_PATHS = ["/cli/", "/packages/", "/node_modules/", "/.git/"];
|
|
14
|
+
var STARTER_PAGES = {
|
|
15
|
+
"introduction.mdx": `---
|
|
16
|
+
title: Introduction
|
|
17
|
+
description: Welcome to {NAME} documentation.
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Welcome
|
|
21
|
+
|
|
22
|
+
This is the home page of your **{NAME}** documentation site, powered by [Thally](https://github.com/thallylabs/thally).
|
|
23
|
+
|
|
24
|
+
Get started by editing this file at \`src/content/introduction.mdx\`.
|
|
25
|
+
`,
|
|
26
|
+
"quickstart.mdx": `---
|
|
27
|
+
title: Quickstart
|
|
28
|
+
description: Get up and running with {NAME} in under 5 minutes.
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
\`\`\`bash
|
|
34
|
+
npm install {SLUG}
|
|
35
|
+
\`\`\`
|
|
36
|
+
|
|
37
|
+
## Basic usage
|
|
38
|
+
|
|
39
|
+
\`\`\`ts
|
|
40
|
+
import { create } from '{SLUG}'
|
|
41
|
+
|
|
42
|
+
const client = create({ apiKey: 'your-api-key' })
|
|
43
|
+
\`\`\`
|
|
44
|
+
|
|
45
|
+
That's it \u2014 you're ready to go!
|
|
46
|
+
`
|
|
47
|
+
};
|
|
48
|
+
function buildStarterDocsJson({
|
|
49
|
+
enableAiChat,
|
|
50
|
+
repoUrl,
|
|
51
|
+
i18nLocales
|
|
52
|
+
}) {
|
|
53
|
+
const config = {};
|
|
54
|
+
if (enableAiChat) {
|
|
55
|
+
config.ai = { chat: true };
|
|
56
|
+
}
|
|
57
|
+
if (repoUrl) {
|
|
58
|
+
config.navbar = {
|
|
59
|
+
links: [{ label: "GitHub", href: repoUrl, type: "github" }],
|
|
60
|
+
primary: { label: "Get started", href: "/quickstart" }
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (i18nLocales && i18nLocales.length > 0) {
|
|
64
|
+
config.i18n = {
|
|
65
|
+
defaultLocale: "en",
|
|
66
|
+
locales: [{ code: "en", label: "English" }, ...i18nLocales]
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
config.tabs = [
|
|
70
|
+
{
|
|
71
|
+
tab: "Overview",
|
|
72
|
+
groups: [{ group: "Getting Started", pages: ["introduction", "quickstart"] }]
|
|
73
|
+
},
|
|
74
|
+
{ tab: "API Reference", api: { source: "openapi.yaml" } },
|
|
75
|
+
{ tab: "Changelog", href: "/changelog" }
|
|
76
|
+
];
|
|
77
|
+
return JSON.stringify(config, null, 2) + "\n";
|
|
78
|
+
}
|
|
79
|
+
function slugify(name) {
|
|
80
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
81
|
+
}
|
|
82
|
+
function run(cmd, cwd) {
|
|
83
|
+
execSync(cmd, { cwd, stdio: "inherit" });
|
|
84
|
+
}
|
|
85
|
+
async function downloadTemplate(targetDir) {
|
|
86
|
+
const response = await fetch(TARBALL_URL);
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
throw new Error(`Failed to download template: ${response.status} ${response.statusText}`);
|
|
89
|
+
}
|
|
90
|
+
if (!response.body) {
|
|
91
|
+
throw new Error("Response body is empty");
|
|
92
|
+
}
|
|
93
|
+
const nodeStream = Readable.fromWeb(response.body);
|
|
94
|
+
await pipelineAsync(
|
|
95
|
+
nodeStream,
|
|
96
|
+
tar.extract({
|
|
97
|
+
cwd: targetDir,
|
|
98
|
+
strip: 1,
|
|
99
|
+
filter: (path) => {
|
|
100
|
+
for (const excluded of EXCLUDE_PATHS) {
|
|
101
|
+
if (path.includes(excluded)) return false;
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
function writeStarterContent(targetDir, projectName, slug, enableAiChat = true, repoUrl = "", i18nLocales) {
|
|
109
|
+
const contentDir = join(targetDir, "src", "content");
|
|
110
|
+
if (existsSync(contentDir)) {
|
|
111
|
+
const entries = readdirSync(contentDir);
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
execSync(`rm -rf "${join(contentDir, entry)}"`);
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
mkdirSync(contentDir, { recursive: true });
|
|
117
|
+
}
|
|
118
|
+
for (const [filename, template] of Object.entries(STARTER_PAGES)) {
|
|
119
|
+
const content = template.replace(/\{NAME\}/g, projectName).replace(/\{SLUG\}/g, slug);
|
|
120
|
+
writeFileSync(join(contentDir, filename), content, "utf8");
|
|
121
|
+
}
|
|
122
|
+
writeFileSync(
|
|
123
|
+
join(targetDir, "docs.json"),
|
|
124
|
+
buildStarterDocsJson({ enableAiChat, repoUrl: repoUrl || void 0, i18nLocales }),
|
|
125
|
+
"utf8"
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
function updateSiteConfig(targetDir, projectName, description, brandPreset, repoUrl) {
|
|
129
|
+
const siteFile = join(targetDir, "src", "data", "site.ts");
|
|
130
|
+
if (!existsSync(siteFile)) return;
|
|
131
|
+
let source = readFileSync(siteFile, "utf8");
|
|
132
|
+
source = source.replace(/name:\s*'[^']*'/, `name: '${projectName.replace(/'/g, "\\'")}'`);
|
|
133
|
+
source = source.replace(
|
|
134
|
+
/description:\s*\n\s*'[^']*'/,
|
|
135
|
+
`description:
|
|
136
|
+
'${description.replace(/'/g, "\\'")}'`
|
|
137
|
+
);
|
|
138
|
+
source = source.replace(
|
|
139
|
+
/const brandPreset:\s*BrandPresetKey\s*=\s*'[^']*'/,
|
|
140
|
+
`const brandPreset: BrandPresetKey = '${brandPreset}'`
|
|
141
|
+
);
|
|
142
|
+
if (repoUrl) {
|
|
143
|
+
source = source.replace(/repoUrl:\s*'[^']*'/, `repoUrl: '${repoUrl}'`);
|
|
144
|
+
source = source.replace(
|
|
145
|
+
/\{\s*label:\s*'GitHub',\s*href:\s*'[^']*'\s*\}/,
|
|
146
|
+
`{ label: 'GitHub', href: '${repoUrl}' }`
|
|
147
|
+
);
|
|
148
|
+
source = source.replace(
|
|
149
|
+
/\{\s*label:\s*'Support',\s*href:\s*'[^']*'\s*\}/,
|
|
150
|
+
`{ label: 'Support', href: '${repoUrl}/issues/new' }`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
writeFileSync(siteFile, source, "utf8");
|
|
154
|
+
}
|
|
155
|
+
function patchTopBarNavigation(targetDir) {
|
|
156
|
+
const filePath = join(targetDir, "src", "components", "layout", "top-bar.tsx");
|
|
157
|
+
if (!existsSync(filePath)) return;
|
|
158
|
+
const source = readFileSync(filePath, "utf8");
|
|
159
|
+
if (!source.includes("target={isExternal ? '_blank' : undefined}")) return;
|
|
160
|
+
const patched = source.replace(
|
|
161
|
+
/if \(collection\.href\) \{\n const isExternal[^\n]+\n return \(\n <a[\s\S]*?<\/a>\n \)\n \}/,
|
|
162
|
+
`if (collection.href) {
|
|
163
|
+
const isExternal = /^https?:\\/\\//.test(collection.href)
|
|
164
|
+
if (isExternal) {
|
|
165
|
+
return (
|
|
166
|
+
<a
|
|
167
|
+
key={collection.id}
|
|
168
|
+
href={collection.href}
|
|
169
|
+
target="_blank"
|
|
170
|
+
rel="noreferrer"
|
|
171
|
+
className={baseClasses}
|
|
172
|
+
>
|
|
173
|
+
{collection.label}
|
|
174
|
+
</a>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
return (
|
|
178
|
+
<Link
|
|
179
|
+
key={collection.id}
|
|
180
|
+
href={collection.href}
|
|
181
|
+
className={baseClasses}
|
|
182
|
+
>
|
|
183
|
+
{collection.label}
|
|
184
|
+
</Link>
|
|
185
|
+
)
|
|
186
|
+
}`
|
|
187
|
+
);
|
|
188
|
+
writeFileSync(filePath, patched, "utf8");
|
|
189
|
+
}
|
|
190
|
+
function patchApiReferenceGuard(targetDir) {
|
|
191
|
+
const filePath = join(targetDir, "src", "data", "api-reference.ts");
|
|
192
|
+
if (!existsSync(filePath)) return;
|
|
193
|
+
let source = readFileSync(filePath, "utf8");
|
|
194
|
+
source = source.replace(
|
|
195
|
+
/export async function buildApiNavigation\([^)]*\)[^{]*\{\n/,
|
|
196
|
+
(match) => `${match} if (apiReferenceConfig.specs.length === 0) return []
|
|
197
|
+
`
|
|
198
|
+
);
|
|
199
|
+
writeFileSync(filePath, source, "utf8");
|
|
200
|
+
}
|
|
201
|
+
function patchOpenApiFetch(targetDir) {
|
|
202
|
+
const filePath = join(targetDir, "src", "lib", "openapi", "fetch.ts");
|
|
203
|
+
if (!existsSync(filePath)) return;
|
|
204
|
+
let source = readFileSync(filePath, "utf8");
|
|
205
|
+
source = source.replace(
|
|
206
|
+
/const absolutePath = path\.isAbsolute\(filePath\) \? filePath : path\.resolve\(process\.cwd\(\), filePath\)/,
|
|
207
|
+
`const absolutePath = filePath.startsWith('/')
|
|
208
|
+
? path.resolve(process.cwd(), 'public', filePath.slice(1))
|
|
209
|
+
: path.resolve(process.cwd(), filePath)`
|
|
210
|
+
);
|
|
211
|
+
writeFileSync(filePath, source, "utf8");
|
|
212
|
+
}
|
|
213
|
+
function updateEnvExample(targetDir) {
|
|
214
|
+
const envFile = join(targetDir, ".env.example");
|
|
215
|
+
if (existsSync(envFile)) {
|
|
216
|
+
const envLocal = join(targetDir, ".env.local");
|
|
217
|
+
if (!existsSync(envLocal)) cpSync(envFile, envLocal);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function installDeps(targetDir) {
|
|
221
|
+
run("npm install", targetDir);
|
|
222
|
+
}
|
|
223
|
+
function initGit(targetDir) {
|
|
224
|
+
try {
|
|
225
|
+
run("git init", targetDir);
|
|
226
|
+
run("git add -A", targetDir);
|
|
227
|
+
run('git commit -m "Initial commit from create-thally-docs"', targetDir);
|
|
228
|
+
} catch {
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async function scaffold(options) {
|
|
232
|
+
const { projectDir, projectName, description, brandPreset, repoUrl, doInstall, enableAiChat = true, i18nLocales } = options;
|
|
233
|
+
const targetDir = resolve(projectDir);
|
|
234
|
+
if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {
|
|
235
|
+
throw new Error(`Directory "${targetDir}" already exists and is not empty.`);
|
|
236
|
+
}
|
|
237
|
+
mkdirSync(targetDir, { recursive: true });
|
|
238
|
+
const slug = slugify(projectName);
|
|
239
|
+
await downloadTemplate(targetDir);
|
|
240
|
+
writeStarterContent(targetDir, projectName, slug, enableAiChat, repoUrl, i18nLocales);
|
|
241
|
+
updateSiteConfig(targetDir, projectName, description, brandPreset, repoUrl);
|
|
242
|
+
patchApiReferenceGuard(targetDir);
|
|
243
|
+
patchTopBarNavigation(targetDir);
|
|
244
|
+
patchOpenApiFetch(targetDir);
|
|
245
|
+
updateEnvExample(targetDir);
|
|
246
|
+
if (doInstall) installDeps(targetDir);
|
|
247
|
+
initGit(targetDir);
|
|
248
|
+
return { projectDir: targetDir };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// src/tools/create-project.ts
|
|
252
|
+
var createProjectSchema = z.object({
|
|
253
|
+
projectDir: z.string().describe("Path where the new Thally project should be created"),
|
|
254
|
+
projectName: z.string().optional().describe("Display name of the project (defaults to directory name)"),
|
|
255
|
+
description: z.string().optional().describe("Short description of the project"),
|
|
256
|
+
brandPreset: z.enum(["primary", "secondary"]).optional().default("primary").describe("Brand color preset"),
|
|
257
|
+
repoUrl: z.string().optional().describe("GitHub repository URL (optional)"),
|
|
258
|
+
install: z.boolean().optional().default(true).describe("Whether to run npm install after scaffolding"),
|
|
259
|
+
enableAiChat: z.boolean().optional().default(true).describe("Enable AI chat in docs.json (default true)"),
|
|
260
|
+
i18nLocales: z.array(z.object({ code: z.string(), label: z.string() })).optional().describe('Secondary locales to enable (e.g. [{code:"es",label:"Espa\xF1ol"}])')
|
|
261
|
+
});
|
|
262
|
+
async function handleCreateProject(input) {
|
|
263
|
+
const { projectDir, brandPreset = "primary", install = true } = input;
|
|
264
|
+
const dirBase = projectDir.split("/").filter(Boolean).pop() ?? "my-docs";
|
|
265
|
+
const projectName = input.projectName ?? dirBase.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
266
|
+
const description = input.description ?? `Documentation for ${projectName}.`;
|
|
267
|
+
const repoUrl = input.repoUrl ?? "";
|
|
268
|
+
const result = await scaffold({
|
|
269
|
+
projectDir,
|
|
270
|
+
projectName,
|
|
271
|
+
description,
|
|
272
|
+
brandPreset,
|
|
273
|
+
repoUrl,
|
|
274
|
+
doInstall: install,
|
|
275
|
+
enableAiChat: input.enableAiChat ?? true,
|
|
276
|
+
i18nLocales: input.i18nLocales
|
|
277
|
+
});
|
|
278
|
+
const dirName = result.projectDir.split("/").pop() ?? projectDir;
|
|
279
|
+
return [
|
|
280
|
+
`\u2705 Thally project "${projectName}" created at: ${result.projectDir}`,
|
|
281
|
+
"",
|
|
282
|
+
"Next steps:",
|
|
283
|
+
` cd ${dirName}`,
|
|
284
|
+
" npm run dev",
|
|
285
|
+
"",
|
|
286
|
+
"Then open http://localhost:3040 to see your docs.",
|
|
287
|
+
"",
|
|
288
|
+
"Key files to edit:",
|
|
289
|
+
" \u2022 src/data/site.ts \u2014 name, links, branding",
|
|
290
|
+
" \u2022 docs.json \u2014 navigation, AI chat config",
|
|
291
|
+
" \u2022 src/content/*.mdx \u2014 your documentation",
|
|
292
|
+
"",
|
|
293
|
+
...input.enableAiChat !== false ? ["\u{1F916} AI chat is enabled. Set ANTHROPIC_API_KEY in .env.local."] : []
|
|
294
|
+
].join("\n");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/tools/add-page.ts
|
|
298
|
+
import { z as z2 } from "zod";
|
|
299
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
300
|
+
import { join as join3, dirname } from "path";
|
|
301
|
+
|
|
302
|
+
// src/lib/docs-json.ts
|
|
303
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
304
|
+
import { join as join2 } from "path";
|
|
305
|
+
function readDocsJson(projectDir) {
|
|
306
|
+
const docsPath = join2(projectDir, "docs.json");
|
|
307
|
+
const raw = readFileSync2(docsPath, "utf8");
|
|
308
|
+
return JSON.parse(raw);
|
|
309
|
+
}
|
|
310
|
+
function writeDocsJson(projectDir, config) {
|
|
311
|
+
const docsPath = join2(projectDir, "docs.json");
|
|
312
|
+
writeFileSync2(docsPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// src/tools/add-page.ts
|
|
316
|
+
var addPageSchema = z2.object({
|
|
317
|
+
projectDir: z2.string().describe("Path to the Thally project root"),
|
|
318
|
+
pageId: z2.string().describe('Page identifier (e.g. "guides/auth"). No .mdx extension.'),
|
|
319
|
+
title: z2.string().describe("Page title (used in frontmatter)"),
|
|
320
|
+
description: z2.string().optional().describe("Page description (used in frontmatter)"),
|
|
321
|
+
content: z2.string().optional().describe("MDX body content (placeholder used if omitted)"),
|
|
322
|
+
tab: z2.string().optional().describe("Tab name to add the page to (defaults to first tab)"),
|
|
323
|
+
group: z2.string().optional().describe("Group name within the tab (defaults to first group)"),
|
|
324
|
+
position: z2.enum(["start", "end"]).optional().default("end").describe("Whether to insert at start or end of the group")
|
|
325
|
+
});
|
|
326
|
+
function isString(value) {
|
|
327
|
+
return typeof value === "string";
|
|
328
|
+
}
|
|
329
|
+
async function handleAddPage(input) {
|
|
330
|
+
const { projectDir, pageId, title, description, content, position = "end" } = input;
|
|
331
|
+
if (!/^[a-zA-Z0-9\-/]+$/.test(pageId)) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`Invalid pageId "${pageId}". Use only alphanumeric characters, hyphens, and slashes. Do not include .mdx extension.`
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
const mdxPath = join3(projectDir, "src", "content", `${pageId}.mdx`);
|
|
337
|
+
if (existsSync2(mdxPath)) {
|
|
338
|
+
throw new Error(`Page already exists at: ${mdxPath}`);
|
|
339
|
+
}
|
|
340
|
+
mkdirSync2(dirname(mdxPath), { recursive: true });
|
|
341
|
+
const frontmatterLines = [`title: ${title}`];
|
|
342
|
+
if (description) {
|
|
343
|
+
frontmatterLines.push(`description: ${description}`);
|
|
344
|
+
}
|
|
345
|
+
const bodyContent = content ?? `## ${title}
|
|
346
|
+
|
|
347
|
+
Add your content here.`;
|
|
348
|
+
const mdxContent = `---
|
|
349
|
+
${frontmatterLines.join("\n")}
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
${bodyContent}
|
|
353
|
+
`;
|
|
354
|
+
writeFileSync3(mdxPath, mdxContent, "utf8");
|
|
355
|
+
const config = readDocsJson(projectDir);
|
|
356
|
+
let targetTab = config.tabs[0];
|
|
357
|
+
if (input.tab) {
|
|
358
|
+
const found = config.tabs.find((t) => t.tab === input.tab);
|
|
359
|
+
if (found) {
|
|
360
|
+
targetTab = found;
|
|
361
|
+
} else {
|
|
362
|
+
const newTab = { tab: input.tab, groups: [] };
|
|
363
|
+
config.tabs.push(newTab);
|
|
364
|
+
targetTab = newTab;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (!targetTab.groups) {
|
|
368
|
+
targetTab.groups = [];
|
|
369
|
+
}
|
|
370
|
+
const groupName = input.group ?? (targetTab.groups[0]?.group ?? "General");
|
|
371
|
+
let targetGroup = targetTab.groups.find((g) => g.group === groupName);
|
|
372
|
+
if (!targetGroup) {
|
|
373
|
+
const newGroup = { group: groupName, pages: [] };
|
|
374
|
+
targetTab.groups.push(newGroup);
|
|
375
|
+
targetGroup = newGroup;
|
|
376
|
+
}
|
|
377
|
+
const existingStringPages = targetGroup.pages.filter(isString);
|
|
378
|
+
if (existingStringPages.includes(pageId)) {
|
|
379
|
+
throw new Error(`Page "${pageId}" already exists in group "${groupName}".`);
|
|
380
|
+
}
|
|
381
|
+
if (position === "start") {
|
|
382
|
+
targetGroup.pages.unshift(pageId);
|
|
383
|
+
} else {
|
|
384
|
+
targetGroup.pages.push(pageId);
|
|
385
|
+
}
|
|
386
|
+
writeDocsJson(projectDir, config);
|
|
387
|
+
return [
|
|
388
|
+
`\u2705 Page created: ${mdxPath}`,
|
|
389
|
+
` pageId: ${pageId}`,
|
|
390
|
+
` tab: ${targetTab.tab}`,
|
|
391
|
+
` group: ${groupName}`,
|
|
392
|
+
` position: ${position}`
|
|
393
|
+
].join("\n");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// src/tools/add-tab.ts
|
|
397
|
+
import { z as z3 } from "zod";
|
|
398
|
+
var addTabSchema = z3.object({
|
|
399
|
+
projectDir: z3.string().describe("Path to the Thally project root"),
|
|
400
|
+
tabName: z3.string().describe('Display name for the new tab (e.g. "Guides", "API Reference")'),
|
|
401
|
+
href: z3.string().optional().describe('If set, the tab is a redirect link instead of a content tab (e.g. "/changelog")'),
|
|
402
|
+
position: z3.enum(["start", "end"]).optional().default("end").describe("Insert the tab at the start or end of the tab bar")
|
|
403
|
+
});
|
|
404
|
+
async function handleAddTab(input) {
|
|
405
|
+
const { projectDir, tabName, href, position = "end" } = input;
|
|
406
|
+
const config = readDocsJson(projectDir);
|
|
407
|
+
const existing = config.tabs.find((t) => t.tab === tabName);
|
|
408
|
+
if (existing) {
|
|
409
|
+
throw new Error(`Tab "${tabName}" already exists in docs.json.`);
|
|
410
|
+
}
|
|
411
|
+
const newTab = href ? { tab: tabName, href } : { tab: tabName, groups: [] };
|
|
412
|
+
if (position === "start") {
|
|
413
|
+
config.tabs.unshift(newTab);
|
|
414
|
+
} else {
|
|
415
|
+
config.tabs.push(newTab);
|
|
416
|
+
}
|
|
417
|
+
writeDocsJson(projectDir, config);
|
|
418
|
+
const kind = href ? `redirect \u2192 ${href}` : "content tab (empty, ready for pages)";
|
|
419
|
+
return [
|
|
420
|
+
`\u2705 Tab "${tabName}" added to docs.json`,
|
|
421
|
+
` kind: ${kind}`,
|
|
422
|
+
` position: ${position}`,
|
|
423
|
+
...href ? [] : [``, `Next: add pages with add_page using tab: "${tabName}"`]
|
|
424
|
+
].join("\n");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// src/tools/list-pages.ts
|
|
428
|
+
import { z as z4 } from "zod";
|
|
429
|
+
var listPagesSchema = z4.object({
|
|
430
|
+
projectDir: z4.string().describe("Path to the Thally project root")
|
|
431
|
+
});
|
|
432
|
+
function formatGroup(group, indent) {
|
|
433
|
+
const lines = [`${indent}Group: ${group.group}`];
|
|
434
|
+
for (const page of group.pages) {
|
|
435
|
+
if (typeof page === "string") {
|
|
436
|
+
const href = page === "introduction" ? "/" : `/${page}`;
|
|
437
|
+
lines.push(`${indent} - ${page.padEnd(30)} \u2192 ${href}`);
|
|
438
|
+
} else {
|
|
439
|
+
lines.push(...formatGroup(page, indent + " "));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return lines;
|
|
443
|
+
}
|
|
444
|
+
async function handleListPages(input) {
|
|
445
|
+
const config = readDocsJson(input.projectDir);
|
|
446
|
+
const lines = [];
|
|
447
|
+
for (const tab of config.tabs) {
|
|
448
|
+
lines.push(`Tab: ${tab.tab}`);
|
|
449
|
+
if (tab.href) {
|
|
450
|
+
lines.push(` \u2192 External: ${tab.href}`);
|
|
451
|
+
} else if (tab.api) {
|
|
452
|
+
lines.push(` \u2192 API Reference: ${tab.api.source}`);
|
|
453
|
+
} else if (tab.groups && tab.groups.length > 0) {
|
|
454
|
+
for (const group of tab.groups) {
|
|
455
|
+
lines.push(...formatGroup(group, " "));
|
|
456
|
+
}
|
|
457
|
+
} else {
|
|
458
|
+
lines.push(" (no pages)");
|
|
459
|
+
}
|
|
460
|
+
lines.push("");
|
|
461
|
+
}
|
|
462
|
+
return lines.join("\n").trimEnd();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// src/tools/update-page.ts
|
|
466
|
+
import { z as z5 } from "zod";
|
|
467
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "fs";
|
|
468
|
+
import { join as join4 } from "path";
|
|
469
|
+
import matter from "gray-matter";
|
|
470
|
+
var updatePageSchema = z5.object({
|
|
471
|
+
projectDir: z5.string().describe("Path to the Thally project root"),
|
|
472
|
+
pageId: z5.string().describe('Page identifier (e.g. "guides/auth"). No .mdx extension.'),
|
|
473
|
+
title: z5.string().optional().describe("New page title"),
|
|
474
|
+
description: z5.string().optional().describe("New page description"),
|
|
475
|
+
content: z5.string().optional().describe("New MDX body content (replaces existing body)"),
|
|
476
|
+
mergeFrontmatter: z5.record(z5.unknown()).optional().describe("Additional frontmatter fields to merge in")
|
|
477
|
+
});
|
|
478
|
+
function findPageFile(projectDir, pageId) {
|
|
479
|
+
const candidates = [
|
|
480
|
+
join4(projectDir, "src", "content", `${pageId}.mdx`),
|
|
481
|
+
join4(projectDir, "src", "content", `${pageId}/index.mdx`)
|
|
482
|
+
];
|
|
483
|
+
for (const candidate of candidates) {
|
|
484
|
+
if (existsSync3(candidate)) return candidate;
|
|
485
|
+
}
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
async function handleUpdatePage(input) {
|
|
489
|
+
const { projectDir, pageId } = input;
|
|
490
|
+
const filePath = findPageFile(projectDir, pageId);
|
|
491
|
+
if (!filePath) {
|
|
492
|
+
throw new Error(
|
|
493
|
+
`Page not found for pageId "${pageId}". Tried:
|
|
494
|
+
src/content/${pageId}.mdx
|
|
495
|
+
src/content/${pageId}/index.mdx`
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
const raw = readFileSync3(filePath, "utf8");
|
|
499
|
+
const parsed = matter(raw);
|
|
500
|
+
const newFm = { ...parsed.data };
|
|
501
|
+
if (input.title !== void 0) newFm["title"] = input.title;
|
|
502
|
+
if (input.description !== void 0) newFm["description"] = input.description;
|
|
503
|
+
if (input.mergeFrontmatter) {
|
|
504
|
+
Object.assign(newFm, input.mergeFrontmatter);
|
|
505
|
+
}
|
|
506
|
+
const newBody = input.content !== void 0 ? input.content : parsed.content;
|
|
507
|
+
const newContent = matter.stringify(newBody.trim(), newFm);
|
|
508
|
+
writeFileSync4(filePath, newContent, "utf8");
|
|
509
|
+
return [
|
|
510
|
+
`\u2705 Page updated: ${filePath}`,
|
|
511
|
+
` pageId: ${pageId}`,
|
|
512
|
+
...input.title ? [` title: ${input.title}`] : [],
|
|
513
|
+
...input.description ? [` description: ${input.description}`] : [],
|
|
514
|
+
...input.content !== void 0 ? [" body: replaced"] : []
|
|
515
|
+
].join("\n");
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// src/tools/migrate-docs.ts
|
|
519
|
+
import { z as z6 } from "zod";
|
|
520
|
+
|
|
521
|
+
// src/lib/migrate/index.ts
|
|
522
|
+
import { mkdirSync as mkdirSync3, copyFileSync, writeFileSync as writeFileSync5, existsSync as existsSync6, mkdtempSync, rmSync } from "fs";
|
|
523
|
+
import { join as join7, dirname as dirname2, resolve as resolve2 } from "path";
|
|
524
|
+
import { tmpdir } from "os";
|
|
525
|
+
import { execSync as execSync3 } from "child_process";
|
|
526
|
+
import pLimit from "p-limit";
|
|
527
|
+
|
|
528
|
+
// src/lib/migrate/github.ts
|
|
529
|
+
import { execSync as execSync2 } from "child_process";
|
|
530
|
+
import { readdirSync as readdirSync2, statSync } from "fs";
|
|
531
|
+
import { join as join5, relative, extname, basename } from "path";
|
|
532
|
+
var OPENAPI_FILENAMES = [
|
|
533
|
+
"openapi.json",
|
|
534
|
+
"openapi.yaml",
|
|
535
|
+
"openapi.yml",
|
|
536
|
+
"swagger.json",
|
|
537
|
+
"swagger.yaml",
|
|
538
|
+
"swagger.yml"
|
|
539
|
+
];
|
|
540
|
+
function detectOpenApiSpec(cloneDir) {
|
|
541
|
+
return findOpenApiSpec(cloneDir, 0);
|
|
542
|
+
}
|
|
543
|
+
function findOpenApiSpec(dir, depth) {
|
|
544
|
+
if (depth > 3) return null;
|
|
545
|
+
let entries;
|
|
546
|
+
try {
|
|
547
|
+
entries = readdirSync2(dir);
|
|
548
|
+
} catch {
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
for (const filename of OPENAPI_FILENAMES) {
|
|
552
|
+
if (entries.includes(filename)) {
|
|
553
|
+
return { absPath: join5(dir, filename), filename };
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
for (const entry of entries) {
|
|
557
|
+
if (entry.startsWith(".") || entry === "node_modules") continue;
|
|
558
|
+
const fullPath = join5(dir, entry);
|
|
559
|
+
try {
|
|
560
|
+
if (statSync(fullPath).isDirectory()) {
|
|
561
|
+
const found = findOpenApiSpec(fullPath, depth + 1);
|
|
562
|
+
if (found) return found;
|
|
563
|
+
}
|
|
564
|
+
} catch {
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
function parseGitHubUrl(rawUrl) {
|
|
570
|
+
let url;
|
|
571
|
+
try {
|
|
572
|
+
url = new URL(rawUrl);
|
|
573
|
+
} catch {
|
|
574
|
+
throw new Error(`Invalid URL: ${rawUrl}`);
|
|
575
|
+
}
|
|
576
|
+
if (url.hostname !== "github.com") {
|
|
577
|
+
throw new Error(`URL must be a github.com URL, got: ${url.hostname}`);
|
|
578
|
+
}
|
|
579
|
+
const parts = url.pathname.replace(/^\//, "").split("/");
|
|
580
|
+
if (parts.length < 2 || !parts[0] || !parts[1]) {
|
|
581
|
+
throw new Error(`GitHub URL must include owner and repo: ${rawUrl}`);
|
|
582
|
+
}
|
|
583
|
+
const owner = parts[0];
|
|
584
|
+
const repo = parts[1];
|
|
585
|
+
let branch = "HEAD";
|
|
586
|
+
let docsDir = "";
|
|
587
|
+
if (parts.length >= 4 && parts[2] === "tree") {
|
|
588
|
+
branch = parts[3];
|
|
589
|
+
if (parts.length > 4) {
|
|
590
|
+
docsDir = parts.slice(4).join("/");
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
const cloneUrl = `https://github.com/${owner}/${repo}.git`;
|
|
594
|
+
return { owner, repo, branch, docsDir, cloneUrl };
|
|
595
|
+
}
|
|
596
|
+
async function cloneRepo(source, targetDir) {
|
|
597
|
+
const parts = ["git", "clone", "--depth", "1"];
|
|
598
|
+
if (source.branch !== "HEAD") {
|
|
599
|
+
parts.push("--branch", source.branch);
|
|
600
|
+
}
|
|
601
|
+
parts.push(source.cloneUrl, targetDir);
|
|
602
|
+
const cmd = parts.join(" ");
|
|
603
|
+
try {
|
|
604
|
+
execSync2(cmd, { stdio: "pipe" });
|
|
605
|
+
} catch (err) {
|
|
606
|
+
const stderr = err.stderr?.toString().trim() ?? "";
|
|
607
|
+
const msg = stderr || (err instanceof Error ? err.message : String(err));
|
|
608
|
+
throw new Error(`Failed to clone ${source.cloneUrl}: ${msg}`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
var MD_EXTENSIONS = /* @__PURE__ */ new Set([".md", ".mdx"]);
|
|
612
|
+
var ALL_DOC_EXTENSIONS = /* @__PURE__ */ new Set([".md", ".mdx", ".rst", ".txt"]);
|
|
613
|
+
function hasMdFiles(dir) {
|
|
614
|
+
let entries;
|
|
615
|
+
try {
|
|
616
|
+
entries = readdirSync2(dir);
|
|
617
|
+
} catch {
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
for (const entry of entries) {
|
|
621
|
+
const fullPath = join5(dir, entry);
|
|
622
|
+
try {
|
|
623
|
+
const stat = statSync(fullPath);
|
|
624
|
+
if (stat.isDirectory()) {
|
|
625
|
+
if (hasMdFiles(fullPath)) return true;
|
|
626
|
+
} else if (MD_EXTENSIONS.has(extname(entry).toLowerCase())) {
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
629
|
+
} catch {
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
function detectDocsDir(cloneDir) {
|
|
635
|
+
const candidates = [
|
|
636
|
+
"docs",
|
|
637
|
+
"documentation",
|
|
638
|
+
"content",
|
|
639
|
+
"pages",
|
|
640
|
+
"src/content",
|
|
641
|
+
"src/pages",
|
|
642
|
+
"guide",
|
|
643
|
+
"guides",
|
|
644
|
+
""
|
|
645
|
+
];
|
|
646
|
+
for (const candidate of candidates) {
|
|
647
|
+
const fullPath = candidate ? join5(cloneDir, candidate) : cloneDir;
|
|
648
|
+
try {
|
|
649
|
+
const stat = statSync(fullPath);
|
|
650
|
+
if (stat.isDirectory() && hasMdFiles(fullPath)) {
|
|
651
|
+
return candidate;
|
|
652
|
+
}
|
|
653
|
+
} catch {
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return "";
|
|
657
|
+
}
|
|
658
|
+
function slugifySegment(seg) {
|
|
659
|
+
return seg.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
660
|
+
}
|
|
661
|
+
function derivePageId(relPath) {
|
|
662
|
+
const normalized = relPath.replace(/\\/g, "/");
|
|
663
|
+
const parts = normalized.split("/");
|
|
664
|
+
const filename = parts[parts.length - 1];
|
|
665
|
+
const dirs = parts.slice(0, -1);
|
|
666
|
+
const base = basename(filename, extname(filename));
|
|
667
|
+
if (dirs.length === 0 && base.toLowerCase() === "readme") {
|
|
668
|
+
return "introduction";
|
|
669
|
+
}
|
|
670
|
+
if (base.toLowerCase() === "index") {
|
|
671
|
+
if (dirs.length === 0) return "introduction";
|
|
672
|
+
return dirs.map(slugifySegment).join("/");
|
|
673
|
+
}
|
|
674
|
+
return [...dirs, base].map(slugifySegment).join("/");
|
|
675
|
+
}
|
|
676
|
+
function scanDir(dir, baseDir, primaryOnly, results) {
|
|
677
|
+
let entries;
|
|
678
|
+
try {
|
|
679
|
+
entries = readdirSync2(dir);
|
|
680
|
+
} catch {
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
for (const entry of entries) {
|
|
684
|
+
if (entry.startsWith("_") || entry.startsWith(".") || entry === "node_modules") continue;
|
|
685
|
+
const fullPath = join5(dir, entry);
|
|
686
|
+
let stat;
|
|
687
|
+
try {
|
|
688
|
+
stat = statSync(fullPath);
|
|
689
|
+
} catch {
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
if (stat.isDirectory()) {
|
|
693
|
+
scanDir(fullPath, baseDir, primaryOnly, results);
|
|
694
|
+
} else {
|
|
695
|
+
const ext = extname(entry).toLowerCase();
|
|
696
|
+
const validExt = primaryOnly ? MD_EXTENSIONS.has(ext) : ALL_DOC_EXTENSIONS.has(ext);
|
|
697
|
+
if (!validExt) continue;
|
|
698
|
+
const relPath = relative(baseDir, fullPath);
|
|
699
|
+
const pageId = derivePageId(relPath);
|
|
700
|
+
results.push({ absPath: fullPath, relPath, pageId, ext });
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
function findDocFiles(cloneDir, docsDir) {
|
|
705
|
+
const baseDir = docsDir ? join5(cloneDir, docsDir) : cloneDir;
|
|
706
|
+
const primaryResults = [];
|
|
707
|
+
scanDir(baseDir, baseDir, true, primaryResults);
|
|
708
|
+
if (primaryResults.length > 0) return primaryResults;
|
|
709
|
+
const allResults = [];
|
|
710
|
+
scanDir(baseDir, baseDir, false, allResults);
|
|
711
|
+
return allResults;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// src/lib/migrate/importer.ts
|
|
715
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
716
|
+
import { basename as basename2, extname as extname2 } from "path";
|
|
717
|
+
import matter2 from "gray-matter";
|
|
718
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
719
|
+
function titleFromFilename(relPath) {
|
|
720
|
+
const filename = relPath.replace(/\\/g, "/").split("/").pop() ?? relPath;
|
|
721
|
+
const base = basename2(filename, extname2(filename));
|
|
722
|
+
return base.split(/[-_]/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
723
|
+
}
|
|
724
|
+
function extractFirstParagraph(content) {
|
|
725
|
+
for (const line of content.split("\n")) {
|
|
726
|
+
const trimmed = line.trim();
|
|
727
|
+
if (!trimmed) continue;
|
|
728
|
+
if (trimmed.startsWith("#")) continue;
|
|
729
|
+
if (trimmed.startsWith("```") || trimmed.startsWith(":::") || trimmed.startsWith("<")) continue;
|
|
730
|
+
if (trimmed.startsWith("import ") || trimmed.startsWith("export ")) continue;
|
|
731
|
+
return trimmed.slice(0, 200);
|
|
732
|
+
}
|
|
733
|
+
return "";
|
|
734
|
+
}
|
|
735
|
+
function normalizeComponents(body) {
|
|
736
|
+
let result = body;
|
|
737
|
+
const importedComponents = /* @__PURE__ */ new Set();
|
|
738
|
+
result = result.replace(
|
|
739
|
+
/^import\s+(\w+|\{[^}]+\})\s+from\s+['"][^'"]+['"]\s*;?\s*$/gm,
|
|
740
|
+
(_, imported) => {
|
|
741
|
+
const name = imported.trim();
|
|
742
|
+
if (/^[A-Z]\w*$/.test(name)) importedComponents.add(name);
|
|
743
|
+
return "";
|
|
744
|
+
}
|
|
745
|
+
);
|
|
746
|
+
for (const name of importedComponents) {
|
|
747
|
+
result = result.replace(
|
|
748
|
+
new RegExp(`<${name}(?:\\s[^>]*)?\\/>`, "gm"),
|
|
749
|
+
`{/* <${name} /> \u2014 imported snippet component */}`
|
|
750
|
+
);
|
|
751
|
+
result = result.replace(
|
|
752
|
+
new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${name}>`, "g"),
|
|
753
|
+
`{/* <${name}> \u2014 imported snippet component */}`
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
result = result.replace(/<!--([\s\S]*?)-->/g, (_, inner) => `{/*${inner}*/}`);
|
|
757
|
+
result = result.replace(/<Tip>([\s\S]*?)<\/Tip>/g, (_, c) => `<Note>${c}</Note>`);
|
|
758
|
+
result = result.replace(/<Check>([\s\S]*?)<\/Check>/g, (_, c) => `<Note>${c}</Note>`);
|
|
759
|
+
result = result.replace(/<Danger>([\s\S]*?)<\/Danger>/g, (_, c) => `<Error>${c}</Error>`);
|
|
760
|
+
result = result.replace(/<Callout(?:\s[^>]*)?>([\s\S]*?)<\/Callout>/g, (_, c) => `<Note>${c}</Note>`);
|
|
761
|
+
result = result.replace(/:::(\w+)(?:\s+[^\n]*)?\n([\s\S]*?):::/g, (_, type, content) => {
|
|
762
|
+
const tag = mapAdmonitionToThallyTag(type.toLowerCase());
|
|
763
|
+
return `<${tag}>
|
|
764
|
+
${content.trim()}
|
|
765
|
+
</${tag}>`;
|
|
766
|
+
});
|
|
767
|
+
result = result.replace(
|
|
768
|
+
/\{%\s*hint\s+style="(\w+)"\s*%\}([\s\S]*?)\{%\s*endhint\s*%\}/g,
|
|
769
|
+
(_, style, content) => {
|
|
770
|
+
const tag = mapGitBookStyleToThallyTag(style.toLowerCase());
|
|
771
|
+
return `<${tag}>
|
|
772
|
+
${content.trim()}
|
|
773
|
+
</${tag}>`;
|
|
774
|
+
}
|
|
775
|
+
);
|
|
776
|
+
result = result.replace(/<AccordionGroup[^>]*>\n?([\s\S]*?)\n?<\/AccordionGroup>/g, (_, inner) => inner.trim());
|
|
777
|
+
result = result.replace(/<Expandable(\s[^>]*)?>/g, (_, attrs = "") => {
|
|
778
|
+
const title = attrs.match(/title="([^"]*)"/)?.[1] ?? "Details";
|
|
779
|
+
return `<Accordion title="${title}">`;
|
|
780
|
+
});
|
|
781
|
+
result = result.replace(/<\/Expandable>/g, "</Accordion>");
|
|
782
|
+
result = result.replace(/<Latex>([\s\S]*?)<\/Latex>/g, (_, inner) => `\`${inner.trim()}\``);
|
|
783
|
+
result = result.replace(/<(?:ResponseField|ParamField)([^>]*)>/g, (_, attrs) => {
|
|
784
|
+
const name = attrs.match(/name="([^"]*)"/)?.[1] ?? "";
|
|
785
|
+
const type = attrs.match(/type="([^"]*)"/)?.[1] ?? "";
|
|
786
|
+
const required = /\brequired\b/.test(attrs);
|
|
787
|
+
const def = attrs.match(/default="([^"]*)"/)?.[1];
|
|
788
|
+
const deprecated = /\bdeprecated\b/.test(attrs);
|
|
789
|
+
const meta = [
|
|
790
|
+
type && `\`${type}\``,
|
|
791
|
+
required && "*(required)*",
|
|
792
|
+
deprecated && "*(deprecated)*",
|
|
793
|
+
def !== void 0 && `*(default: \`${def}\`)*`
|
|
794
|
+
].filter(Boolean).join(" ");
|
|
795
|
+
return `
|
|
796
|
+
**\`${name}\`** ${meta}
|
|
797
|
+
|
|
798
|
+
`;
|
|
799
|
+
});
|
|
800
|
+
result = result.replace(/<\/(?:ResponseField|ParamField)>/g, "\n");
|
|
801
|
+
result = result.replace(/<RequestExample[^>]*>/g, "<CodeGroup>");
|
|
802
|
+
result = result.replace(/<\/RequestExample>/g, "</CodeGroup>");
|
|
803
|
+
result = result.replace(/<ResponseExample[^>]*>/g, "<CodeGroup>");
|
|
804
|
+
result = result.replace(/<\/ResponseExample>/g, "</CodeGroup>");
|
|
805
|
+
result = result.replace(/<Panel[^>]*>([\s\S]*?)<\/Panel>/g, (_, inner) => inner.trim());
|
|
806
|
+
result = result.replace(/<Badge[^>]*>([\s\S]*?)<\/Badge>/g, (_, inner) => `**${inner.trim()}**`);
|
|
807
|
+
result = result.replace(/<Tile(\s[^>]*)?>/g, (_, attrs = "") => `<Card${attrs}>`);
|
|
808
|
+
result = result.replace(/<\/Tile>/g, "</Card>");
|
|
809
|
+
result = result.replace(/<View(\s[^>]*)?>/g, (_, attrs = "") => {
|
|
810
|
+
const title = attrs.match(/title="([^"]*)"/)?.[1] ?? "View";
|
|
811
|
+
return `<Tab title="${title}">`;
|
|
812
|
+
});
|
|
813
|
+
result = result.replace(/<\/View>/g, "</Tab>");
|
|
814
|
+
result = result.replace(/<Update(\s[^>]*)?>/g, (_, attrs = "") => {
|
|
815
|
+
const label = attrs.match(/label="([^"]*)"/)?.[1] ?? "";
|
|
816
|
+
const desc = attrs.match(/description="([^"]*)"/)?.[1] ?? "";
|
|
817
|
+
return `## ${label}${desc ? `
|
|
818
|
+
|
|
819
|
+
*${desc}*` : ""}
|
|
820
|
+
|
|
821
|
+
`;
|
|
822
|
+
});
|
|
823
|
+
result = result.replace(/<\/Update>/g, "\n");
|
|
824
|
+
result = result.replace(/<Prompt[^>]*>([\s\S]*?)<\/Prompt>/g, (_, inner) => {
|
|
825
|
+
return `\`\`\`text
|
|
826
|
+
${inner.trim()}
|
|
827
|
+
\`\`\``;
|
|
828
|
+
});
|
|
829
|
+
result = result.replace(/<Tree[^>]*>/g, "```\n");
|
|
830
|
+
result = result.replace(/<\/Tree>/g, "\n```");
|
|
831
|
+
result = result.replace(/<Tree\.Folder[^>]*name="([^"]*)"[^>]*>/g, (_, name) => `\u{1F4C1} ${name}/
|
|
832
|
+
`);
|
|
833
|
+
result = result.replace(/<\/Tree\.Folder>/g, "");
|
|
834
|
+
result = result.replace(new RegExp('<Tree\\.File[^>]*name="([^"]*)"[^>]*/>', "g"), (_, name) => ` ${name}
|
|
835
|
+
`);
|
|
836
|
+
result = result.replace(/<Color[^>]*>/g, "| Name | Value |\n|---|---|\n");
|
|
837
|
+
result = result.replace(/<\/Color>/g, "");
|
|
838
|
+
result = result.replace(/<Color\.Row[^>]*title="([^"]*)"[^>]*>/g, (_, title) => `**${title}**
|
|
839
|
+
`);
|
|
840
|
+
result = result.replace(/<\/Color\.Row>/g, "");
|
|
841
|
+
result = result.replace(
|
|
842
|
+
/<Color\.Item[^>]*name="([^"]*)"[^>]*value="([^"]*)"[^>]*\/>/g,
|
|
843
|
+
(_, name, value) => `| ${name} | \`${value}\` |
|
|
844
|
+
`
|
|
845
|
+
);
|
|
846
|
+
result = result.replace(/<Banner[^>]*>([\s\S]*?)<\/Banner>/g, "");
|
|
847
|
+
result = result.replace(/<Banner[^>]*\/>/g, "");
|
|
848
|
+
return result;
|
|
849
|
+
}
|
|
850
|
+
function mapAdmonitionToThallyTag(type) {
|
|
851
|
+
if (type === "warning" || type === "caution") return "Warning";
|
|
852
|
+
if (type === "danger") return "Error";
|
|
853
|
+
if (type === "info") return "Info";
|
|
854
|
+
return "Note";
|
|
855
|
+
}
|
|
856
|
+
function mapGitBookStyleToThallyTag(style) {
|
|
857
|
+
if (style === "warning") return "Warning";
|
|
858
|
+
if (style === "danger") return "Error";
|
|
859
|
+
if (style === "success") return "Note";
|
|
860
|
+
return "Info";
|
|
861
|
+
}
|
|
862
|
+
var RST_SYSTEM_PROMPT = `You are a documentation converter. Convert the given file content to clean MDX.
|
|
863
|
+
Respond with ONLY valid JSON \u2014 no prose, no markdown fences:
|
|
864
|
+
{
|
|
865
|
+
"frontmatter": { "title": "string", "description": "string", "keywords": ["..."] },
|
|
866
|
+
"body": "string \u2014 full MDX body"
|
|
867
|
+
}
|
|
868
|
+
Rules: preserve code blocks with language hints; convert tables to Markdown; convert callout
|
|
869
|
+
boxes to <Note> or <Warning>; preserve heading hierarchy; do not include page title as a heading.`;
|
|
870
|
+
function parseClaudeResponse(text) {
|
|
871
|
+
try {
|
|
872
|
+
return JSON.parse(text);
|
|
873
|
+
} catch {
|
|
874
|
+
const stripped = text.replace(/^```(?:json)?\s*/m, "").replace(/\s*```\s*$/m, "").trim();
|
|
875
|
+
return JSON.parse(stripped);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
async function importFile(file, apiKey) {
|
|
879
|
+
const ext = file.ext.toLowerCase();
|
|
880
|
+
if (ext === ".md" || ext === ".mdx") {
|
|
881
|
+
const raw = readFileSync4(file.absPath, "utf8");
|
|
882
|
+
const parsed = matter2(raw);
|
|
883
|
+
const fmTitle = parsed.data.title ?? "";
|
|
884
|
+
const fmDesc = parsed.data.description ?? "";
|
|
885
|
+
const fmKeywords = parsed.data.keywords;
|
|
886
|
+
const title = fmTitle || titleFromFilename(file.relPath);
|
|
887
|
+
const description = fmDesc || extractFirstParagraph(parsed.content);
|
|
888
|
+
const keywords = Array.isArray(fmKeywords) ? fmKeywords : [];
|
|
889
|
+
const openapi = parsed.data.openapi;
|
|
890
|
+
const body = normalizeComponents(parsed.content);
|
|
891
|
+
if (openapi && !body.trim()) return null;
|
|
892
|
+
return { pageId: file.pageId, frontmatter: { title, description, keywords }, body };
|
|
893
|
+
}
|
|
894
|
+
if (!apiKey) {
|
|
895
|
+
throw new Error(`Skipping non-Markdown file (no API key): ${file.relPath}`);
|
|
896
|
+
}
|
|
897
|
+
const content = readFileSync4(file.absPath, "utf8");
|
|
898
|
+
const client = new Anthropic({ apiKey });
|
|
899
|
+
const message = await client.messages.create({
|
|
900
|
+
model: "claude-sonnet-4-6",
|
|
901
|
+
max_tokens: 4096,
|
|
902
|
+
system: RST_SYSTEM_PROMPT,
|
|
903
|
+
messages: [
|
|
904
|
+
{
|
|
905
|
+
role: "user",
|
|
906
|
+
content: `Convert this documentation file to MDX.
|
|
907
|
+
|
|
908
|
+
File: ${file.relPath}
|
|
909
|
+
|
|
910
|
+
Content:
|
|
911
|
+
${content.slice(0, 8e4)}`
|
|
912
|
+
}
|
|
913
|
+
]
|
|
914
|
+
});
|
|
915
|
+
const responseText = message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
916
|
+
const claudeResult = parseClaudeResponse(responseText);
|
|
917
|
+
return {
|
|
918
|
+
pageId: file.pageId,
|
|
919
|
+
frontmatter: claudeResult.frontmatter,
|
|
920
|
+
body: claudeResult.body
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// src/lib/migrate/nav-builder.ts
|
|
925
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
|
|
926
|
+
import { join as join6 } from "path";
|
|
927
|
+
function titleCase(str) {
|
|
928
|
+
return str.split("-").map((word) => {
|
|
929
|
+
if (word.toLowerCase() === "api") return "API";
|
|
930
|
+
if (word.toLowerCase() === "sdk") return "SDK";
|
|
931
|
+
if (word.toLowerCase() === "cli") return "CLI";
|
|
932
|
+
if (word.toLowerCase() === "ui") return "UI";
|
|
933
|
+
if (word.toLowerCase() === "faq") return "FAQ";
|
|
934
|
+
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
|
935
|
+
}).join(" ");
|
|
936
|
+
}
|
|
937
|
+
function buildNavStructure(pages) {
|
|
938
|
+
const seen = /* @__PURE__ */ new Set();
|
|
939
|
+
const ordered = [];
|
|
940
|
+
for (const p of pages) {
|
|
941
|
+
if (!seen.has(p.pageId)) {
|
|
942
|
+
seen.add(p.pageId);
|
|
943
|
+
ordered.push(p.pageId);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
const depth1Segments = /* @__PURE__ */ new Set();
|
|
947
|
+
for (const id of ordered) {
|
|
948
|
+
const parts = id.split("/");
|
|
949
|
+
if (parts.length > 1) {
|
|
950
|
+
depth1Segments.add(parts[0]);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
const rootOnlyPages = ordered.filter((id) => !id.includes("/"));
|
|
954
|
+
const useSingleTab = depth1Segments.size === 0 || depth1Segments.size === 1 && rootOnlyPages.length === 0;
|
|
955
|
+
let tabs;
|
|
956
|
+
if (useSingleTab) {
|
|
957
|
+
const groups = buildGroups(ordered, null);
|
|
958
|
+
tabs = [{ tab: "Overview", groups }];
|
|
959
|
+
} else {
|
|
960
|
+
tabs = [];
|
|
961
|
+
if (rootOnlyPages.length > 0) {
|
|
962
|
+
const groups = buildGroups(rootOnlyPages, null);
|
|
963
|
+
tabs.push({ tab: "Overview", groups });
|
|
964
|
+
}
|
|
965
|
+
for (const seg of depth1Segments) {
|
|
966
|
+
const tabPages = ordered.filter((id) => id.startsWith(seg + "/") || id === seg);
|
|
967
|
+
const groups = buildGroups(tabPages, seg);
|
|
968
|
+
tabs.push({ tab: titleCase(seg), groups });
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
tabs.push({ tab: "Changelog", href: "/changelog" });
|
|
972
|
+
return { tabs };
|
|
973
|
+
}
|
|
974
|
+
function buildGroups(pageIds, tabSegment) {
|
|
975
|
+
const groupMap = /* @__PURE__ */ new Map();
|
|
976
|
+
for (const id of pageIds) {
|
|
977
|
+
let groupName;
|
|
978
|
+
if (tabSegment === null) {
|
|
979
|
+
groupName = "Overview";
|
|
980
|
+
} else {
|
|
981
|
+
const rel = id.startsWith(tabSegment + "/") ? id.slice(tabSegment.length + 1) : id;
|
|
982
|
+
const relParts = rel.split("/");
|
|
983
|
+
groupName = relParts.length === 1 ? titleCase(tabSegment) : titleCase(relParts[0]);
|
|
984
|
+
}
|
|
985
|
+
if (!groupMap.has(groupName)) groupMap.set(groupName, []);
|
|
986
|
+
groupMap.get(groupName).push(id);
|
|
987
|
+
}
|
|
988
|
+
const groups = [];
|
|
989
|
+
for (const [groupName, groupPages] of groupMap) {
|
|
990
|
+
const sorted = [...groupPages];
|
|
991
|
+
const introIdx = sorted.indexOf("introduction");
|
|
992
|
+
if (introIdx > 0) {
|
|
993
|
+
sorted.splice(introIdx, 1);
|
|
994
|
+
sorted.unshift("introduction");
|
|
995
|
+
}
|
|
996
|
+
groups.push({ group: groupName, pages: sorted });
|
|
997
|
+
}
|
|
998
|
+
return groups;
|
|
999
|
+
}
|
|
1000
|
+
function detectPlatform(cloneDir) {
|
|
1001
|
+
if (existsSync5(join6(cloneDir, "mint.json"))) return "mintlify";
|
|
1002
|
+
if (existsSync5(join6(cloneDir, "docs.json"))) {
|
|
1003
|
+
try {
|
|
1004
|
+
const parsed = JSON.parse(readFileSync5(join6(cloneDir, "docs.json"), "utf8"));
|
|
1005
|
+
if (Array.isArray(parsed.tabs)) return "thally";
|
|
1006
|
+
const schema = parsed.$schema;
|
|
1007
|
+
if (schema?.includes("mintlify") || "navigation" in parsed) return "mintlify";
|
|
1008
|
+
} catch {
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
if (existsSync5(join6(cloneDir, "docusaurus.config.js")) || existsSync5(join6(cloneDir, "docusaurus.config.ts")) || existsSync5(join6(cloneDir, "docusaurus.config.mjs"))) return "docusaurus";
|
|
1012
|
+
if (existsSync5(join6(cloneDir, "SUMMARY.md"))) return "gitbook";
|
|
1013
|
+
if (existsSync5(join6(cloneDir, ".vitepress"))) return "vitepress";
|
|
1014
|
+
if (existsSync5(join6(cloneDir, "astro.config.mjs")) || existsSync5(join6(cloneDir, "astro.config.ts"))) return "starlight";
|
|
1015
|
+
if (existsSync5(join6(cloneDir, "_meta.json")) || existsSync5(join6(cloneDir, "pages", "_meta.json"))) return "nextra";
|
|
1016
|
+
return "unknown";
|
|
1017
|
+
}
|
|
1018
|
+
function slugify2(s) {
|
|
1019
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
1020
|
+
}
|
|
1021
|
+
function normalizePageRef(ref, docsDir) {
|
|
1022
|
+
let r = ref;
|
|
1023
|
+
if (docsDir && r.startsWith(docsDir + "/")) r = r.slice(docsDir.length + 1);
|
|
1024
|
+
r = r.replace(/\.(mdx?|rst|txt)$/, "");
|
|
1025
|
+
const parts = r.split("/");
|
|
1026
|
+
const last = parts[parts.length - 1].toLowerCase();
|
|
1027
|
+
if (last === "index" || last === "readme") {
|
|
1028
|
+
return parts.length === 1 ? "introduction" : parts.slice(0, -1).map(slugify2).join("/");
|
|
1029
|
+
}
|
|
1030
|
+
return parts.map(slugify2).join("/");
|
|
1031
|
+
}
|
|
1032
|
+
function convertMintTabs(tabs, docsDir) {
|
|
1033
|
+
if (tabs.length === 0) return null;
|
|
1034
|
+
function convertPageRef(page) {
|
|
1035
|
+
if (typeof page === "string") return normalizePageRef(page, docsDir);
|
|
1036
|
+
if (page !== null && typeof page === "object" && "group" in page && "pages" in page) {
|
|
1037
|
+
const p = page;
|
|
1038
|
+
return {
|
|
1039
|
+
group: String(p.group),
|
|
1040
|
+
pages: (p.pages ?? []).map(convertPageRef)
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
return String(page);
|
|
1044
|
+
}
|
|
1045
|
+
const resultTabs = tabs.map((item) => {
|
|
1046
|
+
if (item.href) return { tab: String(item.tab), href: String(item.href) };
|
|
1047
|
+
const groups = (item.groups ?? []).map((g) => ({
|
|
1048
|
+
group: String(g.group),
|
|
1049
|
+
pages: (g.pages ?? []).map(convertPageRef)
|
|
1050
|
+
}));
|
|
1051
|
+
return { tab: String(item.tab), groups };
|
|
1052
|
+
});
|
|
1053
|
+
if (!resultTabs.some((t) => t.tab === "Changelog")) {
|
|
1054
|
+
resultTabs.push({ tab: "Changelog", href: "/changelog" });
|
|
1055
|
+
}
|
|
1056
|
+
return { tabs: resultTabs };
|
|
1057
|
+
}
|
|
1058
|
+
function parseMintConfig(config, docsDir) {
|
|
1059
|
+
const nav = config.navigation;
|
|
1060
|
+
if (nav && typeof nav === "object" && !Array.isArray(nav)) {
|
|
1061
|
+
const v3Tabs = nav.tabs;
|
|
1062
|
+
if (Array.isArray(v3Tabs) && v3Tabs.length > 0) return convertMintTabs(v3Tabs, docsDir);
|
|
1063
|
+
}
|
|
1064
|
+
if (!Array.isArray(nav) || nav.length === 0) return null;
|
|
1065
|
+
if ("tab" in nav[0]) return convertMintTabs(nav, docsDir);
|
|
1066
|
+
function convertPageRef(page) {
|
|
1067
|
+
if (typeof page === "string") return normalizePageRef(page, docsDir);
|
|
1068
|
+
if (page !== null && typeof page === "object" && "group" in page && "pages" in page) {
|
|
1069
|
+
const p = page;
|
|
1070
|
+
return { group: String(p.group), pages: (p.pages ?? []).map(convertPageRef) };
|
|
1071
|
+
}
|
|
1072
|
+
return String(page);
|
|
1073
|
+
}
|
|
1074
|
+
const groups = nav.map((item) => ({
|
|
1075
|
+
group: String(item.group ?? ""),
|
|
1076
|
+
pages: (item.pages ?? []).map(convertPageRef)
|
|
1077
|
+
}));
|
|
1078
|
+
return { tabs: [{ tab: "Docs", groups }, { tab: "Changelog", href: "/changelog" }] };
|
|
1079
|
+
}
|
|
1080
|
+
function parseGitBookSummary(cloneDir, docsDir) {
|
|
1081
|
+
const candidates = [join6(cloneDir, "SUMMARY.md")];
|
|
1082
|
+
if (docsDir) candidates.push(join6(cloneDir, docsDir, "SUMMARY.md"));
|
|
1083
|
+
let raw = "";
|
|
1084
|
+
for (const p of candidates) {
|
|
1085
|
+
if (existsSync5(p)) {
|
|
1086
|
+
raw = readFileSync5(p, "utf8");
|
|
1087
|
+
break;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
if (!raw) return null;
|
|
1091
|
+
const groups = [];
|
|
1092
|
+
let currentGroupName = "Overview";
|
|
1093
|
+
let currentPages = [];
|
|
1094
|
+
for (const line of raw.split("\n")) {
|
|
1095
|
+
const groupMatch = line.match(/^##\s+(.+)/);
|
|
1096
|
+
if (groupMatch) {
|
|
1097
|
+
if (currentPages.length > 0) groups.push({ group: currentGroupName, pages: currentPages });
|
|
1098
|
+
currentGroupName = groupMatch[1].trim();
|
|
1099
|
+
currentPages = [];
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
const pageMatch = line.match(/^\*\s+\[.+?\]\((.+?)\)/);
|
|
1103
|
+
if (pageMatch) {
|
|
1104
|
+
const ref = pageMatch[1].trim();
|
|
1105
|
+
if (ref.startsWith("http")) continue;
|
|
1106
|
+
currentPages.push(normalizePageRef(ref, docsDir));
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
if (currentPages.length > 0) groups.push({ group: currentGroupName, pages: currentPages });
|
|
1110
|
+
if (groups.length === 0) return null;
|
|
1111
|
+
return { tabs: [{ tab: "Docs", groups }, { tab: "Changelog", href: "/changelog" }] };
|
|
1112
|
+
}
|
|
1113
|
+
function parseNextraMeta(cloneDir, docsDir) {
|
|
1114
|
+
const baseDir = docsDir ? join6(cloneDir, docsDir) : cloneDir;
|
|
1115
|
+
const metaPath = join6(baseDir, "_meta.json");
|
|
1116
|
+
if (!existsSync5(metaPath)) return null;
|
|
1117
|
+
try {
|
|
1118
|
+
const meta = JSON.parse(readFileSync5(metaPath, "utf8"));
|
|
1119
|
+
const pages = [];
|
|
1120
|
+
for (const [key, value] of Object.entries(meta)) {
|
|
1121
|
+
if (typeof value === "object" && value !== null) {
|
|
1122
|
+
const v = value;
|
|
1123
|
+
if (v.type === "separator" || v.type === "menu") continue;
|
|
1124
|
+
}
|
|
1125
|
+
pages.push(key === "index" ? "introduction" : slugify2(key));
|
|
1126
|
+
}
|
|
1127
|
+
if (pages.length === 0) return null;
|
|
1128
|
+
return {
|
|
1129
|
+
tabs: [
|
|
1130
|
+
{ tab: "Docs", groups: [{ group: "Overview", pages }] },
|
|
1131
|
+
{ tab: "Changelog", href: "/changelog" }
|
|
1132
|
+
]
|
|
1133
|
+
};
|
|
1134
|
+
} catch {
|
|
1135
|
+
return null;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
var PLATFORM_LABELS = {
|
|
1139
|
+
mintlify: "Mintlify",
|
|
1140
|
+
docusaurus: "Docusaurus",
|
|
1141
|
+
gitbook: "GitBook",
|
|
1142
|
+
nextra: "Nextra",
|
|
1143
|
+
vitepress: "VitePress",
|
|
1144
|
+
starlight: "Starlight (Astro)",
|
|
1145
|
+
thally: "Thally",
|
|
1146
|
+
unknown: "unknown"
|
|
1147
|
+
};
|
|
1148
|
+
function detectNavFromConfig(cloneDir, docsDir, platform) {
|
|
1149
|
+
const detected = platform ?? detectPlatform(cloneDir);
|
|
1150
|
+
const label = PLATFORM_LABELS[detected];
|
|
1151
|
+
switch (detected) {
|
|
1152
|
+
case "thally": {
|
|
1153
|
+
try {
|
|
1154
|
+
const parsed = JSON.parse(
|
|
1155
|
+
readFileSync5(join6(cloneDir, "docs.json"), "utf8")
|
|
1156
|
+
);
|
|
1157
|
+
console.log(` \u{1F4CB} Detected ${label} \u2014 using docs.json navigation as-is`);
|
|
1158
|
+
return parsed;
|
|
1159
|
+
} catch {
|
|
1160
|
+
return null;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
case "mintlify": {
|
|
1164
|
+
for (const file of ["docs.json", "mint.json"]) {
|
|
1165
|
+
const p = join6(cloneDir, file);
|
|
1166
|
+
if (!existsSync5(p)) continue;
|
|
1167
|
+
try {
|
|
1168
|
+
const config = JSON.parse(readFileSync5(p, "utf8"));
|
|
1169
|
+
const nav = parseMintConfig(config, docsDir);
|
|
1170
|
+
if (nav) {
|
|
1171
|
+
console.log(` \u{1F4CB} Detected ${label} (${file}) \u2014 converting navigation`);
|
|
1172
|
+
return nav;
|
|
1173
|
+
}
|
|
1174
|
+
} catch {
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
return null;
|
|
1178
|
+
}
|
|
1179
|
+
case "gitbook": {
|
|
1180
|
+
const nav = parseGitBookSummary(cloneDir, docsDir);
|
|
1181
|
+
if (nav) console.log(` \u{1F4CB} Detected ${label} (SUMMARY.md) \u2014 converting navigation`);
|
|
1182
|
+
return nav;
|
|
1183
|
+
}
|
|
1184
|
+
case "nextra": {
|
|
1185
|
+
const nav = parseNextraMeta(cloneDir, docsDir);
|
|
1186
|
+
if (nav) console.log(` \u{1F4CB} Detected ${label} (_meta.json) \u2014 converting navigation`);
|
|
1187
|
+
return nav;
|
|
1188
|
+
}
|
|
1189
|
+
case "docusaurus":
|
|
1190
|
+
case "vitepress":
|
|
1191
|
+
case "starlight":
|
|
1192
|
+
console.log(` \u{1F4CB} Detected ${label} \u2014 nav config is JavaScript, using directory structure`);
|
|
1193
|
+
return null;
|
|
1194
|
+
default:
|
|
1195
|
+
return null;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// src/lib/migrate/index.ts
|
|
1200
|
+
function mergeDocsJson(existing, incoming) {
|
|
1201
|
+
const existingTabNames = new Set(existing.tabs.map((t) => t.tab));
|
|
1202
|
+
const merged = { tabs: [...existing.tabs.filter((t) => t.tab !== "Changelog")] };
|
|
1203
|
+
if (existing.ai || incoming.ai) {
|
|
1204
|
+
merged.ai = { ...incoming.ai, ...existing.ai };
|
|
1205
|
+
}
|
|
1206
|
+
for (const tab of incoming.tabs) {
|
|
1207
|
+
if (tab.tab === "Changelog") continue;
|
|
1208
|
+
if (existingTabNames.has(tab.tab)) {
|
|
1209
|
+
const existingTab = merged.tabs.find((t) => t.tab === tab.tab);
|
|
1210
|
+
if (existingTab.groups && tab.groups) {
|
|
1211
|
+
const existingGroupNames = new Set(existingTab.groups.map((g) => g.group));
|
|
1212
|
+
for (const group of tab.groups) {
|
|
1213
|
+
if (existingGroupNames.has(group.group)) {
|
|
1214
|
+
const eg = existingTab.groups.find((g) => g.group === group.group);
|
|
1215
|
+
const existingPageSet = new Set(eg.pages.map((p) => typeof p === "string" ? p : p.group));
|
|
1216
|
+
for (const page of group.pages) {
|
|
1217
|
+
const key = typeof page === "string" ? page : page.group;
|
|
1218
|
+
if (!existingPageSet.has(key)) eg.pages.push(page);
|
|
1219
|
+
}
|
|
1220
|
+
} else {
|
|
1221
|
+
existingTab.groups.push(group);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
} else if (tab.groups) {
|
|
1225
|
+
existingTab.groups = tab.groups;
|
|
1226
|
+
}
|
|
1227
|
+
} else {
|
|
1228
|
+
merged.tabs.push(tab);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
merged.tabs.push({ tab: "Changelog", href: "/changelog" });
|
|
1232
|
+
return merged;
|
|
1233
|
+
}
|
|
1234
|
+
function injectApiTab(config, specFilename) {
|
|
1235
|
+
const apiTab = { tab: "API Reference", api: { source: `/${specFilename}` } };
|
|
1236
|
+
const tabs = config.tabs.filter((t) => {
|
|
1237
|
+
if (t.tab.toLowerCase().includes("api")) return false;
|
|
1238
|
+
return true;
|
|
1239
|
+
});
|
|
1240
|
+
const changelogIdx = tabs.findIndex((t) => t.tab === "Changelog");
|
|
1241
|
+
if (changelogIdx >= 0) {
|
|
1242
|
+
tabs.splice(changelogIdx, 0, apiTab);
|
|
1243
|
+
} else {
|
|
1244
|
+
tabs.push(apiTab);
|
|
1245
|
+
}
|
|
1246
|
+
return { ...config, tabs };
|
|
1247
|
+
}
|
|
1248
|
+
function installDeps2(targetDir) {
|
|
1249
|
+
execSync3("npm install", { cwd: targetDir, stdio: "inherit" });
|
|
1250
|
+
}
|
|
1251
|
+
function initGit2(targetDir) {
|
|
1252
|
+
try {
|
|
1253
|
+
execSync3("git init", { cwd: targetDir, stdio: "inherit" });
|
|
1254
|
+
execSync3("git add -A", { cwd: targetDir, stdio: "inherit" });
|
|
1255
|
+
execSync3('git commit -m "Initial commit from create-thally-docs"', { cwd: targetDir, stdio: "inherit" });
|
|
1256
|
+
} catch {
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
async function migrateDocs(opts) {
|
|
1260
|
+
const { sourceUrl, projectDir: rawProjectDir, into, apiKey, projectName } = opts;
|
|
1261
|
+
const projectDir = resolve2(rawProjectDir);
|
|
1262
|
+
const source = parseGitHubUrl(sourceUrl);
|
|
1263
|
+
if (opts.branch) source.branch = opts.branch;
|
|
1264
|
+
if (!into) {
|
|
1265
|
+
console.log(` \u{1F3D7} Scaffolding new project at ${projectDir}...`);
|
|
1266
|
+
await scaffold({
|
|
1267
|
+
projectDir,
|
|
1268
|
+
projectName: projectName ?? "My Docs",
|
|
1269
|
+
description: `Documentation migrated from ${source.owner}/${source.repo}`,
|
|
1270
|
+
brandPreset: "primary",
|
|
1271
|
+
repoUrl: `https://github.com/${source.owner}/${source.repo}`,
|
|
1272
|
+
doInstall: false
|
|
1273
|
+
});
|
|
1274
|
+
} else {
|
|
1275
|
+
if (!existsSync6(projectDir)) {
|
|
1276
|
+
throw new Error(`Project directory "${projectDir}" does not exist.`);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
const tmpBase = mkdtempSync(join7(tmpdir(), "thally-migrate-"));
|
|
1280
|
+
const cloneDir = join7(tmpBase, "repo");
|
|
1281
|
+
console.log(` \u{1F4E6} Cloning ${source.owner}/${source.repo}...`);
|
|
1282
|
+
try {
|
|
1283
|
+
await cloneRepo(source, cloneDir);
|
|
1284
|
+
const docsDir = opts.docsDir ?? (source.docsDir || detectDocsDir(cloneDir));
|
|
1285
|
+
const docFiles = findDocFiles(cloneDir, docsDir);
|
|
1286
|
+
const docsDirLabel = docsDir ? `${docsDir}/` : "repo root";
|
|
1287
|
+
console.log(` \u{1F4C4} Found ${docFiles.length} files in ${docsDirLabel}`);
|
|
1288
|
+
if (docFiles.length === 0) {
|
|
1289
|
+
console.warn(" \u26A0 No doc files found. Check the URL and try again.");
|
|
1290
|
+
return { pagesWritten: 0, projectDir };
|
|
1291
|
+
}
|
|
1292
|
+
const platform = detectPlatform(cloneDir);
|
|
1293
|
+
const detectedNav = detectNavFromConfig(cloneDir, docsDir, platform);
|
|
1294
|
+
const openApiSpec = detectOpenApiSpec(cloneDir);
|
|
1295
|
+
if (openApiSpec) {
|
|
1296
|
+
console.log(` \u{1F50C} Found OpenAPI spec: ${openApiSpec.filename}`);
|
|
1297
|
+
}
|
|
1298
|
+
const limit = pLimit(5);
|
|
1299
|
+
let doneCount = 0;
|
|
1300
|
+
const imported = (await Promise.all(
|
|
1301
|
+
docFiles.map(
|
|
1302
|
+
(file) => limit(async () => {
|
|
1303
|
+
try {
|
|
1304
|
+
const result = await importFile(file, apiKey);
|
|
1305
|
+
doneCount++;
|
|
1306
|
+
if (result) {
|
|
1307
|
+
console.log(` [${doneCount}/${docFiles.length}] ${result.pageId}`);
|
|
1308
|
+
} else {
|
|
1309
|
+
console.log(` [${doneCount}/${docFiles.length}] ${file.pageId} (openapi \u2014 wired via spec)`);
|
|
1310
|
+
}
|
|
1311
|
+
return result;
|
|
1312
|
+
} catch (err) {
|
|
1313
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1314
|
+
if (msg.includes("no API key")) {
|
|
1315
|
+
console.warn(` \u26A0 ${msg}`);
|
|
1316
|
+
} else {
|
|
1317
|
+
console.warn(` \u26A0 Skipping ${file.relPath}: ${msg}`);
|
|
1318
|
+
}
|
|
1319
|
+
doneCount++;
|
|
1320
|
+
return null;
|
|
1321
|
+
}
|
|
1322
|
+
})
|
|
1323
|
+
)
|
|
1324
|
+
)).filter(Boolean);
|
|
1325
|
+
const pageIdSeen = /* @__PURE__ */ new Set();
|
|
1326
|
+
const deduped = imported.filter((p) => {
|
|
1327
|
+
if (pageIdSeen.has(p.pageId)) return false;
|
|
1328
|
+
pageIdSeen.add(p.pageId);
|
|
1329
|
+
return true;
|
|
1330
|
+
});
|
|
1331
|
+
const contentDir = join7(projectDir, "src", "content");
|
|
1332
|
+
let pagesWritten = 0;
|
|
1333
|
+
for (const page of deduped) {
|
|
1334
|
+
const filePath = join7(contentDir, `${page.pageId}.mdx`);
|
|
1335
|
+
mkdirSync3(dirname2(filePath), { recursive: true });
|
|
1336
|
+
const mdx = [
|
|
1337
|
+
"---",
|
|
1338
|
+
`title: "${page.frontmatter.title.replace(/"/g, '\\"')}"`,
|
|
1339
|
+
`description: "${page.frontmatter.description.replace(/"/g, '\\"')}"`,
|
|
1340
|
+
page.frontmatter.keywords.length > 0 ? `keywords: [${page.frontmatter.keywords.map((k) => `"${k.replace(/"/g, '\\"')}"`).join(", ")}]` : null,
|
|
1341
|
+
"---",
|
|
1342
|
+
"",
|
|
1343
|
+
page.body
|
|
1344
|
+
].filter((line) => line !== null).join("\n");
|
|
1345
|
+
writeFileSync5(filePath, mdx, "utf8");
|
|
1346
|
+
pagesWritten++;
|
|
1347
|
+
}
|
|
1348
|
+
let finalNav = detectedNav ?? buildNavStructure(deduped);
|
|
1349
|
+
if (openApiSpec) {
|
|
1350
|
+
const publicDir = join7(projectDir, "public");
|
|
1351
|
+
mkdirSync3(publicDir, { recursive: true });
|
|
1352
|
+
copyFileSync(openApiSpec.absPath, join7(publicDir, openApiSpec.filename));
|
|
1353
|
+
console.log(` \u{1F4CB} Copied ${openApiSpec.filename} \u2192 public/${openApiSpec.filename}`);
|
|
1354
|
+
finalNav = injectApiTab(finalNav, openApiSpec.filename);
|
|
1355
|
+
}
|
|
1356
|
+
if (into && existsSync6(join7(projectDir, "docs.json"))) {
|
|
1357
|
+
const existing = readDocsJson(projectDir);
|
|
1358
|
+
const merged = mergeDocsJson(existing, finalNav);
|
|
1359
|
+
writeDocsJson(projectDir, merged);
|
|
1360
|
+
} else {
|
|
1361
|
+
writeDocsJson(projectDir, finalNav);
|
|
1362
|
+
}
|
|
1363
|
+
if (!into) {
|
|
1364
|
+
installDeps2(projectDir);
|
|
1365
|
+
initGit2(projectDir);
|
|
1366
|
+
}
|
|
1367
|
+
return { pagesWritten, projectDir };
|
|
1368
|
+
} finally {
|
|
1369
|
+
rmSync(tmpBase, { recursive: true, force: true });
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// src/tools/migrate-docs.ts
|
|
1374
|
+
var migrateDocsSchema = z6.object({
|
|
1375
|
+
sourceUrl: z6.string().describe("GitHub URL of the docs repo to migrate"),
|
|
1376
|
+
projectDir: z6.string().describe("Path for new project or existing project dir"),
|
|
1377
|
+
into: z6.boolean().optional().default(false).describe("Migrate into existing project instead of scaffolding"),
|
|
1378
|
+
branch: z6.string().optional().describe("Git branch (default: auto-detect)"),
|
|
1379
|
+
docsDir: z6.string().optional().describe("Docs subdirectory in repo (default: auto-detect)"),
|
|
1380
|
+
apiKey: z6.string().optional().describe("Anthropic API key for non-Markdown file conversion")
|
|
1381
|
+
});
|
|
1382
|
+
async function handleMigrateDocs(input) {
|
|
1383
|
+
const apiKey = input.apiKey ?? process.env.ANTHROPIC_API_KEY;
|
|
1384
|
+
const result = await migrateDocs({
|
|
1385
|
+
sourceUrl: input.sourceUrl,
|
|
1386
|
+
projectDir: input.projectDir,
|
|
1387
|
+
into: input.into ?? false,
|
|
1388
|
+
apiKey,
|
|
1389
|
+
branch: input.branch,
|
|
1390
|
+
docsDir: input.docsDir,
|
|
1391
|
+
yes: true
|
|
1392
|
+
});
|
|
1393
|
+
return `Migration complete! ${result.pagesWritten} pages written to ${result.projectDir}/src/content/`;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// src/tools/search-docs.ts
|
|
1397
|
+
import { z as z7 } from "zod";
|
|
1398
|
+
import { readdirSync as readdirSync3, statSync as statSync2, readFileSync as readFileSync7, existsSync as existsSync7 } from "fs";
|
|
1399
|
+
import { join as join8, relative as relative2, extname as extname3 } from "path";
|
|
1400
|
+
import matter3 from "gray-matter";
|
|
1401
|
+
var searchDocsSchema = z7.object({
|
|
1402
|
+
projectDir: z7.string().describe("Path to the Thally project root"),
|
|
1403
|
+
query: z7.string().describe("Search query"),
|
|
1404
|
+
limit: z7.number().optional().default(5).describe("Max results to return (default 5)")
|
|
1405
|
+
});
|
|
1406
|
+
function scanMdxFiles(dir, results) {
|
|
1407
|
+
let entries;
|
|
1408
|
+
try {
|
|
1409
|
+
entries = readdirSync3(dir);
|
|
1410
|
+
} catch {
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
for (const entry of entries) {
|
|
1414
|
+
const fullPath = join8(dir, entry);
|
|
1415
|
+
try {
|
|
1416
|
+
const stat = statSync2(fullPath);
|
|
1417
|
+
if (stat.isDirectory()) {
|
|
1418
|
+
scanMdxFiles(fullPath, results);
|
|
1419
|
+
} else if (extname3(entry).toLowerCase() === ".mdx") {
|
|
1420
|
+
results.push(fullPath);
|
|
1421
|
+
}
|
|
1422
|
+
} catch {
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
function scoreFiles(files, contentDir, query) {
|
|
1427
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
1428
|
+
const results = [];
|
|
1429
|
+
for (const filePath of files) {
|
|
1430
|
+
let raw;
|
|
1431
|
+
try {
|
|
1432
|
+
raw = readFileSync7(filePath, "utf8");
|
|
1433
|
+
} catch {
|
|
1434
|
+
continue;
|
|
1435
|
+
}
|
|
1436
|
+
const { data, content } = matter3(raw);
|
|
1437
|
+
const title = data.title ?? "";
|
|
1438
|
+
const description = data.description ?? "";
|
|
1439
|
+
const keywords = data.keywords ?? [];
|
|
1440
|
+
const pageId = relative2(contentDir, filePath).replace(/\.mdx$/, "").replace(/\\/g, "/");
|
|
1441
|
+
let score = 0;
|
|
1442
|
+
for (const term of terms) {
|
|
1443
|
+
if (title.toLowerCase().includes(term)) score += 3;
|
|
1444
|
+
if (description.toLowerCase().includes(term)) score += 2;
|
|
1445
|
+
if (keywords.some((k) => k.toLowerCase().includes(term))) score += 2;
|
|
1446
|
+
const bodyOccurrences = content.toLowerCase().split(term).length - 1;
|
|
1447
|
+
score += Math.min(bodyOccurrences, 5);
|
|
1448
|
+
}
|
|
1449
|
+
if (score > 0) {
|
|
1450
|
+
results.push({ pageId, title, description, score });
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
return results.sort((a, b) => b.score - a.score);
|
|
1454
|
+
}
|
|
1455
|
+
async function handleSearchDocs(input) {
|
|
1456
|
+
const { projectDir, query, limit = 5 } = input;
|
|
1457
|
+
const contentDir = join8(projectDir, "src", "content");
|
|
1458
|
+
if (!existsSync7(contentDir)) {
|
|
1459
|
+
throw new Error(`Content directory not found: ${contentDir}`);
|
|
1460
|
+
}
|
|
1461
|
+
const files = [];
|
|
1462
|
+
scanMdxFiles(contentDir, files);
|
|
1463
|
+
const results = scoreFiles(files, contentDir, query).slice(0, limit);
|
|
1464
|
+
if (results.length === 0) {
|
|
1465
|
+
return `No results found for "${query}".`;
|
|
1466
|
+
}
|
|
1467
|
+
const lines = [`Found ${results.length} result${results.length > 1 ? "s" : ""} for "${query}":
|
|
1468
|
+
`];
|
|
1469
|
+
results.forEach((r, i) => {
|
|
1470
|
+
lines.push(`${i + 1}. ${r.title || r.pageId} \u2014 ${r.pageId}`);
|
|
1471
|
+
if (r.description) lines.push(` ${r.description}`);
|
|
1472
|
+
lines.push("");
|
|
1473
|
+
});
|
|
1474
|
+
return lines.join("\n").trimEnd();
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// src/tools/semantic-search.ts
|
|
1478
|
+
import { z as z8 } from "zod";
|
|
1479
|
+
var semanticSearchSchema = z8.object({
|
|
1480
|
+
siteUrl: z8.string().describe("Base URL of the deployed Thally site (e.g. https://docs.example.com)"),
|
|
1481
|
+
query: z8.string().describe("Natural-language search query"),
|
|
1482
|
+
limit: z8.number().optional().default(8).describe("Max results to return (default 8)"),
|
|
1483
|
+
mode: z8.enum(["hybrid", "fulltext"]).optional().default("hybrid").describe("Search mode: hybrid (full-text + vector) or fulltext")
|
|
1484
|
+
});
|
|
1485
|
+
async function handleSemanticSearch(input) {
|
|
1486
|
+
const { siteUrl, query, limit = 8, mode = "hybrid" } = input;
|
|
1487
|
+
const base = siteUrl.replace(/\/$/, "");
|
|
1488
|
+
const url = `${base}/api/search?q=${encodeURIComponent(query)}&limit=${limit}&mode=${mode}`;
|
|
1489
|
+
let response;
|
|
1490
|
+
try {
|
|
1491
|
+
response = await fetch(url, { headers: { Accept: "application/json" } });
|
|
1492
|
+
} catch (err) {
|
|
1493
|
+
throw new Error(`Failed to reach ${url}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1494
|
+
}
|
|
1495
|
+
if (!response.ok) {
|
|
1496
|
+
throw new Error(`Search request failed: ${response.status} ${response.statusText}`);
|
|
1497
|
+
}
|
|
1498
|
+
const data = await response.json();
|
|
1499
|
+
if (!data.results || data.results.length === 0) {
|
|
1500
|
+
return `No results found for "${query}".`;
|
|
1501
|
+
}
|
|
1502
|
+
const lines = [`Found ${data.total} result${data.total === 1 ? "" : "s"} for "${query}" (${data.mode}):
|
|
1503
|
+
`];
|
|
1504
|
+
data.results.forEach((result, index) => {
|
|
1505
|
+
lines.push(`${index + 1}. ${result.title} \u2014 ${result.url}`);
|
|
1506
|
+
if (result.snippet) lines.push(` ${result.snippet}`);
|
|
1507
|
+
lines.push(` API: ${result.api_url}`);
|
|
1508
|
+
lines.push("");
|
|
1509
|
+
});
|
|
1510
|
+
return lines.join("\n").trimEnd();
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// src/tools/agent-readiness.ts
|
|
1514
|
+
import { z as z9 } from "zod";
|
|
1515
|
+
var agentReadinessSchema = z9.object({
|
|
1516
|
+
siteUrl: z9.string().describe("Base URL of the deployed Thally site (e.g. https://docs.example.com)"),
|
|
1517
|
+
minScore: z9.number().optional().describe("Optional threshold (0-100). If set, the summary flags whether the site passes.")
|
|
1518
|
+
});
|
|
1519
|
+
async function handleAgentReadiness(input) {
|
|
1520
|
+
const { siteUrl, minScore } = input;
|
|
1521
|
+
const base = siteUrl.replace(/\/$/, "");
|
|
1522
|
+
const url = `${base}/api/agent-readiness`;
|
|
1523
|
+
let response;
|
|
1524
|
+
try {
|
|
1525
|
+
response = await fetch(url, { headers: { Accept: "application/json" } });
|
|
1526
|
+
} catch (err) {
|
|
1527
|
+
throw new Error(`Failed to reach ${url}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1528
|
+
}
|
|
1529
|
+
if (!response.ok) {
|
|
1530
|
+
throw new Error(`Agent readiness request failed: ${response.status} ${response.statusText}`);
|
|
1531
|
+
}
|
|
1532
|
+
const data = await response.json();
|
|
1533
|
+
const lines = [
|
|
1534
|
+
`Agent Readiness: ${data.score}/100 (grade ${data.grade}) across ${data.totalPages} pages.`
|
|
1535
|
+
];
|
|
1536
|
+
if (typeof minScore === "number") {
|
|
1537
|
+
lines.push(data.score >= minScore ? `PASS (>= ${minScore})` : `FAIL (< ${minScore})`);
|
|
1538
|
+
}
|
|
1539
|
+
lines.push("", "Subscores:");
|
|
1540
|
+
for (const sub of data.subscores) {
|
|
1541
|
+
const pct = Math.round(sub.score * 100);
|
|
1542
|
+
const status = sub.available ? `${pct}%` : "n/a";
|
|
1543
|
+
lines.push(`- ${sub.label} (weight ${sub.weight}): ${status} \u2014 ${sub.detail}`);
|
|
1544
|
+
for (const offender of sub.offenders.slice(0, 3)) {
|
|
1545
|
+
lines.push(` \u2022 ${offender.href}: ${offender.reason}`);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
return lines.join("\n").trimEnd();
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// src/tools/read-page.ts
|
|
1552
|
+
import { z as z10 } from "zod";
|
|
1553
|
+
import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
|
|
1554
|
+
import { join as join9 } from "path";
|
|
1555
|
+
import matter4 from "gray-matter";
|
|
1556
|
+
var readPageSchema = z10.object({
|
|
1557
|
+
projectDir: z10.string().describe("Path to the Thally project root"),
|
|
1558
|
+
pageId: z10.string().describe('Page ID, e.g. "guides/authentication"')
|
|
1559
|
+
});
|
|
1560
|
+
async function handleReadPage(input) {
|
|
1561
|
+
const { projectDir, pageId } = input;
|
|
1562
|
+
const contentDir = join9(projectDir, "src", "content");
|
|
1563
|
+
const candidates = [
|
|
1564
|
+
join9(contentDir, `${pageId}.mdx`),
|
|
1565
|
+
join9(contentDir, `${pageId}/index.mdx`)
|
|
1566
|
+
];
|
|
1567
|
+
let filePath = null;
|
|
1568
|
+
for (const c of candidates) {
|
|
1569
|
+
if (existsSync8(c)) {
|
|
1570
|
+
filePath = c;
|
|
1571
|
+
break;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
if (!filePath) {
|
|
1575
|
+
throw new Error(`Page not found: "${pageId}". No file at src/content/${pageId}.mdx`);
|
|
1576
|
+
}
|
|
1577
|
+
const raw = readFileSync8(filePath, "utf8");
|
|
1578
|
+
const { data, content } = matter4(raw);
|
|
1579
|
+
const title = data.title ?? pageId;
|
|
1580
|
+
const description = data.description ?? "";
|
|
1581
|
+
const lines = [`# ${title}`, `*${pageId}*`, ""];
|
|
1582
|
+
if (description) {
|
|
1583
|
+
lines.push(`> ${description}`);
|
|
1584
|
+
lines.push("");
|
|
1585
|
+
}
|
|
1586
|
+
lines.push("---", "", content.trim());
|
|
1587
|
+
return lines.join("\n");
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
// src/tools/get-context.ts
|
|
1591
|
+
import { z as z11 } from "zod";
|
|
1592
|
+
import { existsSync as existsSync9, readFileSync as readFileSync9 } from "fs";
|
|
1593
|
+
import { join as join10 } from "path";
|
|
1594
|
+
import matter5 from "gray-matter";
|
|
1595
|
+
var getContextSchema = z11.object({
|
|
1596
|
+
projectDir: z11.string().describe("Path to the Thally project root"),
|
|
1597
|
+
topic: z11.string().describe("Topic or question to find relevant docs for"),
|
|
1598
|
+
maxTokens: z11.number().optional().default(4e3).describe("Approximate token budget for returned context (default 4000)")
|
|
1599
|
+
});
|
|
1600
|
+
async function handleGetContext(input) {
|
|
1601
|
+
const { projectDir, topic, maxTokens = 4e3 } = input;
|
|
1602
|
+
const contentDir = join10(projectDir, "src", "content");
|
|
1603
|
+
if (!existsSync9(contentDir)) {
|
|
1604
|
+
throw new Error(`Content directory not found: ${contentDir}`);
|
|
1605
|
+
}
|
|
1606
|
+
const files = [];
|
|
1607
|
+
scanMdxFiles(contentDir, files);
|
|
1608
|
+
const scored = scoreFiles(files, contentDir, topic).slice(0, 10);
|
|
1609
|
+
if (scored.length === 0) {
|
|
1610
|
+
return `No relevant documentation found for "${topic}".`;
|
|
1611
|
+
}
|
|
1612
|
+
const charBudget = Math.floor(maxTokens * 4 * 0.8);
|
|
1613
|
+
let usedChars = 0;
|
|
1614
|
+
const sections = [];
|
|
1615
|
+
for (const result of scored) {
|
|
1616
|
+
const candidates = [
|
|
1617
|
+
join10(contentDir, `${result.pageId}.mdx`),
|
|
1618
|
+
join10(contentDir, `${result.pageId}/index.mdx`)
|
|
1619
|
+
];
|
|
1620
|
+
let content = "";
|
|
1621
|
+
for (const c of candidates) {
|
|
1622
|
+
if (existsSync9(c)) {
|
|
1623
|
+
const raw = readFileSync9(c, "utf8");
|
|
1624
|
+
const { content: body } = matter5(raw);
|
|
1625
|
+
content = body.trim();
|
|
1626
|
+
break;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
if (!content) continue;
|
|
1630
|
+
const section = [
|
|
1631
|
+
`## ${result.title || result.pageId} (${result.pageId})`,
|
|
1632
|
+
result.description ? `> ${result.description}` : "",
|
|
1633
|
+
"",
|
|
1634
|
+
content
|
|
1635
|
+
].filter((l) => l !== null).join("\n");
|
|
1636
|
+
if (usedChars + section.length > charBudget) break;
|
|
1637
|
+
sections.push(section);
|
|
1638
|
+
usedChars += section.length;
|
|
1639
|
+
}
|
|
1640
|
+
if (sections.length === 0) {
|
|
1641
|
+
return `No relevant documentation found for "${topic}".`;
|
|
1642
|
+
}
|
|
1643
|
+
return sections.join("\n\n---\n\n");
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
// src/tools/lint-project.ts
|
|
1647
|
+
import { z as z12 } from "zod";
|
|
1648
|
+
import { existsSync as existsSync10, readFileSync as readFileSync10 } from "fs";
|
|
1649
|
+
import { join as join11 } from "path";
|
|
1650
|
+
import matter6 from "gray-matter";
|
|
1651
|
+
var lintProjectSchema = z12.object({
|
|
1652
|
+
projectDir: z12.string().describe("Path to the Thally project root"),
|
|
1653
|
+
fix: z12.boolean().optional().default(false).describe("Auto-fix issues where possible (adds orphan pages to nav)")
|
|
1654
|
+
});
|
|
1655
|
+
function collectNavPageIds(groups, seen, duplicates) {
|
|
1656
|
+
for (const page of groups) {
|
|
1657
|
+
if (typeof page === "string") {
|
|
1658
|
+
if (seen.has(page)) {
|
|
1659
|
+
duplicates.add(page);
|
|
1660
|
+
} else {
|
|
1661
|
+
seen.add(page);
|
|
1662
|
+
}
|
|
1663
|
+
} else if (page.pages) {
|
|
1664
|
+
collectNavPageIds(page.pages, seen, duplicates);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
function addOrphanToNav(projectDir, pageId) {
|
|
1669
|
+
const config = readDocsJson(projectDir);
|
|
1670
|
+
const tab = config.tabs.find((t) => !t.href && !t.api && t.groups && t.groups.length > 0);
|
|
1671
|
+
if (!tab || !tab.groups) return;
|
|
1672
|
+
const lastGroup = tab.groups[tab.groups.length - 1];
|
|
1673
|
+
const existing = lastGroup.pages.filter((p) => typeof p === "string");
|
|
1674
|
+
if (!existing.includes(pageId)) {
|
|
1675
|
+
lastGroup.pages.push(pageId);
|
|
1676
|
+
writeDocsJson(projectDir, config);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
async function handleLintProject(input) {
|
|
1680
|
+
const { projectDir, fix = false } = input;
|
|
1681
|
+
const contentDir = join11(projectDir, "src", "content");
|
|
1682
|
+
const issues = [];
|
|
1683
|
+
if (!existsSync10(join11(projectDir, "docs.json"))) {
|
|
1684
|
+
throw new Error(`Not a Thally project: docs.json not found in ${projectDir}`);
|
|
1685
|
+
}
|
|
1686
|
+
const config = readDocsJson(projectDir);
|
|
1687
|
+
const navPageIds = /* @__PURE__ */ new Set();
|
|
1688
|
+
const duplicates = /* @__PURE__ */ new Set();
|
|
1689
|
+
for (const tab of config.tabs) {
|
|
1690
|
+
if (tab.href || tab.api) continue;
|
|
1691
|
+
if (!tab.groups || tab.groups.length === 0) {
|
|
1692
|
+
issues.push({ severity: "error", message: `Tab "${tab.tab}" has no groups and no href \u2014 it will render empty` });
|
|
1693
|
+
continue;
|
|
1694
|
+
}
|
|
1695
|
+
collectNavPageIds(tab.groups.map((g) => g), navPageIds, duplicates);
|
|
1696
|
+
}
|
|
1697
|
+
for (const dup of duplicates) {
|
|
1698
|
+
issues.push({ severity: "error", message: `[duplicate] "${dup}" appears more than once in docs.json` });
|
|
1699
|
+
}
|
|
1700
|
+
for (const pageId of navPageIds) {
|
|
1701
|
+
const candidates = [
|
|
1702
|
+
join11(contentDir, `${pageId}.mdx`),
|
|
1703
|
+
join11(contentDir, `${pageId}/index.mdx`)
|
|
1704
|
+
];
|
|
1705
|
+
if (!candidates.some((c) => existsSync10(c))) {
|
|
1706
|
+
issues.push({
|
|
1707
|
+
severity: "error",
|
|
1708
|
+
message: `"${pageId}" is in docs.json but has no MDX file`,
|
|
1709
|
+
file: `src/content/${pageId}.mdx`
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
const allFiles = [];
|
|
1714
|
+
if (existsSync10(contentDir)) {
|
|
1715
|
+
scanMdxFiles(contentDir, allFiles);
|
|
1716
|
+
}
|
|
1717
|
+
const fixedOrphans = [];
|
|
1718
|
+
for (const filePath of allFiles) {
|
|
1719
|
+
const rel = filePath.slice(contentDir.length + 1).replace(/\.mdx$/, "").replace(/\\/g, "/");
|
|
1720
|
+
const pageId = rel.endsWith("/index") ? rel.slice(0, -6) : rel;
|
|
1721
|
+
if (!navPageIds.has(pageId)) {
|
|
1722
|
+
if (fix) {
|
|
1723
|
+
addOrphanToNav(projectDir, pageId);
|
|
1724
|
+
fixedOrphans.push(pageId);
|
|
1725
|
+
} else {
|
|
1726
|
+
issues.push({ severity: "warning", message: `"${pageId}" is not in docs.json nav (orphan)`, file: filePath.slice(projectDir.length + 1) });
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
let data = {};
|
|
1730
|
+
let content = "";
|
|
1731
|
+
try {
|
|
1732
|
+
const raw = readFileSync10(filePath, "utf8");
|
|
1733
|
+
const parsed = matter6(raw);
|
|
1734
|
+
data = parsed.data;
|
|
1735
|
+
content = parsed.content;
|
|
1736
|
+
} catch {
|
|
1737
|
+
issues.push({ severity: "error", message: `Could not parse frontmatter`, file: filePath.slice(projectDir.length + 1) });
|
|
1738
|
+
continue;
|
|
1739
|
+
}
|
|
1740
|
+
if (!data.title) {
|
|
1741
|
+
issues.push({ severity: "warning", message: `Missing "title" in frontmatter`, file: filePath.slice(projectDir.length + 1) });
|
|
1742
|
+
}
|
|
1743
|
+
if (!data.description) {
|
|
1744
|
+
issues.push({ severity: "warning", message: `Missing "description" in frontmatter`, file: filePath.slice(projectDir.length + 1) });
|
|
1745
|
+
}
|
|
1746
|
+
if (content.trim().length < 50) {
|
|
1747
|
+
issues.push({ severity: "warning", message: `Very short body (${content.trim().length} chars) \u2014 page may be empty`, file: filePath.slice(projectDir.length + 1) });
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
const errors = issues.filter((i) => i.severity === "error");
|
|
1751
|
+
const warnings = issues.filter((i) => i.severity === "warning");
|
|
1752
|
+
const lines = [`Linting ${projectDir}...
|
|
1753
|
+
`];
|
|
1754
|
+
if (errors.length === 0 && warnings.length === 0 && fixedOrphans.length === 0) {
|
|
1755
|
+
lines.push("\u2705 No issues found.");
|
|
1756
|
+
return lines.join("\n");
|
|
1757
|
+
}
|
|
1758
|
+
lines.push(`\u274C ${errors.length} error${errors.length !== 1 ? "s" : ""}, \u26A0\uFE0F ${warnings.length} warning${warnings.length !== 1 ? "s" : ""}
|
|
1759
|
+
`);
|
|
1760
|
+
if (errors.length > 0) {
|
|
1761
|
+
lines.push("ERRORS:");
|
|
1762
|
+
for (const issue of errors) {
|
|
1763
|
+
lines.push(` ${issue.message}`);
|
|
1764
|
+
if (issue.file) lines.push(` \u2192 ${issue.file}`);
|
|
1765
|
+
lines.push("");
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
if (warnings.length > 0) {
|
|
1769
|
+
lines.push("WARNINGS:");
|
|
1770
|
+
for (const issue of warnings) {
|
|
1771
|
+
lines.push(` ${issue.message}`);
|
|
1772
|
+
if (issue.file) lines.push(` \u2192 ${issue.file}`);
|
|
1773
|
+
lines.push("");
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
if (fixedOrphans.length > 0) {
|
|
1777
|
+
lines.push(`\u2705 Auto-fixed ${fixedOrphans.length} orphan page${fixedOrphans.length > 1 ? "s" : ""} (added to nav):`);
|
|
1778
|
+
for (const p of fixedOrphans) lines.push(` + ${p}`);
|
|
1779
|
+
lines.push("");
|
|
1780
|
+
}
|
|
1781
|
+
if (!fix && warnings.some((w) => w.message.includes("orphan"))) {
|
|
1782
|
+
lines.push("Tip: run with fix: true to auto-add orphan pages to navigation.");
|
|
1783
|
+
}
|
|
1784
|
+
return lines.join("\n").trimEnd();
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
// src/tools/translate-docs.ts
|
|
1788
|
+
import { z as z13 } from "zod";
|
|
1789
|
+
import { readFileSync as readFileSync11, writeFileSync as writeFileSync7, existsSync as existsSync11, mkdirSync as mkdirSync4 } from "fs";
|
|
1790
|
+
import { join as join12, dirname as dirname3 } from "path";
|
|
1791
|
+
import matter7 from "gray-matter";
|
|
1792
|
+
import Anthropic2 from "@anthropic-ai/sdk";
|
|
1793
|
+
import pLimit2 from "p-limit";
|
|
1794
|
+
var translateDocsSchema = z13.object({
|
|
1795
|
+
projectDir: z13.string().describe("Path to the Thally project directory"),
|
|
1796
|
+
locale: z13.string().describe('Target locale code, e.g. "es", "fr"'),
|
|
1797
|
+
pages: z13.array(z13.string()).optional().describe("Page IDs to translate (omit for all pages)"),
|
|
1798
|
+
force: z13.boolean().optional().default(false).describe("Overwrite existing translation files"),
|
|
1799
|
+
apiKey: z13.string().optional().describe("Anthropic API key (falls back to ANTHROPIC_API_KEY env var)"),
|
|
1800
|
+
model: z13.string().optional().default("claude-sonnet-4-6").describe("Claude model to use for translation")
|
|
1801
|
+
});
|
|
1802
|
+
function readDocsJson2(projectDir) {
|
|
1803
|
+
const docsPath = join12(projectDir, "docs.json");
|
|
1804
|
+
const raw = readFileSync11(docsPath, "utf8");
|
|
1805
|
+
return JSON.parse(raw);
|
|
1806
|
+
}
|
|
1807
|
+
function collectPageIds(pages) {
|
|
1808
|
+
const ids = [];
|
|
1809
|
+
for (const page of pages) {
|
|
1810
|
+
if (typeof page === "string") {
|
|
1811
|
+
ids.push(page);
|
|
1812
|
+
} else if (page && typeof page === "object" && "pages" in page) {
|
|
1813
|
+
ids.push(...collectPageIds(page.pages));
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
return ids;
|
|
1817
|
+
}
|
|
1818
|
+
function getAllPageIds(config) {
|
|
1819
|
+
const ids = [];
|
|
1820
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1821
|
+
const hrefOnlyPages = [];
|
|
1822
|
+
for (const tab of config.tabs) {
|
|
1823
|
+
if (tab.api && !tab.groups) continue;
|
|
1824
|
+
if (!tab.groups && tab.href) {
|
|
1825
|
+
const pageId = tab.href.replace(/^\//, "");
|
|
1826
|
+
if (pageId && !seen.has(pageId)) {
|
|
1827
|
+
seen.add(pageId);
|
|
1828
|
+
ids.push(pageId);
|
|
1829
|
+
hrefOnlyPages.push({ tab: tab.tab, pageId });
|
|
1830
|
+
}
|
|
1831
|
+
continue;
|
|
1832
|
+
}
|
|
1833
|
+
if (!tab.groups) continue;
|
|
1834
|
+
for (const group of tab.groups) {
|
|
1835
|
+
for (const id of collectPageIds(group.pages)) {
|
|
1836
|
+
if (!seen.has(id)) {
|
|
1837
|
+
seen.add(id);
|
|
1838
|
+
ids.push(id);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
return { ids, hrefOnlyPages };
|
|
1844
|
+
}
|
|
1845
|
+
function findSourceFile(projectDir, pageId) {
|
|
1846
|
+
const contentRoot = join12(projectDir, "src", "content");
|
|
1847
|
+
const candidates = [
|
|
1848
|
+
join12(contentRoot, `${pageId}.mdx`),
|
|
1849
|
+
join12(contentRoot, `${pageId}/index.mdx`)
|
|
1850
|
+
];
|
|
1851
|
+
return candidates.find((p) => existsSync11(p)) ?? null;
|
|
1852
|
+
}
|
|
1853
|
+
var TRANSLATION_SYSTEM_PROMPT = `You are a professional documentation translator. You will receive an MDX documentation file and translate it into the target language.
|
|
1854
|
+
|
|
1855
|
+
CRITICAL RULES \u2014 follow exactly:
|
|
1856
|
+
1. Translate ALL prose text, headings, and paragraphs.
|
|
1857
|
+
2. Translate frontmatter fields: title, description, and keywords values.
|
|
1858
|
+
3. DO NOT translate or modify MDX component names (e.g. <Note>, <Warning>, <Steps>, <Step>, <CodeGroup>, <Tabs>, <Tab>, <Card>, <Accordion>, <Columns>).
|
|
1859
|
+
4. DO NOT translate component prop names or prop values that are identifiers.
|
|
1860
|
+
5. DO NOT translate content inside code blocks (\`\`\` ... \`\`\`).
|
|
1861
|
+
6. DO NOT translate inline code spans (\`...\`).
|
|
1862
|
+
7. DO NOT translate URLs, file paths, or import statements.
|
|
1863
|
+
8. Preserve ALL whitespace, blank lines, and indentation exactly as in the original.
|
|
1864
|
+
9. Preserve ALL frontmatter YAML structure exactly \u2014 only translate the string values.
|
|
1865
|
+
10. Output ONLY the translated MDX file content \u2014 no preamble, no explanation, no markdown fences.`;
|
|
1866
|
+
async function translatePage(sourceContent, targetLocaleLabel, targetLocaleCode, model, client) {
|
|
1867
|
+
const message = await client.messages.create({
|
|
1868
|
+
model,
|
|
1869
|
+
max_tokens: 8192,
|
|
1870
|
+
system: TRANSLATION_SYSTEM_PROMPT,
|
|
1871
|
+
messages: [
|
|
1872
|
+
{
|
|
1873
|
+
role: "user",
|
|
1874
|
+
content: `Translate the following MDX documentation file to ${targetLocaleLabel} (locale code: ${targetLocaleCode}). Output ONLY the translated MDX content.
|
|
1875
|
+
|
|
1876
|
+
${sourceContent}`
|
|
1877
|
+
}
|
|
1878
|
+
]
|
|
1879
|
+
});
|
|
1880
|
+
const text = message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
1881
|
+
return text.trim();
|
|
1882
|
+
}
|
|
1883
|
+
async function handleTranslateDocs(input) {
|
|
1884
|
+
const { projectDir, locale, pages, force = false, model = "claude-sonnet-4-6" } = input;
|
|
1885
|
+
const apiKey = input.apiKey ?? process.env.ANTHROPIC_API_KEY;
|
|
1886
|
+
if (!apiKey) {
|
|
1887
|
+
throw new Error("Anthropic API key required. Set ANTHROPIC_API_KEY or pass apiKey.");
|
|
1888
|
+
}
|
|
1889
|
+
const config = readDocsJson2(projectDir);
|
|
1890
|
+
if (!config.i18n) {
|
|
1891
|
+
throw new Error('No i18n config found in docs.json. Add an "i18n" block first.');
|
|
1892
|
+
}
|
|
1893
|
+
const targetLocale = config.i18n.locales.find((l) => l.code === locale);
|
|
1894
|
+
if (!targetLocale) {
|
|
1895
|
+
const available = config.i18n.locales.map((l) => l.code).join(", ");
|
|
1896
|
+
throw new Error(`Locale "${locale}" not found in docs.json. Available: ${available}`);
|
|
1897
|
+
}
|
|
1898
|
+
if (locale === config.i18n.defaultLocale) {
|
|
1899
|
+
throw new Error(`Cannot translate to the default locale "${locale}".`);
|
|
1900
|
+
}
|
|
1901
|
+
const { ids: allPageIds, hrefOnlyPages } = getAllPageIds(config);
|
|
1902
|
+
const targetPageIds = pages ?? allPageIds;
|
|
1903
|
+
const contentRoot = join12(projectDir, "src", "content");
|
|
1904
|
+
const toTranslate = [];
|
|
1905
|
+
const skipped = [];
|
|
1906
|
+
for (const pageId of targetPageIds) {
|
|
1907
|
+
const sourceFile = findSourceFile(projectDir, pageId);
|
|
1908
|
+
if (!sourceFile) {
|
|
1909
|
+
skipped.push(`${pageId} (source not found)`);
|
|
1910
|
+
continue;
|
|
1911
|
+
}
|
|
1912
|
+
const relativeFromContent = sourceFile.slice(contentRoot.length + 1);
|
|
1913
|
+
const targetFile = join12(contentRoot, locale, relativeFromContent);
|
|
1914
|
+
if (existsSync11(targetFile) && !force) {
|
|
1915
|
+
skipped.push(`${pageId} (already translated)`);
|
|
1916
|
+
continue;
|
|
1917
|
+
}
|
|
1918
|
+
toTranslate.push({ pageId, sourceFile, targetFile });
|
|
1919
|
+
}
|
|
1920
|
+
if (toTranslate.length === 0) {
|
|
1921
|
+
return `Nothing to translate. ${skipped.length} page(s) skipped.`;
|
|
1922
|
+
}
|
|
1923
|
+
const client = new Anthropic2({ apiKey });
|
|
1924
|
+
const limit = pLimit2(3);
|
|
1925
|
+
const results = [];
|
|
1926
|
+
await Promise.all(
|
|
1927
|
+
toTranslate.map(
|
|
1928
|
+
({ pageId, sourceFile, targetFile }) => limit(async () => {
|
|
1929
|
+
try {
|
|
1930
|
+
const sourceContent = readFileSync11(sourceFile, "utf8");
|
|
1931
|
+
const parsed = matter7(sourceContent);
|
|
1932
|
+
if (!parsed.data.title) {
|
|
1933
|
+
console.warn(`[translate] ${pageId}: missing title in frontmatter`);
|
|
1934
|
+
}
|
|
1935
|
+
const translated = await translatePage(sourceContent, targetLocale.label, locale, model, client);
|
|
1936
|
+
mkdirSync4(dirname3(targetFile), { recursive: true });
|
|
1937
|
+
writeFileSync7(targetFile, translated + "\n", "utf8");
|
|
1938
|
+
results.push({ pageId, success: true });
|
|
1939
|
+
} catch (err) {
|
|
1940
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1941
|
+
results.push({ pageId, success: false, error: msg });
|
|
1942
|
+
}
|
|
1943
|
+
})
|
|
1944
|
+
)
|
|
1945
|
+
);
|
|
1946
|
+
const succeeded = results.filter((r) => r.success);
|
|
1947
|
+
const failed = results.filter((r) => !r.success);
|
|
1948
|
+
const lines = [
|
|
1949
|
+
`\u2705 Translation to ${targetLocale.label} (${locale}) complete!`,
|
|
1950
|
+
"",
|
|
1951
|
+
` ${succeeded.length}/${toTranslate.length} pages translated successfully`
|
|
1952
|
+
];
|
|
1953
|
+
if (hrefOnlyPages.length > 0 && !pages) {
|
|
1954
|
+
const labels = hrefOnlyPages.map(({ tab, pageId }) => `${tab} (${pageId}.mdx)`).join(", ");
|
|
1955
|
+
lines.push("", `\u2139 Standalone tab page(s) included: ${labels}`);
|
|
1956
|
+
}
|
|
1957
|
+
if (succeeded.length > 0) {
|
|
1958
|
+
lines.push("", "Translated pages:");
|
|
1959
|
+
for (const r of succeeded) {
|
|
1960
|
+
lines.push(` \u2713 ${r.pageId}`);
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
if (failed.length > 0) {
|
|
1964
|
+
lines.push("", "Failed:");
|
|
1965
|
+
for (const r of failed) {
|
|
1966
|
+
lines.push(` \u2717 ${r.pageId}: ${r.error}`);
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
if (skipped.length > 0) {
|
|
1970
|
+
lines.push("", `${skipped.length} page(s) skipped`);
|
|
1971
|
+
}
|
|
1972
|
+
lines.push("", `Files written to: ${contentRoot}/${locale}/`);
|
|
1973
|
+
return lines.join("\n");
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// src/tools/sync-from-repo.ts
|
|
1977
|
+
import { z as z14 } from "zod";
|
|
1978
|
+
|
|
1979
|
+
// src/lib/track.ts
|
|
1980
|
+
import { createSign, createHash } from "crypto";
|
|
1981
|
+
function parseOwnerRepo(spec) {
|
|
1982
|
+
const trimmed = spec.trim();
|
|
1983
|
+
const url = trimmed.match(
|
|
1984
|
+
/^https?:\/\/github\.com\/([^/\s]+)\/([^/\s#?]+?)(?:\.git)?(?:\/pull\/(\d+))?(?:[/#?].*)?$/i
|
|
1985
|
+
);
|
|
1986
|
+
if (url) return { owner: url[1], repo: url[2], ...url[3] ? { pr: Number(url[3]) } : {} };
|
|
1987
|
+
const plain = trimmed.match(/^([A-Za-z0-9-_.]+)\/([A-Za-z0-9-_.]+?)(?:#(\d+))?$/);
|
|
1988
|
+
if (!plain) return null;
|
|
1989
|
+
return { owner: plain[1], repo: plain[2], ...plain[3] ? { pr: Number(plain[3]) } : {} };
|
|
1990
|
+
}
|
|
1991
|
+
function base64url(input) {
|
|
1992
|
+
return Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1993
|
+
}
|
|
1994
|
+
var installationTokenCache = /* @__PURE__ */ new Map();
|
|
1995
|
+
function createAppJwt(appId, privateKey) {
|
|
1996
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1997
|
+
const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
|
1998
|
+
const payload = base64url(JSON.stringify({ iat: now - 60, exp: now + 9 * 60, iss: String(appId) }));
|
|
1999
|
+
const signature = base64url(createSign("RSA-SHA256").update(`${header}.${payload}`).sign(privateKey));
|
|
2000
|
+
return `${header}.${payload}.${signature}`;
|
|
2001
|
+
}
|
|
2002
|
+
async function mintInstallationToken(creds, fetchImpl = fetch) {
|
|
2003
|
+
const keyFp = createHash("sha256").update(creds.privateKey).digest("hex").slice(0, 12);
|
|
2004
|
+
const cacheKey = `${creds.appId}:${creds.installationId}:${keyFp}`;
|
|
2005
|
+
const cached = installationTokenCache.get(cacheKey);
|
|
2006
|
+
if (cached && cached.expiresAtMs - 6e4 > Date.now()) return cached.token;
|
|
2007
|
+
const jwt = createAppJwt(creds.appId, creds.privateKey);
|
|
2008
|
+
const res = await fetchImpl(`https://api.github.com/app/installations/${creds.installationId}/access_tokens`, {
|
|
2009
|
+
method: "POST",
|
|
2010
|
+
headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${jwt}` }
|
|
2011
|
+
});
|
|
2012
|
+
if (!res.ok) {
|
|
2013
|
+
throw new Error(`GitHub App token exchange failed (${res.status}) \u2014 check the app id, installation id, and private key.`);
|
|
2014
|
+
}
|
|
2015
|
+
const body = await res.json();
|
|
2016
|
+
const parsed = Date.parse(body.expires_at);
|
|
2017
|
+
const expiresAtMs = Number.isFinite(parsed) ? parsed : Date.now() + 30 * 60 * 1e3;
|
|
2018
|
+
installationTokenCache.set(cacheKey, { token: body.token, expiresAtMs });
|
|
2019
|
+
return body.token;
|
|
2020
|
+
}
|
|
2021
|
+
function envAppCreds() {
|
|
2022
|
+
const appId = (process.env.THALLY_GITHUB_APP_ID ?? process.env.DOX_GITHUB_APP_ID)?.trim();
|
|
2023
|
+
const installationId = (process.env.THALLY_GITHUB_APP_INSTALLATION_ID ?? process.env.DOX_GITHUB_APP_INSTALLATION_ID)?.trim();
|
|
2024
|
+
const privateKey = process.env.THALLY_GITHUB_APP_PRIVATE_KEY ?? process.env.DOX_GITHUB_APP_PRIVATE_KEY;
|
|
2025
|
+
if (appId && installationId && privateKey) return { appId, installationId, privateKey };
|
|
2026
|
+
return void 0;
|
|
2027
|
+
}
|
|
2028
|
+
async function resolveGithubToken(options) {
|
|
2029
|
+
if (options?.token) return options.token;
|
|
2030
|
+
const pat = process.env.THALLY_GITHUB_TOKEN ?? process.env.DOX_GITHUB_TOKEN ?? process.env.THALLY_TASKS_TOKEN ?? process.env.DOX_TASKS_TOKEN ?? process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? void 0;
|
|
2031
|
+
const appCreds = options?.appCreds ?? envAppCreds();
|
|
2032
|
+
if (appCreds) {
|
|
2033
|
+
try {
|
|
2034
|
+
return await mintInstallationToken(appCreds, options?.fetchImpl ?? fetch);
|
|
2035
|
+
} catch (err) {
|
|
2036
|
+
console.warn(`[thally-track] GitHub App token mint failed, falling back to PAT: ${err instanceof Error ? err.message : String(err)}`);
|
|
2037
|
+
return pat;
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
return pat;
|
|
2041
|
+
}
|
|
2042
|
+
async function githubJson(path, options) {
|
|
2043
|
+
const fetchImpl = options?.fetchImpl ?? fetch;
|
|
2044
|
+
const token = await resolveGithubToken(options);
|
|
2045
|
+
const headers = { Accept: "application/vnd.github+json" };
|
|
2046
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
2047
|
+
const response = await fetchImpl(`https://api.github.com${path}`, { headers });
|
|
2048
|
+
if (!response.ok) {
|
|
2049
|
+
const hint = response.status === 404 || response.status === 403 ? " (private repo or rate limit? set THALLY_GITHUB_TOKEN or connect a GitHub App)" : "";
|
|
2050
|
+
throw new Error(`GitHub API ${response.status} for ${path}${hint}`);
|
|
2051
|
+
}
|
|
2052
|
+
return await response.json();
|
|
2053
|
+
}
|
|
2054
|
+
function toPullRequestInfo(raw) {
|
|
2055
|
+
return {
|
|
2056
|
+
number: raw.number,
|
|
2057
|
+
title: raw.title ?? "",
|
|
2058
|
+
body: raw.body ?? "",
|
|
2059
|
+
htmlUrl: raw.html_url ?? "",
|
|
2060
|
+
baseRef: raw.base?.ref ?? "main",
|
|
2061
|
+
...raw.merge_commit_sha ? { mergeCommitSha: raw.merge_commit_sha } : {},
|
|
2062
|
+
...raw.user?.login ? { author: raw.user.login } : {}
|
|
2063
|
+
};
|
|
2064
|
+
}
|
|
2065
|
+
async function fetchPullRequest(owner, repo, number, options) {
|
|
2066
|
+
const raw = await githubJson(`/repos/${owner}/${repo}/pulls/${number}`, options);
|
|
2067
|
+
return toPullRequestInfo(raw);
|
|
2068
|
+
}
|
|
2069
|
+
async function fetchPullRequestFiles(owner, repo, number, options) {
|
|
2070
|
+
const perPage = 100;
|
|
2071
|
+
const maxPages = 30;
|
|
2072
|
+
const raw = [];
|
|
2073
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
2074
|
+
const chunk = await githubJson(`/repos/${owner}/${repo}/pulls/${number}/files?per_page=${perPage}&page=${page}`, options);
|
|
2075
|
+
raw.push(...chunk);
|
|
2076
|
+
if (chunk.length < perPage) break;
|
|
2077
|
+
}
|
|
2078
|
+
return raw.map((f) => ({
|
|
2079
|
+
filename: f.filename,
|
|
2080
|
+
status: f.status,
|
|
2081
|
+
additions: f.additions,
|
|
2082
|
+
deletions: f.deletions,
|
|
2083
|
+
...f.patch ? { patch: f.patch } : {}
|
|
2084
|
+
}));
|
|
2085
|
+
}
|
|
2086
|
+
async function fetchLatestMergedPr(owner, repo, base, options) {
|
|
2087
|
+
const raw = await githubJson(
|
|
2088
|
+
`/repos/${owner}/${repo}/pulls?state=closed&base=${encodeURIComponent(base)}&sort=updated&direction=desc&per_page=30`,
|
|
2089
|
+
options
|
|
2090
|
+
);
|
|
2091
|
+
const merged = raw.filter((pr) => Boolean(pr.merged_at)).sort((a, b) => Date.parse(b.merged_at ?? "") - Date.parse(a.merged_at ?? ""))[0];
|
|
2092
|
+
return merged ? toPullRequestInfo(merged) : null;
|
|
2093
|
+
}
|
|
2094
|
+
function compileGlob(pattern) {
|
|
2095
|
+
const normalized = pattern.replace(/^\.\//, "");
|
|
2096
|
+
let regex = "";
|
|
2097
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
2098
|
+
const char = normalized[i];
|
|
2099
|
+
if (char === "*") {
|
|
2100
|
+
if (normalized[i + 1] === "*") {
|
|
2101
|
+
if (normalized[i + 2] === "/") {
|
|
2102
|
+
regex += "(?:[^/]+/)*";
|
|
2103
|
+
i += 2;
|
|
2104
|
+
} else {
|
|
2105
|
+
regex += ".*";
|
|
2106
|
+
i += 1;
|
|
2107
|
+
}
|
|
2108
|
+
} else {
|
|
2109
|
+
regex += "[^/]*";
|
|
2110
|
+
}
|
|
2111
|
+
} else if (char === "?") {
|
|
2112
|
+
regex += "[^/]";
|
|
2113
|
+
} else {
|
|
2114
|
+
regex += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
return new RegExp(`^${regex}$`);
|
|
2118
|
+
}
|
|
2119
|
+
function filterFilesByGlobs(files, globs) {
|
|
2120
|
+
if (!globs || globs.length === 0) return files;
|
|
2121
|
+
const compiled = globs.map(compileGlob);
|
|
2122
|
+
return files.filter((file) => {
|
|
2123
|
+
const path = file.filename.replace(/^\.\//, "");
|
|
2124
|
+
return compiled.some((re) => re.test(path));
|
|
2125
|
+
});
|
|
2126
|
+
}
|
|
2127
|
+
var TRACK_CONTEXT_CHAR_CAP = 2e4;
|
|
2128
|
+
function buildTrackInstruction(repo, pr, options) {
|
|
2129
|
+
const placement = repo.outputTab ? ` If new pages are warranted, add them under the ${repo.outputTab} tab${repo.outputGroup ? ` (${repo.outputGroup} group)` : ""}.` : "";
|
|
2130
|
+
const lead = options?.preview ? `An OPEN pull request in ${repo.owner}/${repo.repo} (#${pr.number}) is up for review.` : `A pull request merged in ${repo.owner}/${repo.repo} (#${pr.number}).`;
|
|
2131
|
+
const tail = options?.preview ? ` This is a preview: the PR may still change before it merges, so draft the docs it will need for review alongside it.` : ` Make no change if the PR has no user-facing impact.`;
|
|
2132
|
+
return `${lead} Review it and decide what user-facing behavior it changes (API surface, config, CLI, defaults, behavior). Then find the documentation pages that describe that behavior and update them so the docs match \u2014 editing existing pages in place where they already cover it.${placement}` + tail;
|
|
2133
|
+
}
|
|
2134
|
+
function buildTrackContext(repo, pr, files) {
|
|
2135
|
+
const header = [
|
|
2136
|
+
`# Merged PR ${repo.owner}/${repo.repo}#${pr.number}: ${pr.title}`,
|
|
2137
|
+
pr.author ? `Author: ${pr.author}` : null,
|
|
2138
|
+
pr.htmlUrl ? `URL: ${pr.htmlUrl}` : null,
|
|
2139
|
+
"",
|
|
2140
|
+
pr.body?.trim() || "(no description)",
|
|
2141
|
+
""
|
|
2142
|
+
].filter((line) => line !== null).join("\n");
|
|
2143
|
+
const NOTE_RESERVE = 100;
|
|
2144
|
+
let context = header;
|
|
2145
|
+
for (const file of files) {
|
|
2146
|
+
const section = [
|
|
2147
|
+
`### ${file.filename} (${file.status}, +${file.additions}/-${file.deletions})`,
|
|
2148
|
+
file.patch ? `\`\`\`diff
|
|
2149
|
+
${file.patch}
|
|
2150
|
+
\`\`\`` : "_(no text diff \u2014 binary or too large)_",
|
|
2151
|
+
""
|
|
2152
|
+
].join("\n");
|
|
2153
|
+
if (context.length + section.length > TRACK_CONTEXT_CHAR_CAP - NOTE_RESERVE) {
|
|
2154
|
+
context += `
|
|
2155
|
+
_(diff truncated \u2014 ${files.length} file(s) total)_
|
|
2156
|
+
`;
|
|
2157
|
+
break;
|
|
2158
|
+
}
|
|
2159
|
+
context += section;
|
|
2160
|
+
}
|
|
2161
|
+
return context.slice(0, TRACK_CONTEXT_CHAR_CAP);
|
|
2162
|
+
}
|
|
2163
|
+
function buildTrackTask(repo, pr, files) {
|
|
2164
|
+
return { instruction: buildTrackInstruction(repo, pr), context: buildTrackContext(repo, pr, files) };
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
// src/tools/sync-from-repo.ts
|
|
2168
|
+
var syncFromRepoSchema = z14.object({
|
|
2169
|
+
projectDir: z14.string().describe("Path to the Thally project (reads the tracking config from docs.json)"),
|
|
2170
|
+
repo: z14.string().optional().describe("Tracked repo to sync as owner/repo (defaults to the single tracked repo when only one is configured)"),
|
|
2171
|
+
pr: z14.number().optional().describe("Pull request number to analyze (defaults to the latest PR merged into the tracked base branch)"),
|
|
2172
|
+
dryRun: z14.boolean().optional().default(true).describe("When true (default), preview the distilled docs task without dispatching anything"),
|
|
2173
|
+
docsRepo: z14.string().optional().describe("owner/repo of the docs repository to dispatch the task to (required when dryRun is false)")
|
|
2174
|
+
});
|
|
2175
|
+
async function handleSyncFromRepo(input) {
|
|
2176
|
+
const config = readDocsJson(input.projectDir);
|
|
2177
|
+
const tracked = config.tracking?.repos ?? [];
|
|
2178
|
+
let target;
|
|
2179
|
+
if (input.repo) {
|
|
2180
|
+
const ref = parseOwnerRepo(input.repo);
|
|
2181
|
+
if (!ref) throw new Error(`Invalid repo "${input.repo}" \u2014 expected owner/repo`);
|
|
2182
|
+
target = tracked.find(
|
|
2183
|
+
(r) => r.owner.toLowerCase() === ref.owner.toLowerCase() && r.repo.toLowerCase() === ref.repo.toLowerCase()
|
|
2184
|
+
) ?? { owner: ref.owner, repo: ref.repo };
|
|
2185
|
+
} else if (tracked.length === 1) {
|
|
2186
|
+
target = tracked[0];
|
|
2187
|
+
}
|
|
2188
|
+
if (!target) {
|
|
2189
|
+
throw new Error(
|
|
2190
|
+
tracked.length === 0 ? "No tracked repos in docs.json \u2014 add one with `thally track add <owner/repo>` or pass `repo`." : `Multiple tracked repos configured \u2014 pass \`repo\` (one of: ${tracked.map((r) => `${r.owner}/${r.repo}`).join(", ")}).`
|
|
2191
|
+
);
|
|
2192
|
+
}
|
|
2193
|
+
const branch = target.branch ?? "main";
|
|
2194
|
+
const pr = input.pr ? await fetchPullRequest(target.owner, target.repo, input.pr) : await fetchLatestMergedPr(target.owner, target.repo, branch);
|
|
2195
|
+
if (!pr) {
|
|
2196
|
+
return `No merged pull requests found on ${target.owner}/${target.repo}@${branch}.`;
|
|
2197
|
+
}
|
|
2198
|
+
const files = await fetchPullRequestFiles(target.owner, target.repo, pr.number);
|
|
2199
|
+
const matched = filterFilesByGlobs(files, target.paths);
|
|
2200
|
+
if (matched.length === 0) {
|
|
2201
|
+
return `PR ${target.owner}/${target.repo}#${pr.number} touches no tracked paths (${target.paths?.join(", ") ?? "all"}) \u2014 nothing to document.`;
|
|
2202
|
+
}
|
|
2203
|
+
const task = buildTrackTask(target, pr, matched);
|
|
2204
|
+
if (input.dryRun) {
|
|
2205
|
+
return [
|
|
2206
|
+
`\u{1F50D} Dry run \u2014 docs task for ${target.owner}/${target.repo}#${pr.number}`,
|
|
2207
|
+
"",
|
|
2208
|
+
`PR: ${pr.title}`,
|
|
2209
|
+
`Files matched: ${matched.length} of ${files.length} (${matched.map((f) => f.filename).slice(0, 10).join(", ")}${matched.length > 10 ? ", \u2026" : ""})`,
|
|
2210
|
+
"",
|
|
2211
|
+
`Instruction: ${task.instruction}`,
|
|
2212
|
+
"",
|
|
2213
|
+
"Context head:",
|
|
2214
|
+
task.context.slice(0, 1500),
|
|
2215
|
+
"",
|
|
2216
|
+
"Run again with dryRun: false and a docsRepo to dispatch this task to the docs-agent workflow."
|
|
2217
|
+
].join("\n");
|
|
2218
|
+
}
|
|
2219
|
+
if (!input.docsRepo) throw new Error("docsRepo (owner/repo of the docs repository) is required when dryRun is false.");
|
|
2220
|
+
const docsRef = parseOwnerRepo(input.docsRepo);
|
|
2221
|
+
if (!docsRef) throw new Error(`Invalid docsRepo "${input.docsRepo}" \u2014 expected owner/repo`);
|
|
2222
|
+
const token = await resolveGithubToken();
|
|
2223
|
+
if (!token) {
|
|
2224
|
+
throw new Error("Set THALLY_GITHUB_TOKEN (or connect a GitHub App) with dispatch access to the docs repo to dispatch a docs task.");
|
|
2225
|
+
}
|
|
2226
|
+
const response = await fetch(`https://api.github.com/repos/${docsRef.owner}/${docsRef.repo}/dispatches`, {
|
|
2227
|
+
method: "POST",
|
|
2228
|
+
headers: {
|
|
2229
|
+
Accept: "application/vnd.github+json",
|
|
2230
|
+
Authorization: `Bearer ${token}`,
|
|
2231
|
+
"Content-Type": "application/json"
|
|
2232
|
+
},
|
|
2233
|
+
body: JSON.stringify({
|
|
2234
|
+
event_type: "thally-document",
|
|
2235
|
+
client_payload: { instruction: task.instruction, from_pr: pr.htmlUrl }
|
|
2236
|
+
})
|
|
2237
|
+
});
|
|
2238
|
+
if (!response.ok) {
|
|
2239
|
+
throw new Error(`repository_dispatch failed (${response.status}) \u2014 check the token's access to ${input.docsRepo}.`);
|
|
2240
|
+
}
|
|
2241
|
+
return `\u2705 Dispatched docs task for ${target.owner}/${target.repo}#${pr.number} to ${input.docsRepo}. The "Thally docs agent" workflow there will draft the PR \u2014 watch its Actions tab.`;
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
// src/lib/tools.ts
|
|
2245
|
+
function defineTool(def) {
|
|
2246
|
+
return def;
|
|
2247
|
+
}
|
|
2248
|
+
var tools = [
|
|
2249
|
+
defineTool({
|
|
2250
|
+
name: "create_project",
|
|
2251
|
+
description: "Scaffold a new Thally documentation project from the GitHub template",
|
|
2252
|
+
scope: "project",
|
|
2253
|
+
schema: createProjectSchema,
|
|
2254
|
+
handler: handleCreateProject
|
|
2255
|
+
}),
|
|
2256
|
+
defineTool({
|
|
2257
|
+
name: "add_page",
|
|
2258
|
+
description: "Add a new MDX page to a Thally project and register it in docs.json navigation",
|
|
2259
|
+
scope: "project",
|
|
2260
|
+
schema: addPageSchema,
|
|
2261
|
+
handler: handleAddPage
|
|
2262
|
+
}),
|
|
2263
|
+
defineTool({
|
|
2264
|
+
name: "add_tab",
|
|
2265
|
+
description: "Add a new top-level tab to a Thally project navigation (content tab or redirect link)",
|
|
2266
|
+
scope: "project",
|
|
2267
|
+
schema: addTabSchema,
|
|
2268
|
+
handler: handleAddTab
|
|
2269
|
+
}),
|
|
2270
|
+
defineTool({
|
|
2271
|
+
name: "list_pages",
|
|
2272
|
+
description: "List all pages in a Thally project, organized by tab and group",
|
|
2273
|
+
scope: "project",
|
|
2274
|
+
schema: listPagesSchema,
|
|
2275
|
+
handler: handleListPages
|
|
2276
|
+
}),
|
|
2277
|
+
defineTool({
|
|
2278
|
+
name: "update_page",
|
|
2279
|
+
description: "Update the frontmatter or body content of an existing MDX page in a Thally project",
|
|
2280
|
+
scope: "project",
|
|
2281
|
+
schema: updatePageSchema,
|
|
2282
|
+
handler: handleUpdatePage
|
|
2283
|
+
}),
|
|
2284
|
+
defineTool({
|
|
2285
|
+
name: "migrate_docs",
|
|
2286
|
+
description: "Crawl a docs site and migrate it into a Thally project",
|
|
2287
|
+
scope: "project",
|
|
2288
|
+
schema: migrateDocsSchema,
|
|
2289
|
+
handler: handleMigrateDocs
|
|
2290
|
+
}),
|
|
2291
|
+
defineTool({
|
|
2292
|
+
name: "search_docs",
|
|
2293
|
+
description: "Search documentation pages by keyword \u2014 returns ranked list of matching pages",
|
|
2294
|
+
scope: "project",
|
|
2295
|
+
schema: searchDocsSchema,
|
|
2296
|
+
handler: handleSearchDocs
|
|
2297
|
+
}),
|
|
2298
|
+
defineTool({
|
|
2299
|
+
name: "semantic_search",
|
|
2300
|
+
description: "Hybrid (full-text + vector) semantic search against a deployed Thally site \u2014 uses the same index as the in-app command palette and /api/search",
|
|
2301
|
+
scope: "site",
|
|
2302
|
+
schema: semanticSearchSchema,
|
|
2303
|
+
handler: handleSemanticSearch
|
|
2304
|
+
}),
|
|
2305
|
+
defineTool({
|
|
2306
|
+
name: "agent_readiness",
|
|
2307
|
+
description: "Fetch the Agent Readiness Score (0-100) for a deployed Thally site \u2014 the same report as /api/agent-readiness and `thally check`, with per-signal subscores and fixable offenders",
|
|
2308
|
+
scope: "site",
|
|
2309
|
+
schema: agentReadinessSchema,
|
|
2310
|
+
handler: handleAgentReadiness
|
|
2311
|
+
}),
|
|
2312
|
+
defineTool({
|
|
2313
|
+
name: "read_page",
|
|
2314
|
+
description: "Read the full content of a documentation page by its page ID",
|
|
2315
|
+
scope: "project",
|
|
2316
|
+
schema: readPageSchema,
|
|
2317
|
+
handler: handleReadPage
|
|
2318
|
+
}),
|
|
2319
|
+
defineTool({
|
|
2320
|
+
name: "get_context",
|
|
2321
|
+
description: "Get the most relevant documentation context for a topic or question, within a token budget",
|
|
2322
|
+
scope: "project",
|
|
2323
|
+
schema: getContextSchema,
|
|
2324
|
+
handler: handleGetContext
|
|
2325
|
+
}),
|
|
2326
|
+
defineTool({
|
|
2327
|
+
name: "lint_project",
|
|
2328
|
+
description: "Check a Thally project for issues: broken nav references, orphan files, missing frontmatter",
|
|
2329
|
+
scope: "project",
|
|
2330
|
+
schema: lintProjectSchema,
|
|
2331
|
+
handler: handleLintProject
|
|
2332
|
+
}),
|
|
2333
|
+
defineTool({
|
|
2334
|
+
name: "translate_docs",
|
|
2335
|
+
description: "Translate Thally documentation pages to a secondary locale using Claude AI",
|
|
2336
|
+
scope: "project",
|
|
2337
|
+
schema: translateDocsSchema,
|
|
2338
|
+
handler: handleTranslateDocs
|
|
2339
|
+
}),
|
|
2340
|
+
defineTool({
|
|
2341
|
+
name: "sync_from_repo",
|
|
2342
|
+
description: "Thally Track: analyze a merged pull request in a tracked product repo and preview the docs task it would produce (dryRun), or dispatch it to the docs repo so the docs agent drafts a documentation PR",
|
|
2343
|
+
scope: "project",
|
|
2344
|
+
schema: syncFromRepoSchema,
|
|
2345
|
+
handler: handleSyncFromRepo
|
|
2346
|
+
})
|
|
2347
|
+
];
|
|
2348
|
+
function getTool(name) {
|
|
2349
|
+
return tools.find((tool) => tool.name === name);
|
|
2350
|
+
}
|
|
2351
|
+
export {
|
|
2352
|
+
getTool,
|
|
2353
|
+
tools
|
|
2354
|
+
};
|