dotmd-cli 0.8.1 → 0.8.3
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/bin/dotmd.mjs +83 -10
- package/package.json +1 -1
- package/src/ai.mjs +6 -1
- package/src/completions.mjs +9 -4
- package/src/config.mjs +1 -1
- package/src/diff.mjs +2 -2
- package/src/export.mjs +30 -13
- package/src/index-file.mjs +5 -4
- package/src/lifecycle.mjs +12 -3
- package/src/lint.mjs +28 -0
- package/src/new.mjs +12 -3
- package/src/prompt.mjs +25 -0
- package/src/query.mjs +7 -2
- package/src/rename.mjs +12 -5
- package/src/util.mjs +15 -0
- package/src/validate.mjs +6 -3
package/bin/dotmd.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { runSummary } from '../src/summary.mjs';
|
|
|
25
25
|
import { runDeps } from '../src/deps.mjs';
|
|
26
26
|
import { runExport } from '../src/export.mjs';
|
|
27
27
|
import { runNotion } from '../src/notion.mjs';
|
|
28
|
-
import { die, warn } from '../src/util.mjs';
|
|
28
|
+
import { die, warn, levenshtein } from '../src/util.mjs';
|
|
29
29
|
|
|
30
30
|
const __filename = fileURLToPath(import.meta.url);
|
|
31
31
|
const __dirname = path.dirname(__filename);
|
|
@@ -88,7 +88,10 @@ Filters:
|
|
|
88
88
|
--limit <n> Max results (default: 20)
|
|
89
89
|
--all Show all results (no limit)
|
|
90
90
|
--git Use git dates instead of frontmatter
|
|
91
|
-
--json Output as JSON
|
|
91
|
+
--json Output as JSON
|
|
92
|
+
--summarize Add AI summaries to results
|
|
93
|
+
--summarize-limit <n> Max docs to summarize (default: 5)
|
|
94
|
+
--model <name> MLX model for AI summaries`,
|
|
92
95
|
|
|
93
96
|
status: `dotmd status <file> <new-status> — transition document status
|
|
94
97
|
|
|
@@ -111,6 +114,25 @@ references in other docs, and regenerates the index.
|
|
|
111
114
|
|
|
112
115
|
Use --dry-run (-n) to preview changes without writing anything.`,
|
|
113
116
|
|
|
117
|
+
coverage: `dotmd coverage — metadata coverage report
|
|
118
|
+
|
|
119
|
+
Shows which docs are missing surface, module, or audit metadata.
|
|
120
|
+
|
|
121
|
+
Options:
|
|
122
|
+
--json Machine-readable JSON output`,
|
|
123
|
+
|
|
124
|
+
focus: `dotmd focus [status] — detailed view for one status group
|
|
125
|
+
|
|
126
|
+
Shows detailed info for all docs matching the given status (default: active).`,
|
|
127
|
+
|
|
128
|
+
context: `dotmd context — compact briefing (LLM-oriented)
|
|
129
|
+
|
|
130
|
+
Generates a compact status briefing designed for AI/LLM consumption.
|
|
131
|
+
|
|
132
|
+
Options:
|
|
133
|
+
--summarize Add AI summaries for expanded docs
|
|
134
|
+
--model <name> MLX model for AI summaries`,
|
|
135
|
+
|
|
114
136
|
stats: `dotmd stats — doc health dashboard
|
|
115
137
|
|
|
116
138
|
Shows aggregated metrics: status counts, staleness, errors/warnings,
|
|
@@ -320,7 +342,14 @@ async function main() {
|
|
|
320
342
|
const verbose = args.includes('--verbose');
|
|
321
343
|
|
|
322
344
|
const config = await resolveConfig(process.cwd(), explicitConfig);
|
|
323
|
-
|
|
345
|
+
|
|
346
|
+
// Strip global flags from restArgs so commands don't have to filter them
|
|
347
|
+
const restArgs = [];
|
|
348
|
+
for (let i = 1; i < args.length; i++) {
|
|
349
|
+
if (args[i] === '--config') { i++; continue; }
|
|
350
|
+
if (args[i] === '--dry-run' || args[i] === '-n' || args[i] === '--verbose') continue;
|
|
351
|
+
restArgs.push(args[i]);
|
|
352
|
+
}
|
|
324
353
|
|
|
325
354
|
if (!config.configFound && command !== 'init') {
|
|
326
355
|
warn('No dotmd config found — using defaults. Run `dotmd init` to create one.');
|
|
@@ -351,16 +380,16 @@ async function main() {
|
|
|
351
380
|
if (command === 'diff') { runDiff(restArgs, config); return; }
|
|
352
381
|
if (command === 'summary') { runSummary(restArgs, config); return; }
|
|
353
382
|
if (command === 'deps') { runDeps(restArgs, config); return; }
|
|
354
|
-
if (command === 'export') { runExport(restArgs, config); return; }
|
|
383
|
+
if (command === 'export') { runExport(restArgs, config, { dryRun }); return; }
|
|
355
384
|
if (command === 'notion') { await runNotion(restArgs, config, { dryRun }); return; }
|
|
356
385
|
|
|
357
386
|
// Lifecycle commands
|
|
358
|
-
if (command === 'status') { runStatus(restArgs, config, { dryRun }); return; }
|
|
387
|
+
if (command === 'status') { await runStatus(restArgs, config, { dryRun }); return; }
|
|
359
388
|
if (command === 'archive') { runArchive(restArgs, config, { dryRun }); return; }
|
|
360
389
|
if (command === 'touch') { runTouch(restArgs, config, { dryRun }); return; }
|
|
361
|
-
if (command === 'new') { runNew(restArgs, config, { dryRun }); return; }
|
|
390
|
+
if (command === 'new') { await runNew(restArgs, config, { dryRun }); return; }
|
|
362
391
|
if (command === 'lint') { runLint(restArgs, config, { dryRun }); return; }
|
|
363
|
-
if (command === 'rename') { runRename(restArgs, config, { dryRun }); return; }
|
|
392
|
+
if (command === 'rename') { await runRename(restArgs, config, { dryRun }); return; }
|
|
364
393
|
if (command === 'migrate') { runMigrate(restArgs, config, { dryRun }); return; }
|
|
365
394
|
if (command === 'fix-refs') { runFixRefs(restArgs, config, { dryRun }); return; }
|
|
366
395
|
if (command === 'doctor') { runDoctor(restArgs, config, { dryRun }); return; }
|
|
@@ -388,7 +417,9 @@ async function main() {
|
|
|
388
417
|
}
|
|
389
418
|
|
|
390
419
|
if (command === 'list') {
|
|
391
|
-
if (args.includes('--
|
|
420
|
+
if (args.includes('--json')) {
|
|
421
|
+
process.stdout.write(`${JSON.stringify(index, null, 2)}\n`);
|
|
422
|
+
} else if (args.includes('--verbose')) {
|
|
392
423
|
process.stdout.write(renderVerboseList(index, config));
|
|
393
424
|
} else {
|
|
394
425
|
process.stdout.write(renderCompactList(index, config));
|
|
@@ -419,6 +450,19 @@ async function main() {
|
|
|
419
450
|
return;
|
|
420
451
|
}
|
|
421
452
|
|
|
453
|
+
if (args.includes('--json')) {
|
|
454
|
+
process.stdout.write(JSON.stringify({
|
|
455
|
+
docsScanned: index.docs.length,
|
|
456
|
+
errors: index.errors,
|
|
457
|
+
warnings: errorsOnly ? [] : index.warnings,
|
|
458
|
+
errorCount: index.errors.length,
|
|
459
|
+
warningCount: index.warnings.length,
|
|
460
|
+
passed: index.errors.length === 0,
|
|
461
|
+
}, null, 2) + '\n');
|
|
462
|
+
if (index.errors.length > 0) process.exitCode = 1;
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
422
466
|
process.stdout.write(renderCheck(index, config, { errorsOnly }));
|
|
423
467
|
if (index.errors.length > 0) process.exitCode = 1;
|
|
424
468
|
return;
|
|
@@ -463,6 +507,23 @@ async function main() {
|
|
|
463
507
|
if (command === 'focus') { runFocus(index, restArgs, config); return; }
|
|
464
508
|
if (command === 'query') { runQuery(index, restArgs, config); return; }
|
|
465
509
|
if (command === 'context') {
|
|
510
|
+
if (args.includes('--json')) {
|
|
511
|
+
const byStatus = {};
|
|
512
|
+
for (const s of config.statusOrder) {
|
|
513
|
+
const docs = index.docs.filter(d => d.status === s);
|
|
514
|
+
if (docs.length) byStatus[s] = docs;
|
|
515
|
+
}
|
|
516
|
+
const stale = index.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status));
|
|
517
|
+
process.stdout.write(JSON.stringify({
|
|
518
|
+
generatedAt: new Date().toISOString(),
|
|
519
|
+
docsByStatus: byStatus,
|
|
520
|
+
countsByStatus: index.countsByStatus,
|
|
521
|
+
stale: stale.map(d => ({ path: d.path, title: d.title, daysSinceUpdate: d.daysSinceUpdate })),
|
|
522
|
+
errorCount: index.errors.length,
|
|
523
|
+
warningCount: index.warnings.length,
|
|
524
|
+
}, null, 2) + '\n');
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
466
527
|
const summarize = args.includes('--summarize');
|
|
467
528
|
const modelIdx = args.indexOf('--model');
|
|
468
529
|
const model = modelIdx !== -1 && args[modelIdx + 1] ? args[modelIdx + 1] : undefined;
|
|
@@ -489,8 +550,20 @@ async function main() {
|
|
|
489
550
|
return;
|
|
490
551
|
}
|
|
491
552
|
|
|
492
|
-
// Unknown command —
|
|
493
|
-
|
|
553
|
+
// Unknown command — suggest closest match
|
|
554
|
+
const allCommands = [
|
|
555
|
+
'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'context',
|
|
556
|
+
'focus', 'query', 'index', 'status', 'archive', 'touch', 'doctor',
|
|
557
|
+
'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
|
|
558
|
+
'watch', 'diff', 'new', 'init', 'completions',
|
|
559
|
+
];
|
|
560
|
+
const matches = allCommands
|
|
561
|
+
.map(c => ({ cmd: c, dist: levenshtein(command, c) }))
|
|
562
|
+
.sort((a, b) => a.dist - b.dist);
|
|
563
|
+
if (matches[0] && matches[0].dist <= 3) {
|
|
564
|
+
die(`Unknown command: ${command}\n\nDid you mean \`dotmd ${matches[0].cmd}\`?`);
|
|
565
|
+
}
|
|
566
|
+
die(`Unknown command: ${command}\n\nRun \`dotmd --help\` for available commands.`);
|
|
494
567
|
}
|
|
495
568
|
|
|
496
569
|
main().catch(err => {
|
package/package.json
CHANGED
package/src/ai.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import { warn } from './util.mjs';
|
|
3
|
+
import { dim } from './color.mjs';
|
|
3
4
|
|
|
4
5
|
let uvChecked = null;
|
|
5
6
|
|
|
@@ -11,10 +12,13 @@ export function checkUvAvailable() {
|
|
|
11
12
|
return uvChecked;
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
export const DEFAULT_MODEL = 'mlx-community/Llama-3.2-3B-Instruct-4bit';
|
|
16
|
+
|
|
14
17
|
export function runMLX(prompt, opts = {}) {
|
|
15
|
-
const { model =
|
|
18
|
+
const { model = DEFAULT_MODEL, maxTokens = 200, timeout = 120000 } = opts;
|
|
16
19
|
if (!checkUvAvailable()) return null;
|
|
17
20
|
|
|
21
|
+
process.stderr.write(dim(' generating...'));
|
|
18
22
|
const result = spawnSync('uv', [
|
|
19
23
|
'run', '--with', 'mlx-lm',
|
|
20
24
|
'python3', '-m', 'mlx_lm', 'generate',
|
|
@@ -24,6 +28,7 @@ export function runMLX(prompt, opts = {}) {
|
|
|
24
28
|
'--verbose', 'false',
|
|
25
29
|
], { encoding: 'utf8', timeout });
|
|
26
30
|
|
|
31
|
+
process.stderr.write('\r\x1b[K');
|
|
27
32
|
if (result.status !== 0) return null;
|
|
28
33
|
|
|
29
34
|
const output = result.stdout.trim();
|
package/src/completions.mjs
CHANGED
|
@@ -14,22 +14,27 @@ const COMMAND_FLAGS = {
|
|
|
14
14
|
'--checklist-open', '--sort', '--limit', '--all', '--git', '--json',
|
|
15
15
|
'--summarize', '--summarize-limit', '--model'],
|
|
16
16
|
index: ['--write'],
|
|
17
|
-
list: ['--verbose'],
|
|
17
|
+
list: ['--verbose', '--json'],
|
|
18
18
|
coverage: ['--json'],
|
|
19
19
|
new: ['--status', '--title', '--template', '--list-templates', '--root'],
|
|
20
|
-
diff: ['--stat', '--since', '--summarize', '--model'],
|
|
21
|
-
check: ['--errors-only', '--fix'],
|
|
20
|
+
diff: ['--stat', '--since', '--summarize', '--model', '--max-tokens'],
|
|
21
|
+
check: ['--errors-only', '--fix', '--json'],
|
|
22
22
|
stats: ['--json'],
|
|
23
23
|
graph: ['--dot', '--json', '--status', '--module', '--surface'],
|
|
24
24
|
deps: ['--json', '--depth'],
|
|
25
25
|
notion: ['import', 'export', 'sync', '--force', '--dry-run'],
|
|
26
26
|
export: ['--format', '--output', '--status', '--module', '--root'],
|
|
27
|
+
focus: ['--json'],
|
|
28
|
+
status: [],
|
|
29
|
+
archive: [],
|
|
30
|
+
doctor: [],
|
|
31
|
+
watch: [],
|
|
27
32
|
lint: ['--fix'],
|
|
28
33
|
rename: [],
|
|
29
34
|
migrate: [],
|
|
30
35
|
'fix-refs': [],
|
|
31
36
|
summary: ['--model', '--max-tokens', '--json'],
|
|
32
|
-
context: ['--summarize', '--model'],
|
|
37
|
+
context: ['--summarize', '--model', '--json'],
|
|
33
38
|
touch: ['--git'],
|
|
34
39
|
};
|
|
35
40
|
|
package/src/config.mjs
CHANGED
|
@@ -161,7 +161,7 @@ export async function resolveConfig(cwd, explicitConfigPath) {
|
|
|
161
161
|
try {
|
|
162
162
|
mod = await import(configUrl);
|
|
163
163
|
} catch (err) {
|
|
164
|
-
die('Failed to load config: ' + configPath + '\n' + err.message + '\
|
|
164
|
+
die('Failed to load config: ' + configPath + '\n' + err.message + '\nCheck for syntax errors in your config file.');
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
configDir = path.dirname(configPath);
|
package/src/diff.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
|
3
3
|
import { asString, toRepoPath, resolveDocPath, die, warn } from './util.mjs';
|
|
4
4
|
import { gitDiffSince } from './git.mjs';
|
|
5
5
|
import { buildIndex } from './index.mjs';
|
|
6
|
-
import { summarizeDiffText } from './ai.mjs';
|
|
6
|
+
import { summarizeDiffText, DEFAULT_MODEL } from './ai.mjs';
|
|
7
7
|
import { bold, dim, green } from './color.mjs';
|
|
8
8
|
|
|
9
9
|
export function runDiff(argv, config) {
|
|
@@ -12,7 +12,7 @@ export function runDiff(argv, config) {
|
|
|
12
12
|
let stat = false;
|
|
13
13
|
let sinceOverride = null;
|
|
14
14
|
let summarize = false;
|
|
15
|
-
let model =
|
|
15
|
+
let model = DEFAULT_MODEL;
|
|
16
16
|
|
|
17
17
|
for (let i = 0; i < argv.length; i++) {
|
|
18
18
|
if (argv[i] === '--stat') { stat = true; continue; }
|
package/src/export.mjs
CHANGED
|
@@ -5,13 +5,14 @@ import { buildIndex } from './index.mjs';
|
|
|
5
5
|
import { buildGraph } from './graph.mjs';
|
|
6
6
|
import { resolveDocPath, toRepoPath, capitalize, die } from './util.mjs';
|
|
7
7
|
|
|
8
|
-
export function runExport(argv, config) {
|
|
8
|
+
export function runExport(argv, config, opts = {}) {
|
|
9
9
|
const positional = [];
|
|
10
10
|
let format = 'md';
|
|
11
11
|
let output = null;
|
|
12
12
|
let statusFilter = null;
|
|
13
13
|
let moduleFilter = null;
|
|
14
14
|
let rootFilter = null;
|
|
15
|
+
const dryRun = opts.dryRun;
|
|
15
16
|
|
|
16
17
|
for (let i = 0; i < argv.length; i++) {
|
|
17
18
|
if (argv[i] === '--format' && argv[i + 1]) { format = argv[++i]; continue; }
|
|
@@ -62,26 +63,42 @@ export function runExport(argv, config) {
|
|
|
62
63
|
// Load bodies
|
|
63
64
|
const docsWithBody = docs.map(d => loadDocWithBody(d, config));
|
|
64
65
|
|
|
66
|
+
const prefix = dryRun ? '[dry-run] ' : '';
|
|
67
|
+
|
|
65
68
|
if (format === 'md') {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
process.stdout.write(`Exported ${docs.length} docs to ${output}\n`);
|
|
69
|
+
if (dryRun) {
|
|
70
|
+
process.stdout.write(`${prefix}Would export ${docs.length} docs as markdown`);
|
|
71
|
+
process.stdout.write(output ? ` to ${output}\n` : ' to stdout\n');
|
|
70
72
|
} else {
|
|
71
|
-
|
|
73
|
+
const content = exportMarkdown(docsWithBody, config);
|
|
74
|
+
if (output) {
|
|
75
|
+
writeFileSync(output, content, 'utf8');
|
|
76
|
+
process.stdout.write(`Exported ${docs.length} docs to ${output}\n`);
|
|
77
|
+
} else {
|
|
78
|
+
process.stdout.write(content);
|
|
79
|
+
}
|
|
72
80
|
}
|
|
73
81
|
} else if (format === 'json') {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
process.stdout.write(`Exported ${docs.length} docs to ${output}\n`);
|
|
82
|
+
if (dryRun) {
|
|
83
|
+
process.stdout.write(`${prefix}Would export ${docs.length} docs as JSON`);
|
|
84
|
+
process.stdout.write(output ? ` to ${output}\n` : ' to stdout\n');
|
|
78
85
|
} else {
|
|
79
|
-
|
|
86
|
+
const content = exportJson(docsWithBody);
|
|
87
|
+
if (output) {
|
|
88
|
+
writeFileSync(output, content, 'utf8');
|
|
89
|
+
process.stdout.write(`Exported ${docs.length} docs to ${output}\n`);
|
|
90
|
+
} else {
|
|
91
|
+
process.stdout.write(content);
|
|
92
|
+
}
|
|
80
93
|
}
|
|
81
94
|
} else if (format === 'html') {
|
|
82
95
|
const outDir = output ?? 'dotmd-export';
|
|
83
|
-
|
|
84
|
-
|
|
96
|
+
if (dryRun) {
|
|
97
|
+
process.stdout.write(`${prefix}Would export ${docs.length} docs as HTML to ${outDir}/\n`);
|
|
98
|
+
} else {
|
|
99
|
+
exportHtml(docsWithBody, config, outDir);
|
|
100
|
+
process.stdout.write(`Exported ${docs.length} docs to ${outDir}/\n`);
|
|
101
|
+
}
|
|
85
102
|
}
|
|
86
103
|
}
|
|
87
104
|
|
package/src/index-file.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { capitalize, escapeTable } from './util.mjs';
|
|
3
4
|
import { formatSnapshot } from './render.mjs';
|
|
4
5
|
|
|
@@ -19,7 +20,7 @@ export function renderIndexFile(index, config) {
|
|
|
19
20
|
|
|
20
21
|
function renderGeneratedBlock(index, config) {
|
|
21
22
|
const lines = [];
|
|
22
|
-
const
|
|
23
|
+
const indexDir = config.indexPath ? path.dirname(path.relative(config.repoRoot, config.indexPath)).split(path.sep).join('/') : '';
|
|
23
24
|
|
|
24
25
|
for (const status of config.statusOrder) {
|
|
25
26
|
const docs = index.docs.filter(doc => doc.status === status);
|
|
@@ -37,7 +38,7 @@ function renderGeneratedBlock(index, config) {
|
|
|
37
38
|
lines.push('|-----|-----------------|');
|
|
38
39
|
for (const doc of docs) {
|
|
39
40
|
const snapshot = formatSnapshot(doc, config);
|
|
40
|
-
const linkPath =
|
|
41
|
+
const linkPath = indexDir ? path.relative(indexDir, doc.path).split(path.sep).join('/') : doc.path;
|
|
41
42
|
lines.push(`| [${escapeTable(doc.title)}](${linkPath}) | ${escapeTable(snapshot)} |`);
|
|
42
43
|
}
|
|
43
44
|
lines.push('');
|
|
@@ -49,7 +50,7 @@ function renderGeneratedBlock(index, config) {
|
|
|
49
50
|
function renderArchivedSection(docs, config, status) {
|
|
50
51
|
const lines = [];
|
|
51
52
|
const limit = config.archivedHighlightLimit;
|
|
52
|
-
const
|
|
53
|
+
const indexDir = config.indexPath ? path.dirname(path.relative(config.repoRoot, config.indexPath)).split(path.sep).join('/') : '';
|
|
53
54
|
const highlights = docs
|
|
54
55
|
.filter(doc => doc.currentState && doc.currentState !== 'No current_state set')
|
|
55
56
|
.sort((a, b) => {
|
|
@@ -66,7 +67,7 @@ function renderArchivedSection(docs, config, status) {
|
|
|
66
67
|
lines.push('| Doc | Status Snapshot |');
|
|
67
68
|
lines.push('|-----|-----------------|');
|
|
68
69
|
for (const doc of highlights) {
|
|
69
|
-
const linkPath =
|
|
70
|
+
const linkPath = indexDir ? path.relative(indexDir, doc.path).split(path.sep).join('/') : doc.path;
|
|
70
71
|
lines.push(`| [${escapeTable(doc.title)}](${linkPath}) | ${escapeTable(formatSnapshot(doc, config))} |`);
|
|
71
72
|
}
|
|
72
73
|
lines.push('');
|
package/src/lifecycle.mjs
CHANGED
|
@@ -6,18 +6,27 @@ import { gitMv, getGitLastModified } from './git.mjs';
|
|
|
6
6
|
import { buildIndex, collectDocFiles } from './index.mjs';
|
|
7
7
|
import { renderIndexFile, writeIndex } from './index-file.mjs';
|
|
8
8
|
import { green, dim, yellow } from './color.mjs';
|
|
9
|
+
import { isInteractive, promptChoice } from './prompt.mjs';
|
|
9
10
|
|
|
10
11
|
function findFileRoot(filePath, config) {
|
|
11
12
|
const roots = config.docsRoots || [config.docsRoot];
|
|
12
13
|
return roots.find(r => filePath.startsWith(r)) ?? config.docsRoot;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
export function runStatus(argv, config, opts = {}) {
|
|
16
|
+
export async function runStatus(argv, config, opts = {}) {
|
|
16
17
|
const { dryRun } = opts;
|
|
17
18
|
const input = argv[0];
|
|
18
|
-
|
|
19
|
+
let newStatus = argv[1];
|
|
19
20
|
|
|
20
|
-
if (!input
|
|
21
|
+
if (!input) { die('Usage: dotmd status <file> <new-status>'); }
|
|
22
|
+
if (!newStatus) {
|
|
23
|
+
if (isInteractive()) {
|
|
24
|
+
newStatus = await promptChoice('Which status?', config.statusOrder);
|
|
25
|
+
if (!newStatus) die('No status selected.');
|
|
26
|
+
} else {
|
|
27
|
+
die('Usage: dotmd status <file> <new-status>');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
21
30
|
if (!config.validStatuses.has(newStatus)) { die(`Invalid status: ${newStatus}\nValid: ${[...config.validStatuses].join(', ')}`); }
|
|
22
31
|
|
|
23
32
|
const filePath = resolveDocPath(input, config);
|
package/src/lint.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from '
|
|
|
3
3
|
import { asString, toRepoPath, escapeRegex, warn } from './util.mjs';
|
|
4
4
|
import { buildIndex, collectDocFiles } from './index.mjs';
|
|
5
5
|
import { updateFrontmatter } from './lifecycle.mjs';
|
|
6
|
+
import { runMLX, checkUvAvailable } from './ai.mjs';
|
|
6
7
|
import { bold, green, yellow, dim } from './color.mjs';
|
|
7
8
|
|
|
8
9
|
const KEY_RENAMES = {
|
|
@@ -28,6 +29,11 @@ export function runLint(argv, config, opts = {}) {
|
|
|
28
29
|
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
29
30
|
const fixes = [];
|
|
30
31
|
|
|
32
|
+
// Missing status (fixable via AI inference)
|
|
33
|
+
if (!asString(parsed.status)) {
|
|
34
|
+
fixes.push({ field: 'status', oldValue: null, newValue: null, type: 'infer-status' });
|
|
35
|
+
}
|
|
36
|
+
|
|
31
37
|
// Missing updated
|
|
32
38
|
if (!asString(parsed.updated) && asString(parsed.status) && !config.lifecycle.skipWarningsFor.has(asString(parsed.status))) {
|
|
33
39
|
const today = new Date().toISOString().slice(0, 10);
|
|
@@ -85,6 +91,8 @@ export function runLint(argv, config, opts = {}) {
|
|
|
85
91
|
for (const f of fixes) {
|
|
86
92
|
if (f.type === 'rename-key') {
|
|
87
93
|
process.stdout.write(dim(` ${f.oldValue} → ${f.newValue}\n`));
|
|
94
|
+
} else if (f.type === 'infer-status') {
|
|
95
|
+
process.stdout.write(dim(` missing status (fixable via AI)\n`));
|
|
88
96
|
} else if (f.type === 'split-to-array') {
|
|
89
97
|
process.stdout.write(dim(` ${f.field}: "${f.oldValue}" → surfaces: [${f.newValue.join(', ')}]\n`));
|
|
90
98
|
} else if (f.type === 'eof') {
|
|
@@ -138,6 +146,20 @@ export function runLint(argv, config, opts = {}) {
|
|
|
138
146
|
}
|
|
139
147
|
|
|
140
148
|
if (!dryRun) {
|
|
149
|
+
// Apply infer-status fixes via AI
|
|
150
|
+
for (const f of fixes.filter(f => f.type === 'infer-status')) {
|
|
151
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
152
|
+
const { body } = extractFrontmatter(raw);
|
|
153
|
+
const statusList = config.statusOrder.join(', ');
|
|
154
|
+
const prompt = `Given this markdown document, classify it into exactly one of these statuses: ${statusList}.\nReply with ONLY the status word, nothing else.\n\nFile: ${repoPath}\n\n${(body ?? '').slice(0, 4000)}`;
|
|
155
|
+
const result = runMLX(prompt, { maxTokens: 10 });
|
|
156
|
+
const suggested = result?.trim().toLowerCase().split(/\s+/)[0];
|
|
157
|
+
if (suggested && config.validStatuses.has(suggested)) {
|
|
158
|
+
updateFrontmatter(filePath, { status: suggested });
|
|
159
|
+
f.newValue = suggested;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
141
163
|
// Apply split-to-array fixes (surface: a, b → surfaces: array)
|
|
142
164
|
for (const sa of splitToArray) {
|
|
143
165
|
let raw = readFileSync(filePath, 'utf8');
|
|
@@ -199,6 +221,12 @@ export function runLint(argv, config, opts = {}) {
|
|
|
199
221
|
process.stdout.write(`${prefix} ${dim(`${f.oldValue} → ${f.newValue}`)}\n`);
|
|
200
222
|
} else if (f.type === 'eof') {
|
|
201
223
|
process.stdout.write(`${prefix} ${dim('added newline at EOF')}\n`);
|
|
224
|
+
} else if (f.type === 'infer-status') {
|
|
225
|
+
if (f.newValue) {
|
|
226
|
+
process.stdout.write(`${prefix} ${dim(`status: (missing) → ${f.newValue} (AI-inferred)`)}\n`);
|
|
227
|
+
} else {
|
|
228
|
+
process.stdout.write(`${prefix} ${dim('status: (missing) — AI inference unavailable')}\n`);
|
|
229
|
+
}
|
|
202
230
|
} else if (f.type === 'split-to-array') {
|
|
203
231
|
process.stdout.write(`${prefix} ${dim(`${f.field}: "${f.oldValue}" → surfaces: [${f.newValue.join(', ')}]`)}\n`);
|
|
204
232
|
} else if (f.type === 'add') {
|
package/src/new.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, writeFileSync } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { toRepoPath, die, warn } from './util.mjs';
|
|
4
4
|
import { green, dim, bold } from './color.mjs';
|
|
5
|
+
import { isInteractive, promptText } from './prompt.mjs';
|
|
5
6
|
|
|
6
7
|
const BUILTIN_TEMPLATES = {
|
|
7
8
|
default: {
|
|
@@ -36,7 +37,7 @@ const BUILTIN_TEMPLATES = {
|
|
|
36
37
|
},
|
|
37
38
|
};
|
|
38
39
|
|
|
39
|
-
export function runNew(argv, config, opts = {}) {
|
|
40
|
+
export async function runNew(argv, config, opts = {}) {
|
|
40
41
|
const { dryRun } = opts;
|
|
41
42
|
|
|
42
43
|
// Parse args
|
|
@@ -50,6 +51,7 @@ export function runNew(argv, config, opts = {}) {
|
|
|
50
51
|
if (argv[i] === '--title' && argv[i + 1]) { title = argv[++i]; continue; }
|
|
51
52
|
if (argv[i] === '--template' && argv[i + 1]) { templateName = argv[++i]; continue; }
|
|
52
53
|
if (argv[i] === '--root' && argv[i + 1]) { rootName = argv[++i]; continue; }
|
|
54
|
+
if (argv[i] === '--config') { i++; continue; }
|
|
53
55
|
if (argv[i] === '--list-templates') {
|
|
54
56
|
listTemplates(config);
|
|
55
57
|
return;
|
|
@@ -57,8 +59,15 @@ export function runNew(argv, config, opts = {}) {
|
|
|
57
59
|
if (!argv[i].startsWith('-')) positional.push(argv[i]);
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
|
|
61
|
-
if (!name) {
|
|
62
|
+
let name = positional[0];
|
|
63
|
+
if (!name) {
|
|
64
|
+
if (isInteractive()) {
|
|
65
|
+
name = await promptText('Document name: ');
|
|
66
|
+
if (!name) die('No name provided.');
|
|
67
|
+
} else {
|
|
68
|
+
die('Usage: dotmd new <name> [--template <t>] [--status <s>] [--title <t>]\n dotmd new --list-templates');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
62
71
|
|
|
63
72
|
// Validate status
|
|
64
73
|
if (!config.validStatuses.has(status)) {
|
package/src/prompt.mjs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
|
|
3
|
+
export function isInteractive() {
|
|
4
|
+
return Boolean(process.stdin.isTTY);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function promptText(question) {
|
|
8
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
9
|
+
return new Promise(resolve => {
|
|
10
|
+
rl.question(question, answer => {
|
|
11
|
+
rl.close();
|
|
12
|
+
resolve(answer.trim());
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function promptChoice(question, options) {
|
|
18
|
+
process.stderr.write(question + '\n');
|
|
19
|
+
options.forEach((opt, i) => process.stderr.write(` ${i + 1}) ${opt}\n`));
|
|
20
|
+
const answer = await promptText('> ');
|
|
21
|
+
const idx = parseInt(answer, 10) - 1;
|
|
22
|
+
if (idx >= 0 && idx < options.length) return options[idx];
|
|
23
|
+
const match = options.find(o => o.toLowerCase() === answer.toLowerCase());
|
|
24
|
+
return match ?? null;
|
|
25
|
+
}
|
package/src/query.mjs
CHANGED
|
@@ -9,9 +9,14 @@ import { summarizeDocBody } from './ai.mjs';
|
|
|
9
9
|
import { dim } from './color.mjs';
|
|
10
10
|
|
|
11
11
|
export function runFocus(index, argv, config) {
|
|
12
|
-
const statusFilter = argv
|
|
12
|
+
const statusFilter = argv.find(a => !a.startsWith('-')) ?? 'active';
|
|
13
13
|
const docs = index.docs.filter(doc => doc.status === statusFilter);
|
|
14
14
|
|
|
15
|
+
if (argv.includes('--json')) {
|
|
16
|
+
process.stdout.write(JSON.stringify({ status: statusFilter, count: docs.length, docs }, null, 2) + '\n');
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
15
20
|
if (docs.length === 0) {
|
|
16
21
|
process.stdout.write(`No docs found for status: ${statusFilter}\n`);
|
|
17
22
|
return;
|
|
@@ -139,7 +144,7 @@ function getDocSummary(doc, config) {
|
|
|
139
144
|
const meta = { title: doc.title, status: doc.status, path: doc.path };
|
|
140
145
|
return config.hooks.summarizeDoc
|
|
141
146
|
? config.hooks.summarizeDoc(body, meta)
|
|
142
|
-
: summarizeDocBody(body, meta
|
|
147
|
+
: summarizeDocBody(body, meta);
|
|
143
148
|
} catch { return null; }
|
|
144
149
|
}
|
|
145
150
|
|
package/src/rename.mjs
CHANGED
|
@@ -5,8 +5,9 @@ import { toRepoPath, resolveDocPath, die, warn } from './util.mjs';
|
|
|
5
5
|
import { collectDocFiles } from './index.mjs';
|
|
6
6
|
import { gitMv } from './git.mjs';
|
|
7
7
|
import { green, dim, yellow } from './color.mjs';
|
|
8
|
+
import { isInteractive, promptText } from './prompt.mjs';
|
|
8
9
|
|
|
9
|
-
export function runRename(argv, config, opts = {}) {
|
|
10
|
+
export async function runRename(argv, config, opts = {}) {
|
|
10
11
|
const { dryRun } = opts;
|
|
11
12
|
|
|
12
13
|
// Parse positional args (skip flags)
|
|
@@ -17,10 +18,16 @@ export function runRename(argv, config, opts = {}) {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
const oldInput = positional[0];
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (!oldInput
|
|
23
|
-
|
|
21
|
+
let newInput = positional[1];
|
|
22
|
+
|
|
23
|
+
if (!oldInput) { die('Usage: dotmd rename <old> <new>'); }
|
|
24
|
+
if (!newInput) {
|
|
25
|
+
if (isInteractive()) {
|
|
26
|
+
newInput = await promptText('New name: ');
|
|
27
|
+
if (!newInput) die('No name provided.');
|
|
28
|
+
} else {
|
|
29
|
+
die('Usage: dotmd rename <old> <new>');
|
|
30
|
+
}
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
// Resolve old path
|
package/src/util.mjs
CHANGED
|
@@ -70,6 +70,21 @@ export function die(message) {
|
|
|
70
70
|
throw new DotmdError(message);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
export function levenshtein(a, b) {
|
|
74
|
+
if (a.length === 0) return b.length;
|
|
75
|
+
if (b.length === 0) return a.length;
|
|
76
|
+
const matrix = [];
|
|
77
|
+
for (let i = 0; i <= b.length; i++) matrix[i] = [i];
|
|
78
|
+
for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
|
|
79
|
+
for (let i = 1; i <= b.length; i++) {
|
|
80
|
+
for (let j = 1; j <= a.length; j++) {
|
|
81
|
+
const cost = b[i - 1] === a[j - 1] ? 0 : 1;
|
|
82
|
+
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return matrix[b.length][a.length];
|
|
86
|
+
}
|
|
87
|
+
|
|
73
88
|
export function resolveDocPath(input, config) {
|
|
74
89
|
if (!input) return null;
|
|
75
90
|
if (path.isAbsolute(input)) return existsSync(input) ? input : null;
|
package/src/validate.mjs
CHANGED
|
@@ -13,15 +13,18 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
|
13
13
|
doc.warnings.push({ path: doc.path, level: 'warning', message: `Unknown status \`${doc.status}\`; not in statuses.order.` });
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// Only enforce lifecycle fields for known statuses (skip for unknown like implemented, partial, etc.)
|
|
17
|
+
const knownStatus = config.validStatuses.has(doc.status);
|
|
18
|
+
|
|
19
|
+
if (knownStatus && !config.lifecycle.skipWarningsFor.has(doc.status) && !doc.updated) {
|
|
17
20
|
doc.errors.push({ path: doc.path, level: 'error', message: 'Missing frontmatter `updated` for non-archived doc.' });
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
if (doc.auditLevel && doc.auditLevel !== 'none' && !doc.audited) {
|
|
23
|
+
if (knownStatus && doc.auditLevel && doc.auditLevel !== 'none' && !doc.audited) {
|
|
21
24
|
doc.errors.push({ path: doc.path, level: 'error', message: '`audit_level` is set without `audited`.' });
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
if (doc.auditLevel === 'none' && doc.audited) {
|
|
27
|
+
if (knownStatus && doc.auditLevel === 'none' && doc.audited) {
|
|
25
28
|
doc.errors.push({ path: doc.path, level: 'error', message: '`audit_level: none` cannot be combined with `audited`.' });
|
|
26
29
|
}
|
|
27
30
|
|