dotmd-cli 0.8.2 → 0.8.4

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 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,
@@ -244,8 +266,14 @@ Options:
244
266
 
245
267
  lint: `dotmd lint [--fix] — check and auto-fix frontmatter issues
246
268
 
247
- Scans all docs for fixable problems: missing updated dates, status casing,
248
- camelCase key names, trailing whitespace in values, missing EOF newline.
269
+ Scans all docs for fixable problems:
270
+ - Missing status (inferred via local AI model when available)
271
+ - Missing updated date (set to today)
272
+ - Status casing (e.g. Active → active)
273
+ - camelCase key names (e.g. nextStep → next_step)
274
+ - Comma-separated surface values (converted to surfaces: array)
275
+ - Trailing whitespace in frontmatter values
276
+ - Missing newline at end of file
249
277
 
250
278
  Without --fix, reports all issues. With --fix, applies fixes in place.
251
279
  Use --dry-run (-n) with --fix to preview without writing anything.`,
@@ -320,7 +348,14 @@ async function main() {
320
348
  const verbose = args.includes('--verbose');
321
349
 
322
350
  const config = await resolveConfig(process.cwd(), explicitConfig);
323
- const restArgs = args.slice(1);
351
+
352
+ // Strip global flags from restArgs so commands don't have to filter them
353
+ const restArgs = [];
354
+ for (let i = 1; i < args.length; i++) {
355
+ if (args[i] === '--config') { i++; continue; }
356
+ if (args[i] === '--dry-run' || args[i] === '-n' || args[i] === '--verbose') continue;
357
+ restArgs.push(args[i]);
358
+ }
324
359
 
325
360
  if (!config.configFound && command !== 'init') {
326
361
  warn('No dotmd config found — using defaults. Run `dotmd init` to create one.');
@@ -351,16 +386,16 @@ async function main() {
351
386
  if (command === 'diff') { runDiff(restArgs, config); return; }
352
387
  if (command === 'summary') { runSummary(restArgs, config); return; }
353
388
  if (command === 'deps') { runDeps(restArgs, config); return; }
354
- if (command === 'export') { runExport(restArgs, config); return; }
389
+ if (command === 'export') { runExport(restArgs, config, { dryRun }); return; }
355
390
  if (command === 'notion') { await runNotion(restArgs, config, { dryRun }); return; }
356
391
 
357
392
  // Lifecycle commands
358
- if (command === 'status') { runStatus(restArgs, config, { dryRun }); return; }
393
+ if (command === 'status') { await runStatus(restArgs, config, { dryRun }); return; }
359
394
  if (command === 'archive') { runArchive(restArgs, config, { dryRun }); return; }
360
395
  if (command === 'touch') { runTouch(restArgs, config, { dryRun }); return; }
361
- if (command === 'new') { runNew(restArgs, config, { dryRun }); return; }
396
+ if (command === 'new') { await runNew(restArgs, config, { dryRun }); return; }
362
397
  if (command === 'lint') { runLint(restArgs, config, { dryRun }); return; }
363
- if (command === 'rename') { runRename(restArgs, config, { dryRun }); return; }
398
+ if (command === 'rename') { await runRename(restArgs, config, { dryRun }); return; }
364
399
  if (command === 'migrate') { runMigrate(restArgs, config, { dryRun }); return; }
365
400
  if (command === 'fix-refs') { runFixRefs(restArgs, config, { dryRun }); return; }
366
401
  if (command === 'doctor') { runDoctor(restArgs, config, { dryRun }); return; }
@@ -388,7 +423,9 @@ async function main() {
388
423
  }
389
424
 
390
425
  if (command === 'list') {
391
- if (args.includes('--verbose')) {
426
+ if (args.includes('--json')) {
427
+ process.stdout.write(`${JSON.stringify(index, null, 2)}\n`);
428
+ } else if (args.includes('--verbose')) {
392
429
  process.stdout.write(renderVerboseList(index, config));
393
430
  } else {
394
431
  process.stdout.write(renderCompactList(index, config));
@@ -419,6 +456,19 @@ async function main() {
419
456
  return;
420
457
  }
421
458
 
459
+ if (args.includes('--json')) {
460
+ process.stdout.write(JSON.stringify({
461
+ docsScanned: index.docs.length,
462
+ errors: index.errors,
463
+ warnings: errorsOnly ? [] : index.warnings,
464
+ errorCount: index.errors.length,
465
+ warningCount: index.warnings.length,
466
+ passed: index.errors.length === 0,
467
+ }, null, 2) + '\n');
468
+ if (index.errors.length > 0) process.exitCode = 1;
469
+ return;
470
+ }
471
+
422
472
  process.stdout.write(renderCheck(index, config, { errorsOnly }));
423
473
  if (index.errors.length > 0) process.exitCode = 1;
424
474
  return;
@@ -463,6 +513,23 @@ async function main() {
463
513
  if (command === 'focus') { runFocus(index, restArgs, config); return; }
464
514
  if (command === 'query') { runQuery(index, restArgs, config); return; }
465
515
  if (command === 'context') {
516
+ if (args.includes('--json')) {
517
+ const byStatus = {};
518
+ for (const s of config.statusOrder) {
519
+ const docs = index.docs.filter(d => d.status === s);
520
+ if (docs.length) byStatus[s] = docs;
521
+ }
522
+ const stale = index.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status));
523
+ process.stdout.write(JSON.stringify({
524
+ generatedAt: new Date().toISOString(),
525
+ docsByStatus: byStatus,
526
+ countsByStatus: index.countsByStatus,
527
+ stale: stale.map(d => ({ path: d.path, title: d.title, daysSinceUpdate: d.daysSinceUpdate })),
528
+ errorCount: index.errors.length,
529
+ warningCount: index.warnings.length,
530
+ }, null, 2) + '\n');
531
+ return;
532
+ }
466
533
  const summarize = args.includes('--summarize');
467
534
  const modelIdx = args.indexOf('--model');
468
535
  const model = modelIdx !== -1 && args[modelIdx + 1] ? args[modelIdx + 1] : undefined;
@@ -489,8 +556,20 @@ async function main() {
489
556
  return;
490
557
  }
491
558
 
492
- // Unknown command — show help
493
- die(`Unknown command: ${command}\n\n${HELP._main}`);
559
+ // Unknown command — suggest closest match
560
+ const allCommands = [
561
+ 'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'context',
562
+ 'focus', 'query', 'index', 'status', 'archive', 'touch', 'doctor',
563
+ 'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
564
+ 'watch', 'diff', 'new', 'init', 'completions',
565
+ ];
566
+ const matches = allCommands
567
+ .map(c => ({ cmd: c, dist: levenshtein(command, c) }))
568
+ .sort((a, b) => a.dist - b.dist);
569
+ if (matches[0] && matches[0].dist <= 3) {
570
+ die(`Unknown command: ${command}\n\nDid you mean \`dotmd ${matches[0].cmd}\`?`);
571
+ }
572
+ die(`Unknown command: ${command}\n\nRun \`dotmd --help\` for available commands.`);
494
573
  }
495
574
 
496
575
  main().catch(err => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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 = 'mlx-community/Llama-3.2-3B-Instruct-4bit', maxTokens = 200, timeout = 120000 } = opts;
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();
@@ -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 + '\nRun `dotmd init` to create a starter config.');
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 = 'mlx-community/Llama-3.2-3B-Instruct-4bit';
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
- const content = exportMarkdown(docsWithBody, config);
67
- if (output) {
68
- writeFileSync(output, content, 'utf8');
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
- process.stdout.write(content);
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
- const content = exportJson(docsWithBody);
75
- if (output) {
76
- writeFileSync(output, content, 'utf8');
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
- process.stdout.write(content);
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
- exportHtml(docsWithBody, config, outDir);
84
- process.stdout.write(`Exported ${docs.length} docs to ${outDir}/\n`);
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
 
@@ -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 prefix = config.docsRootPrefix;
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 = prefix ? doc.path.replace(prefix, '') : doc.path;
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 prefix = config.docsRootPrefix;
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 = prefix ? doc.path.replace(prefix, '') : doc.path;
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
- const newStatus = argv[1];
19
+ let newStatus = argv[1];
19
20
 
20
- if (!input || !newStatus) { die('Usage: dotmd status <file> <new-status>'); }
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
- const name = positional[0];
61
- if (!name) { die('Usage: dotmd new <name> [--template <t>] [--status <s>] [--title <t>]\n dotmd new --list-templates'); }
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[0] ?? 'active';
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, { model: undefined });
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
- const newInput = positional[1];
21
-
22
- if (!oldInput || !newInput) {
23
- die('Usage: dotmd rename <old> <new>');
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;