@doquflow/cli 0.4.5 → 0.5.1
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/dist/commands/init.js +174 -3
- package/dist/commands/sync.js +361 -0
- package/dist/commands/watch-stop.js +223 -0
- package/dist/commands/watch.js +587 -0
- package/dist/index.js +92 -8
- package/package.json +2 -2
package/dist/commands/init.js
CHANGED
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.buildClaudeMd = buildClaudeMd;
|
|
7
|
+
exports.buildAgentsMd = buildAgentsMd;
|
|
7
8
|
exports.run = run;
|
|
8
9
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
10
|
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
@@ -32,6 +33,9 @@ function getVSCodeMcpConfigPath() {
|
|
|
32
33
|
function getCopilotCliMcpConfigPath() {
|
|
33
34
|
return node_path_1.default.join(node_os_1.default.homedir(), ".copilot", "mcp-config.json");
|
|
34
35
|
}
|
|
36
|
+
function getCodexConfigPath() {
|
|
37
|
+
return node_path_1.default.join(node_os_1.default.homedir(), ".codex", "config.toml");
|
|
38
|
+
}
|
|
35
39
|
function resolveServerBin() {
|
|
36
40
|
// Try npm-installed package first
|
|
37
41
|
try {
|
|
@@ -161,10 +165,98 @@ async function writeClaudeMd(projectDir) {
|
|
|
161
165
|
await promises_1.default.writeFile(claudeMdPath, newSection, "utf8");
|
|
162
166
|
}
|
|
163
167
|
}
|
|
168
|
+
function buildAgentsMd(projectDir) {
|
|
169
|
+
return `# DocuFlow — AI Documentation Assistant
|
|
170
|
+
|
|
171
|
+
DocuFlow is an MCP server that provides structured access to this codebase and maintains a living wiki.
|
|
172
|
+
It is registered via \`.codex/config.toml\` and available as MCP tools in every Codex session.
|
|
173
|
+
|
|
174
|
+
## Available MCP Tools
|
|
175
|
+
|
|
176
|
+
### Codebase Scanner
|
|
177
|
+
- **read_module** — Analyse a single file: language, classes, functions, dependencies, DB tables, endpoints, config refs, raw content.
|
|
178
|
+
- \`read_module({ path: "src/UserService.cs" })\`
|
|
179
|
+
- **list_modules** — Walk a directory, extract facts for every file. One call to understand the whole project.
|
|
180
|
+
- \`list_modules({ path: "${projectDir}" })\`
|
|
181
|
+
- **write_spec** — Save a markdown spec to \`.docuflow/specs/<name>.md\`.
|
|
182
|
+
- \`write_spec({ project_path: "${projectDir}", filename: "UserService", content: "..." })\`
|
|
183
|
+
- **read_specs** — Read saved specs, optionally filtered by name.
|
|
184
|
+
- \`read_specs({ project_path: "${projectDir}" })\`
|
|
185
|
+
|
|
186
|
+
### Wiki Pipeline
|
|
187
|
+
- **ingest_source** — Ingest a markdown file from \`.docuflow/sources/\` into the wiki (entities, concepts).
|
|
188
|
+
- **update_index** — Rebuild \`.docuflow/index.md\` from all wiki pages.
|
|
189
|
+
- **list_wiki** — List all wiki pages by category (entity/concept/timeline/synthesis).
|
|
190
|
+
- **wiki_search** — BM25 search across all wiki pages.
|
|
191
|
+
- **query_wiki** — Q&A: searches wiki, synthesises an answer, returns citations.
|
|
192
|
+
- \`query_wiki({ project_path: "${projectDir}", question: "How does auth work?" })\`
|
|
193
|
+
- **synthesize_answer** — Generate a markdown synthesis from a list of page IDs.
|
|
194
|
+
- **save_answer_as_page** — Persist a synthesis as a wiki page.
|
|
195
|
+
|
|
196
|
+
### Health & Guidance
|
|
197
|
+
- **lint_wiki** — Health check: orphan pages, broken refs, stale content. Returns a 0–100 health score.
|
|
198
|
+
- **get_schema_guidance** — Recommend what wiki pages should exist based on schema + current state.
|
|
199
|
+
- **preview_generation** — Preview what a tool will generate before running it.
|
|
200
|
+
|
|
201
|
+
## Common Workflows
|
|
202
|
+
|
|
203
|
+
Start here — understand the codebase:
|
|
204
|
+
\`\`\`
|
|
205
|
+
list_modules({ path: "${projectDir}" })
|
|
206
|
+
→ write_spec for important modules
|
|
207
|
+
\`\`\`
|
|
208
|
+
|
|
209
|
+
Answer a question:
|
|
210
|
+
\`\`\`
|
|
211
|
+
query_wiki({ project_path: "${projectDir}", question: "..." })
|
|
212
|
+
\`\`\`
|
|
213
|
+
|
|
214
|
+
Maintain wiki health:
|
|
215
|
+
\`\`\`
|
|
216
|
+
lint_wiki({ project_path: "${projectDir}" })
|
|
217
|
+
\`\`\`
|
|
218
|
+
|
|
219
|
+
## Storage Layout
|
|
220
|
+
|
|
221
|
+
\`\`\`
|
|
222
|
+
.docuflow/
|
|
223
|
+
├── specs/ Code specs written by write_spec
|
|
224
|
+
├── wiki/ LLM-generated wiki pages
|
|
225
|
+
│ ├── entities/
|
|
226
|
+
│ ├── concepts/
|
|
227
|
+
│ ├── timelines/
|
|
228
|
+
│ └── syntheses/
|
|
229
|
+
├── sources/ Raw markdown docs to ingest
|
|
230
|
+
├── schema.md Wiki configuration (edit to customise)
|
|
231
|
+
├── index.md Auto-maintained catalog
|
|
232
|
+
└── log.md Operation log
|
|
233
|
+
\`\`\`
|
|
234
|
+
`;
|
|
235
|
+
}
|
|
236
|
+
async function writeAgentsMd(projectDir) {
|
|
237
|
+
const agentsMdPath = node_path_1.default.join(projectDir, "AGENTS.md");
|
|
238
|
+
const newSection = buildAgentsMd(projectDir);
|
|
239
|
+
if (node_fs_1.default.existsSync(agentsMdPath)) {
|
|
240
|
+
const existing = await promises_1.default.readFile(agentsMdPath, "utf8");
|
|
241
|
+
if (existing.includes("DocuFlow")) {
|
|
242
|
+
// Replace existing DocuFlow section
|
|
243
|
+
const withoutDocuflow = existing.replace(/\n?# DocuFlow[\s\S]*/, "").trimEnd();
|
|
244
|
+
await promises_1.default.writeFile(agentsMdPath, withoutDocuflow + "\n\n" + newSection, "utf8");
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
// Append to existing AGENTS.md
|
|
248
|
+
await promises_1.default.appendFile(agentsMdPath, "\n\n" + newSection, "utf8");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
await promises_1.default.writeFile(agentsMdPath, newSection, "utf8");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
164
255
|
async function run() {
|
|
165
256
|
const configPath = getClaudeDesktopConfigPath();
|
|
166
257
|
const vscodeConfigPath = getVSCodeMcpConfigPath();
|
|
167
258
|
const copilotCliConfigPath = getCopilotCliMcpConfigPath();
|
|
259
|
+
const codexConfigPath = getCodexConfigPath();
|
|
168
260
|
const serverBin = resolveServerBin();
|
|
169
261
|
const nodeBin = process.execPath;
|
|
170
262
|
// Register in Claude Desktop config
|
|
@@ -223,6 +315,24 @@ async function run() {
|
|
|
223
315
|
catch {
|
|
224
316
|
// Copilot CLI not installed — skip silently
|
|
225
317
|
}
|
|
318
|
+
// Register in OpenAI Codex CLI (~/.codex/config.toml in TOML format)
|
|
319
|
+
let codexCliRegistered = false;
|
|
320
|
+
try {
|
|
321
|
+
await promises_1.default.mkdir(node_path_1.default.dirname(codexConfigPath), { recursive: true });
|
|
322
|
+
let tomlContent = "";
|
|
323
|
+
try {
|
|
324
|
+
tomlContent = await promises_1.default.readFile(codexConfigPath, "utf8");
|
|
325
|
+
}
|
|
326
|
+
catch { /* new file */ }
|
|
327
|
+
if (!tomlContent.includes("[mcp_servers.docuflow]")) {
|
|
328
|
+
const entry = `\n[mcp_servers.docuflow]\ncommand = "${nodeBin}"\nargs = [${JSON.stringify(serverBin)}]\n`;
|
|
329
|
+
await promises_1.default.writeFile(codexConfigPath, tomlContent + entry, "utf8");
|
|
330
|
+
}
|
|
331
|
+
codexCliRegistered = true;
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
// Codex CLI not installed — skip silently
|
|
335
|
+
}
|
|
226
336
|
// Create .docuflow/ directory structure
|
|
227
337
|
const projectDir = process.cwd();
|
|
228
338
|
const docuflowDir = node_path_1.default.join(projectDir, ".docuflow");
|
|
@@ -245,6 +355,8 @@ async function run() {
|
|
|
245
355
|
await copyTemplateFile("log.md", node_path_1.default.join(docuflowDir, "log.md"));
|
|
246
356
|
// Generate CLAUDE.md so Claude Code picks up DocuFlow automatically
|
|
247
357
|
await writeClaudeMd(projectDir);
|
|
358
|
+
// Generate AGENTS.md so OpenAI Codex picks up DocuFlow automatically
|
|
359
|
+
await writeAgentsMd(projectDir);
|
|
248
360
|
// Write .vscode/mcp.json for project-level workspace MCP config (shareable via git)
|
|
249
361
|
// Uses npx so it works on any machine — safe to commit
|
|
250
362
|
const vscodeDirPath = node_path_1.default.join(projectDir, ".vscode");
|
|
@@ -274,6 +386,8 @@ async function run() {
|
|
|
274
386
|
await promises_1.default.appendFile(gitignorePath, "\n# Docuflow\n.docuflow/\n");
|
|
275
387
|
}
|
|
276
388
|
}
|
|
389
|
+
// Install git post-commit hook (auto-sync on every commit)
|
|
390
|
+
await installGitHook(projectDir);
|
|
277
391
|
console.log("\u2713 DocuFlow initialised successfully.");
|
|
278
392
|
console.log("");
|
|
279
393
|
console.log("\ud83d\udcc1 Structure created:");
|
|
@@ -289,18 +403,75 @@ async function run() {
|
|
|
289
403
|
console.log(` \u251c\u2500\u2500 index.md (auto-maintained catalog)`);
|
|
290
404
|
console.log(` \u2514\u2500\u2500 log.md (operation log)`);
|
|
291
405
|
console.log("");
|
|
292
|
-
console.log("\ud83d\udcdd
|
|
293
|
-
console.log(`
|
|
294
|
-
console.log(`
|
|
406
|
+
console.log("\ud83d\udcdd Instruction files:");
|
|
407
|
+
console.log(` CLAUDE.md ✓ ${node_path_1.default.join(projectDir, "CLAUDE.md")}`);
|
|
408
|
+
console.log(` AGENTS.md ✓ ${node_path_1.default.join(projectDir, "AGENTS.md")}`);
|
|
295
409
|
console.log("");
|
|
296
410
|
console.log("\ud83d\udd27 MCP Registration:");
|
|
297
411
|
console.log(` Claude Desktop: \u2713 registered`);
|
|
298
412
|
console.log(` VS Code Copilot: ${vscodeRegistered ? "\u2713 registered (user-level)" : "\u2014 not detected"}`);
|
|
299
413
|
console.log(` Copilot CLI: ${copilotCliRegistered ? "\u2713 registered (~/.copilot/mcp-config.json)" : "\u2014 not detected"}`);
|
|
414
|
+
console.log(` Codex CLI: ${codexCliRegistered ? "\u2713 registered (~/.codex/config.toml)" : "\u2014 not detected"}`);
|
|
300
415
|
console.log(` Workspace: \u2713 .vscode/mcp.json written (commit to share with team)`);
|
|
301
416
|
console.log("");
|
|
302
417
|
console.log("\ud83d\udcd6 Next steps:");
|
|
303
418
|
console.log(" 1. Edit .docuflow/schema.md to customize your wiki domain");
|
|
304
419
|
console.log(" 2. Add markdown docs to .docuflow/sources/ then ingest them");
|
|
305
420
|
console.log(" 3. Restart Claude Desktop / reload VS Code / restart Copilot CLI");
|
|
421
|
+
console.log("");
|
|
422
|
+
console.log("\u26a1 Auto-sync options:");
|
|
423
|
+
console.log(" docuflow watch # background daemon (watches for file changes)");
|
|
424
|
+
console.log(" docuflow watch --ai # + Claude/Codex documents code changes automatically");
|
|
425
|
+
console.log(" docuflow sync # one-shot sync (good for CI/CD)");
|
|
426
|
+
console.log(" docuflow sync --ai # + AI generates docs from last git commit");
|
|
427
|
+
console.log("");
|
|
428
|
+
console.log(" A git post-commit hook was installed at .git/hooks/post-commit");
|
|
429
|
+
console.log(" It runs \"docuflow sync --ai --quiet\" after every commit automatically.");
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Install a git post-commit hook that runs `docuflow sync` automatically.
|
|
433
|
+
* Idempotent — does not overwrite a hook that already has DocuFlow content.
|
|
434
|
+
*/
|
|
435
|
+
async function installGitHook(projectDir) {
|
|
436
|
+
const gitDir = node_path_1.default.join(projectDir, ".git");
|
|
437
|
+
if (!node_fs_1.default.existsSync(gitDir))
|
|
438
|
+
return; // not a git repo
|
|
439
|
+
const hooksDir = node_path_1.default.join(gitDir, "hooks");
|
|
440
|
+
await promises_1.default.mkdir(hooksDir, { recursive: true });
|
|
441
|
+
const hookPath = node_path_1.default.join(hooksDir, "post-commit");
|
|
442
|
+
const hookMarker = "# docuflow-auto-sync";
|
|
443
|
+
// Don't overwrite if already installed
|
|
444
|
+
if (node_fs_1.default.existsSync(hookPath)) {
|
|
445
|
+
const existing = await promises_1.default.readFile(hookPath, "utf8");
|
|
446
|
+
if (existing.includes(hookMarker))
|
|
447
|
+
return;
|
|
448
|
+
// Append to existing hook
|
|
449
|
+
const appendContent = [
|
|
450
|
+
"",
|
|
451
|
+
hookMarker,
|
|
452
|
+
`# Auto-generated by docuflow init — runs wiki sync after every commit`,
|
|
453
|
+
`# Requires Claude CLI, Codex CLI, or ANTHROPIC_API_KEY for AI-powered doc generation`,
|
|
454
|
+
`# Remove the lines below to disable auto-sync`,
|
|
455
|
+
`if command -v docuflow &> /dev/null; then`,
|
|
456
|
+
` docuflow sync --ai --quiet &`,
|
|
457
|
+
`fi`,
|
|
458
|
+
].join("\n");
|
|
459
|
+
await promises_1.default.appendFile(hookPath, appendContent + "\n");
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
// Create new hook file
|
|
463
|
+
const hookContent = [
|
|
464
|
+
`#!/bin/sh`,
|
|
465
|
+
hookMarker,
|
|
466
|
+
`# Auto-generated by docuflow init`,
|
|
467
|
+
`# Syncs the DocuFlow wiki after every commit using Claude / Codex / Anthropic API`,
|
|
468
|
+
`# AI bridge priority: Claude CLI > Codex CLI > ANTHROPIC_API_KEY`,
|
|
469
|
+
`# Run in background (&) so it never delays your git workflow`,
|
|
470
|
+
`if command -v docuflow &> /dev/null; then`,
|
|
471
|
+
` docuflow sync --ai --quiet &`,
|
|
472
|
+
`fi`,
|
|
473
|
+
].join("\n");
|
|
474
|
+
await promises_1.default.writeFile(hookPath, hookContent + "\n", "utf8");
|
|
475
|
+
// Make executable
|
|
476
|
+
node_fs_1.default.chmodSync(hookPath, 0o755);
|
|
306
477
|
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* docuflow sync
|
|
4
|
+
*
|
|
5
|
+
* One-shot synchronisation — re-ingests all sources, rebuilds the index,
|
|
6
|
+
* and runs a lint health check. Designed for CI/CD pipelines and git hooks.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* docuflow sync # sync all sources, rebuild index, lint
|
|
10
|
+
* docuflow sync --ai # also call Claude/Codex for changed code
|
|
11
|
+
* docuflow sync --since-commit HEAD~1 # only process files changed in last commit
|
|
12
|
+
* docuflow sync --source myfile.md # sync a single source file
|
|
13
|
+
* docuflow sync --no-lint # skip health check (faster for CI)
|
|
14
|
+
*
|
|
15
|
+
* Exit codes:
|
|
16
|
+
* 0 — success, wiki is healthy (score ≥ 70)
|
|
17
|
+
* 1 — wiki has issues (score < 70) — use to fail CI
|
|
18
|
+
* 2 — fatal error (server tools not found, .docuflow missing)
|
|
19
|
+
*/
|
|
20
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
21
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
22
|
+
};
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.run = run;
|
|
25
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
26
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
27
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
28
|
+
const node_child_process_1 = require("node:child_process");
|
|
29
|
+
const watch_1 = require("./watch");
|
|
30
|
+
// ─── Colour helpers ────────────────────────────────────────────────────────────
|
|
31
|
+
const c = {
|
|
32
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
33
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
34
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
35
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
36
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
37
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
38
|
+
};
|
|
39
|
+
// ─── Dynamic server tool loader ────────────────────────────────────────────────
|
|
40
|
+
function loadServerTool(toolFile) {
|
|
41
|
+
const candidates = [
|
|
42
|
+
() => require(`@doquflow/server/dist/tools/${toolFile}`),
|
|
43
|
+
() => require(node_path_1.default.resolve(__dirname, "../../../server/dist/tools", toolFile)),
|
|
44
|
+
() => require(node_path_1.default.resolve(__dirname, "../../server/dist/tools", toolFile)),
|
|
45
|
+
];
|
|
46
|
+
for (const attempt of candidates) {
|
|
47
|
+
try {
|
|
48
|
+
return attempt();
|
|
49
|
+
}
|
|
50
|
+
catch { }
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`Cannot load server tool "${toolFile}". Run "npm run build" first.`);
|
|
53
|
+
}
|
|
54
|
+
// ─── Git helpers ───────────────────────────────────────────────────────────────
|
|
55
|
+
/**
|
|
56
|
+
* Get list of source files changed since a given git ref.
|
|
57
|
+
* Returns paths relative to projectPath.
|
|
58
|
+
*/
|
|
59
|
+
function getGitChangedFiles(projectPath, sinceRef) {
|
|
60
|
+
try {
|
|
61
|
+
const output = (0, node_child_process_1.execSync)(`git diff --name-only ${sinceRef} HEAD`, {
|
|
62
|
+
cwd: projectPath,
|
|
63
|
+
encoding: "utf8",
|
|
64
|
+
}).trim();
|
|
65
|
+
return output ? output.split("\n").filter(Boolean) : [];
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Get files staged in the current commit (for post-commit hook use).
|
|
73
|
+
*/
|
|
74
|
+
function getLastCommitFiles(projectPath) {
|
|
75
|
+
try {
|
|
76
|
+
const output = (0, node_child_process_1.execSync)("git diff-tree --no-commit-id -r --name-only HEAD", {
|
|
77
|
+
cwd: projectPath,
|
|
78
|
+
encoding: "utf8",
|
|
79
|
+
}).trim();
|
|
80
|
+
return output ? output.split("\n").filter(Boolean) : [];
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// ─── AI bridge for sync (uses watch.ts bridges) ───────────────────────────────
|
|
87
|
+
function buildSyncPrompt(projectPath, changedFiles) {
|
|
88
|
+
const fileList = changedFiles.length > 0
|
|
89
|
+
? changedFiles.slice(0, 10).map(f => `- ${f}`).join("\n")
|
|
90
|
+
: "(full sync — no specific changed files)";
|
|
91
|
+
return [
|
|
92
|
+
`You are the DocuFlow wiki maintainer for: ${projectPath}`,
|
|
93
|
+
``,
|
|
94
|
+
`Files recently committed/changed:`,
|
|
95
|
+
fileList,
|
|
96
|
+
``,
|
|
97
|
+
`Use docuflow MCP tools in order:`,
|
|
98
|
+
`1. list_wiki({ project_path: "${projectPath}" }) — note page count`,
|
|
99
|
+
`2. ingest_source for each .docuflow/sources/ file relevant to the changes`,
|
|
100
|
+
` (if unsure, ingest all source files)`,
|
|
101
|
+
`3. update_index({ project_path: "${projectPath}" })`,
|
|
102
|
+
`4. lint_wiki({ project_path: "${projectPath}", check_type: "all" })`,
|
|
103
|
+
`5. Report: pages before/after, health score, high-severity issues.`,
|
|
104
|
+
``,
|
|
105
|
+
`Be concise. Just execute and report.`,
|
|
106
|
+
].join("\n");
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Run Copilot CLI — directly calls DocuFlow MCP tools.
|
|
110
|
+
* Returns the assistant's text response or null.
|
|
111
|
+
*/
|
|
112
|
+
function runCopilotSync(prompt) {
|
|
113
|
+
const result = (0, node_child_process_1.spawnSync)("copilot", ["--prompt", prompt, "--allow-all-tools", "--allow-all-paths", "--no-ask-user", "--output-format", "json"], { encoding: "utf8", timeout: 180_000 });
|
|
114
|
+
if (result.error || result.status !== 0)
|
|
115
|
+
return null;
|
|
116
|
+
let lastMessage = null;
|
|
117
|
+
for (const line of (result.stdout ?? "").split("\n")) {
|
|
118
|
+
try {
|
|
119
|
+
const obj = JSON.parse(line.trim());
|
|
120
|
+
if (obj.type === "assistant.message" && obj.data?.content)
|
|
121
|
+
lastMessage = obj.data.content;
|
|
122
|
+
}
|
|
123
|
+
catch { }
|
|
124
|
+
}
|
|
125
|
+
return lastMessage;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Run Claude CLI — directly calls DocuFlow MCP tools.
|
|
129
|
+
* Pass allowDangerousPermissions=true (via --allow-dangerous-permissions CLI flag)
|
|
130
|
+
* to skip interactive permission prompts for non-interactive use.
|
|
131
|
+
* Without it Claude CLI may prompt for tool access and time out in a daemon context.
|
|
132
|
+
* Injects docuflow MCP config explicitly via --mcp-config.
|
|
133
|
+
*/
|
|
134
|
+
function runClaudeSync(prompt, projectPath, allowDangerousPermissions = false) {
|
|
135
|
+
// Build the MCP config pointing to the local server binary
|
|
136
|
+
let serverBin;
|
|
137
|
+
try {
|
|
138
|
+
serverBin = require.resolve("@doquflow/server/dist/index.js");
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
serverBin = node_path_1.default.resolve(__dirname, "../../server/dist/index.js");
|
|
142
|
+
}
|
|
143
|
+
const mcpConfig = JSON.stringify({
|
|
144
|
+
mcpServers: {
|
|
145
|
+
docuflow: { type: "stdio", command: process.execPath, args: [serverBin] }
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
const env = { ...process.env };
|
|
149
|
+
const claudeArgs = ["--print", "--mcp-config", mcpConfig];
|
|
150
|
+
if (allowDangerousPermissions) {
|
|
151
|
+
claudeArgs.splice(1, 0, "--dangerously-skip-permissions");
|
|
152
|
+
}
|
|
153
|
+
const result = (0, node_child_process_1.spawnSync)("claude", claudeArgs, { input: prompt, encoding: "utf8", timeout: 180_000, env });
|
|
154
|
+
const stdout = result.stdout?.trim() ?? "";
|
|
155
|
+
// Filter out Claude auth errors
|
|
156
|
+
if (!stdout || stdout.includes("Invalid API key") || stdout.includes("authentication"))
|
|
157
|
+
return null;
|
|
158
|
+
return stdout || null;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Fallback for codex/api: generate markdown doc from changed files, then ingest.
|
|
162
|
+
*/
|
|
163
|
+
async function generateAndIngest(projectPath, changedFiles, bridge, info) {
|
|
164
|
+
const fileList = changedFiles.map(f => `- ${f}`).join("\n");
|
|
165
|
+
const prompt = [
|
|
166
|
+
`You are maintaining documentation for a software project.`,
|
|
167
|
+
`These files changed:\n${fileList}`,
|
|
168
|
+
``,
|
|
169
|
+
`Write a concise markdown document (200-500 words) capturing what changed.`,
|
|
170
|
+
`Markdown only. Start with # heading. No preamble.`,
|
|
171
|
+
].join("\n");
|
|
172
|
+
let docContent = null;
|
|
173
|
+
if (bridge === "codex") {
|
|
174
|
+
const r = (0, node_child_process_1.spawnSync)("codex", [prompt], { encoding: "utf8", timeout: 90_000 });
|
|
175
|
+
docContent = r.status === 0 ? r.stdout?.trim() ?? null : null;
|
|
176
|
+
}
|
|
177
|
+
else if (bridge === "api" && process.env.ANTHROPIC_API_KEY) {
|
|
178
|
+
const https = require("https");
|
|
179
|
+
const body = JSON.stringify({ model: "claude-3-5-haiku-20241022", max_tokens: 1024, messages: [{ role: "user", content: prompt }] });
|
|
180
|
+
docContent = await new Promise((resolve) => {
|
|
181
|
+
const req = https.request({ hostname: "api.anthropic.com", path: "/v1/messages", method: "POST",
|
|
182
|
+
headers: { "Content-Type": "application/json", "x-api-key": process.env.ANTHROPIC_API_KEY, "anthropic-version": "2023-06-01", "Content-Length": Buffer.byteLength(body) } }, (res) => { let d = ""; res.on("data", (ch) => d += ch); res.on("end", () => { try {
|
|
183
|
+
resolve(JSON.parse(d)?.content?.[0]?.text ?? null);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
resolve(null);
|
|
187
|
+
} }); });
|
|
188
|
+
req.on("error", () => resolve(null));
|
|
189
|
+
req.setTimeout(90_000, () => { req.destroy(); resolve(null); });
|
|
190
|
+
req.write(body);
|
|
191
|
+
req.end();
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (!docContent) {
|
|
195
|
+
info(" ⚠ AI returned no content — skipping AI doc generation");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const sourcesDir = node_path_1.default.join(projectPath, ".docuflow", "sources");
|
|
199
|
+
await promises_1.default.mkdir(sourcesDir, { recursive: true });
|
|
200
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
201
|
+
const autoFilename = `auto_sync_${timestamp}.md`;
|
|
202
|
+
await promises_1.default.writeFile(node_path_1.default.join(sourcesDir, autoFilename), docContent, "utf8");
|
|
203
|
+
info(` ✅ AI doc saved → ${autoFilename}`);
|
|
204
|
+
}
|
|
205
|
+
async function run(options = {}) {
|
|
206
|
+
const projectPath = node_path_1.default.resolve(options.projectPath ?? process.cwd());
|
|
207
|
+
const docuDir = node_path_1.default.join(projectPath, ".docuflow");
|
|
208
|
+
const sourcesDir = node_path_1.default.join(docuDir, "sources");
|
|
209
|
+
const quiet = options.quiet ?? false;
|
|
210
|
+
const failThreshold = options.failOnScore ?? 70;
|
|
211
|
+
function info(msg) {
|
|
212
|
+
if (!quiet)
|
|
213
|
+
console.log(msg);
|
|
214
|
+
}
|
|
215
|
+
if (!node_fs_1.default.existsSync(docuDir)) {
|
|
216
|
+
console.error(c.red(` ✗ .docuflow/ not found at ${projectPath}`));
|
|
217
|
+
console.error(` Run "docuflow init" first.`);
|
|
218
|
+
process.exit(2);
|
|
219
|
+
}
|
|
220
|
+
info(c.bold("\n 🔄 DocuFlow Sync\n"));
|
|
221
|
+
const startTime = Date.now();
|
|
222
|
+
let skipManualSync = false; // set true when Copilot/Claude handle everything
|
|
223
|
+
// ── Step 1: Determine which source files to process ─────────────────────────
|
|
224
|
+
let sourceFilesToProcess = [];
|
|
225
|
+
if (options.sourceFile) {
|
|
226
|
+
// Single file mode
|
|
227
|
+
sourceFilesToProcess = [options.sourceFile];
|
|
228
|
+
info(` 📄 Single file mode: ${c.cyan(options.sourceFile)}`);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
// Scan all .md files in sources/
|
|
232
|
+
try {
|
|
233
|
+
const all = await promises_1.default.readdir(sourcesDir);
|
|
234
|
+
sourceFilesToProcess = all.filter((f) => f.endsWith(".md"));
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
info(c.yellow(` ⚠ No sources/ directory found — nothing to ingest`));
|
|
238
|
+
sourceFilesToProcess = [];
|
|
239
|
+
}
|
|
240
|
+
info(` 📚 Found ${sourceFilesToProcess.length} source file(s) to ingest`);
|
|
241
|
+
}
|
|
242
|
+
// ── Step 2: AI-powered sync for code changes ─────────────────────────────
|
|
243
|
+
if (options.ai) {
|
|
244
|
+
const codeExts = /\.(ts|tsx|js|jsx|mjs|py|go|rb|java|cs|php|rs|kt|swift)$/;
|
|
245
|
+
const changedCodeFiles = options.sinceCommit
|
|
246
|
+
? getGitChangedFiles(projectPath, options.sinceCommit).filter(f => !f.startsWith(".docuflow") && codeExts.test(f))
|
|
247
|
+
: getLastCommitFiles(projectPath).filter(f => !f.startsWith(".docuflow") && codeExts.test(f));
|
|
248
|
+
const bridge = (0, watch_1.detectBridge)({ useAI: true, forceCopilot: options.forceCopilot, forceClaude: options.forceClaude, forceCodex: options.forceCodex });
|
|
249
|
+
const sinceLabel = options.sinceCommit ?? "last commit";
|
|
250
|
+
if (changedCodeFiles.length > 0) {
|
|
251
|
+
info(`\n 🤖 ${changedCodeFiles.length} code file(s) changed (${sinceLabel})`);
|
|
252
|
+
for (const f of changedCodeFiles.slice(0, 5))
|
|
253
|
+
info(c.dim(` ${f}`));
|
|
254
|
+
if (bridge === "copilot") {
|
|
255
|
+
info(` ⚡ Copilot will directly call DocuFlow MCP tools (ingest + index + lint)...`);
|
|
256
|
+
const prompt = buildSyncPrompt(projectPath, changedCodeFiles);
|
|
257
|
+
const result = runCopilotSync(prompt);
|
|
258
|
+
if (result) {
|
|
259
|
+
info(c.green(` ✅ Copilot completed wiki sync via MCP tools`));
|
|
260
|
+
info(c.dim(` ${result.replace(/\n/g, "\n ")}`));
|
|
261
|
+
// Copilot handled everything — skip manual steps below
|
|
262
|
+
skipManualSync = true;
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
info(c.yellow(` ⚠ Copilot returned no result — continuing with direct sync`));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
else if (bridge === "claude") {
|
|
269
|
+
info(` ⚡ Claude will directly call DocuFlow MCP tools (ingest + index + lint)...`);
|
|
270
|
+
const prompt = buildSyncPrompt(projectPath, changedCodeFiles);
|
|
271
|
+
const result = runClaudeSync(prompt, projectPath, options.allowDangerousPermissions);
|
|
272
|
+
if (result) {
|
|
273
|
+
info(c.green(` ✅ Claude completed wiki sync via MCP tools`));
|
|
274
|
+
info(c.dim(` ${result.replace(/\n/g, "\n ")}`));
|
|
275
|
+
skipManualSync = true;
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
info(c.yellow(` ⚠ Claude returned no result — continuing with direct sync`));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
else if (bridge !== "none") {
|
|
282
|
+
// codex or api: generate doc → add to sources
|
|
283
|
+
await generateAndIngest(projectPath, changedCodeFiles, bridge, info);
|
|
284
|
+
const sourcesAgain = await promises_1.default.readdir(sourcesDir).catch(() => []);
|
|
285
|
+
sourceFilesToProcess = sourcesAgain.filter(f => f.endsWith(".md"));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
info(c.dim(` No code files changed since ${sinceLabel}`));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// ── Step 3: Ingest + Step 4: Rebuild (skip if Copilot/Claude handled via MCP) ──
|
|
293
|
+
let totalPagesCreated = 0;
|
|
294
|
+
let ingestErrors = 0;
|
|
295
|
+
if (!skipManualSync) {
|
|
296
|
+
const { ingestSource } = loadServerTool("ingest-source");
|
|
297
|
+
const { updateIndex } = loadServerTool("update-index");
|
|
298
|
+
if (sourceFilesToProcess.length > 0) {
|
|
299
|
+
info(`\n 📥 Ingesting sources...`);
|
|
300
|
+
}
|
|
301
|
+
for (const filename of sourceFilesToProcess) {
|
|
302
|
+
try {
|
|
303
|
+
const result = await ingestSource({ project_path: projectPath, source_filename: filename });
|
|
304
|
+
const created = result.pages_created?.length ?? 0;
|
|
305
|
+
totalPagesCreated += created;
|
|
306
|
+
info(` ${created > 0 ? c.green("✓") : c.yellow("~")} ${filename} → ${created} page(s)`);
|
|
307
|
+
}
|
|
308
|
+
catch (e) {
|
|
309
|
+
info(c.red(` ✗ ${filename}: ${e.message}`));
|
|
310
|
+
ingestErrors++;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
info(`\n 📋 Rebuilding index...`);
|
|
314
|
+
const indexResult = await updateIndex({ project_path: projectPath });
|
|
315
|
+
info(c.green(` ✅ Index rebuilt — ${indexResult.entries_indexed} entries`));
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
info(c.dim(` ℹ️ Ingest/index handled by Copilot/Claude via MCP tools directly`));
|
|
319
|
+
}
|
|
320
|
+
// ── Step 5: Lint ─────────────────────────────────────────────────────────────
|
|
321
|
+
let healthScore = 100;
|
|
322
|
+
if (!options.noLint) {
|
|
323
|
+
info(`\n 🔍 Running wiki health check...`);
|
|
324
|
+
const { lintWiki } = loadServerTool("lint-wiki");
|
|
325
|
+
const lintResult = await lintWiki({ project_path: projectPath, check_type: "all" });
|
|
326
|
+
healthScore = lintResult.health_score ?? 100;
|
|
327
|
+
const scoreLabel = healthScore >= 90
|
|
328
|
+
? c.green(`${healthScore}/100`)
|
|
329
|
+
: healthScore >= 70
|
|
330
|
+
? c.yellow(`${healthScore}/100`)
|
|
331
|
+
: c.red(`${healthScore}/100`);
|
|
332
|
+
info(` 📊 Wiki health: ${scoreLabel}`);
|
|
333
|
+
if (lintResult.issues_found?.length > 0) {
|
|
334
|
+
const high = lintResult.issues_found.filter((i) => i.severity === "high").length;
|
|
335
|
+
const med = lintResult.issues_found.filter((i) => i.severity === "medium").length;
|
|
336
|
+
const low = lintResult.issues_found.filter((i) => i.severity === "low").length;
|
|
337
|
+
info(` 🔴 High: ${high} 🟡 Medium: ${med} 🟢 Low: ${low}`);
|
|
338
|
+
}
|
|
339
|
+
for (const rec of lintResult.recommendations?.slice(0, 3) ?? []) {
|
|
340
|
+
info(c.dim(` → ${rec}`));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// ── Summary ──────────────────────────────────────────────────────────────────
|
|
344
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
345
|
+
info(`\n ─────────────────────────────────────────────`);
|
|
346
|
+
info(` Sources processed: ${sourceFilesToProcess.length}`);
|
|
347
|
+
info(` Pages created: ${totalPagesCreated}`);
|
|
348
|
+
info(` Ingest errors: ${ingestErrors > 0 ? c.red(String(ingestErrors)) : "0"}`);
|
|
349
|
+
if (!options.noLint) {
|
|
350
|
+
info(` Health score: ${healthScore}/100`);
|
|
351
|
+
}
|
|
352
|
+
info(` Duration: ${elapsed}s`);
|
|
353
|
+
info(` ─────────────────────────────────────────────\n`);
|
|
354
|
+
// Exit with code 1 if health is below threshold (useful for CI)
|
|
355
|
+
if (!options.noLint && healthScore < failThreshold) {
|
|
356
|
+
process.exit(1);
|
|
357
|
+
}
|
|
358
|
+
if (ingestErrors > 0) {
|
|
359
|
+
process.exit(1);
|
|
360
|
+
}
|
|
361
|
+
}
|