dotmd-cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,16 +7,28 @@ Index, query, validate, and lifecycle-manage any collection of `.md` files — p
7
7
  ## Install
8
8
 
9
9
  ```bash
10
- npm install -g dotmd-cli
10
+ npm install -g dotmd-cli # global — use `dotmd` anywhere
11
+ npm install -D dotmd-cli # project devDep — use via npm scripts
11
12
  ```
12
13
 
13
14
  ## Quick Start
14
15
 
15
16
  ```bash
16
- dotmd init # creates dotmd.config.mjs + docs/ + docs/README.md
17
- dotmd list # index all docs grouped by status
18
- dotmd check # validate frontmatter and references
19
- dotmd context # compact briefing (great for LLM context)
17
+ dotmd init # creates dotmd.config.mjs, docs/, docs/docs.md
18
+ dotmd new my-feature # scaffold a new doc with frontmatter
19
+ dotmd list # index all docs grouped by status
20
+ dotmd check # validate frontmatter and references
21
+ dotmd context # compact briefing (great for LLM context)
22
+ ```
23
+
24
+ ### Shell Completion
25
+
26
+ ```bash
27
+ # bash
28
+ eval "$(dotmd completions bash)" # add to ~/.bashrc
29
+
30
+ # zsh
31
+ eval "$(dotmd completions zsh)" # add to ~/.zshrc
20
32
  ```
21
33
 
22
34
  ## What It Does
@@ -27,8 +39,10 @@ dotmd scans a directory of markdown files, parses their YAML frontmatter, and gi
27
39
  - **Query** — filter by status, keyword, module, surface, owner, staleness
28
40
  - **Validate** — check for missing fields, broken references, stale dates
29
41
  - **Lifecycle** — transition statuses, auto-archive with `git mv`, bump dates
30
- - **README generation** — auto-generate an index block in your README
42
+ - **Scaffold** — create new docs with frontmatter from the command line
43
+ - **Index generation** — auto-generate a `docs.md` index block
31
44
  - **Context briefing** — compact summary designed for AI/LLM consumption
45
+ - **Dry-run** — preview any mutation with `--dry-run` before committing
32
46
 
33
47
  ## Document Format
34
48
 
@@ -58,7 +72,7 @@ The only required field is `status`. Everything else is optional but unlocks mor
58
72
  ## Commands
59
73
 
60
74
  ```
61
- dotmd list [--verbose] List docs grouped by status
75
+ dotmd list [--verbose] List docs grouped by status (default)
62
76
  dotmd json Full index as JSON
63
77
  dotmd check Validate frontmatter and references
64
78
  dotmd coverage [--json] Metadata coverage report
@@ -69,7 +83,21 @@ dotmd index [--write] Generate/update docs.md index block
69
83
  dotmd status <file> <status> Transition document status
70
84
  dotmd archive <file> Archive (status + move + index regen)
71
85
  dotmd touch <file> Bump updated date
86
+ dotmd watch [command] Re-run a command on file changes
87
+ dotmd diff [file] Show changes since last updated date
88
+ dotmd new <name> Create a new document with frontmatter
72
89
  dotmd init Create starter config + docs directory
90
+ dotmd completions <shell> Output shell completion script (bash, zsh)
91
+ ```
92
+
93
+ ### Global Flags
94
+
95
+ ```
96
+ --config <path> Explicit config file path
97
+ --dry-run, -n Preview changes without writing anything
98
+ --verbose Show resolved config details
99
+ --help, -h Show help (per-command with: dotmd <cmd> --help)
100
+ --version, -v Show version
73
101
  ```
74
102
 
75
103
  ### Query Filters
@@ -83,6 +111,15 @@ dotmd query --surface backend --checklist-open
83
111
 
84
112
  Flags: `--status`, `--keyword`, `--module`, `--surface`, `--domain`, `--owner`, `--updated-since`, `--stale`, `--has-next-step`, `--has-blockers`, `--checklist-open`, `--sort`, `--limit`, `--all`, `--git`, `--json`.
85
113
 
114
+ ### Scaffold a Document
115
+
116
+ ```bash
117
+ dotmd new my-feature # creates docs/my-feature.md (status: active)
118
+ dotmd new "API Redesign" --status planned # custom status
119
+ dotmd new auth-refresh --title "Auth Refresh" # custom title
120
+ dotmd new something --dry-run # preview without creating
121
+ ```
122
+
86
123
  ### Preset Aliases
87
124
 
88
125
  Define custom query presets in your config:
@@ -96,6 +133,29 @@ export const presets = {
96
133
 
97
134
  Then run `dotmd stale` or `dotmd mine` as shorthand.
98
135
 
136
+ ### Watch Mode
137
+
138
+ ```bash
139
+ dotmd watch # re-run list on every .md change
140
+ dotmd watch check # live validation
141
+ dotmd watch context # live briefing
142
+ ```
143
+
144
+ ### Diff & Summarize
145
+
146
+ Show git changes since each document's `updated` frontmatter date:
147
+
148
+ ```bash
149
+ dotmd diff # all drifted docs
150
+ dotmd diff docs/plans/auth.md # single file
151
+ dotmd diff --stat # summary stats only
152
+ dotmd diff --since 2026-01-01 # override date
153
+ dotmd diff --summarize # AI summary via local MLX model
154
+ dotmd diff --summarize --model mlx-community/Mistral-7B-Instruct-v0.3-4bit
155
+ ```
156
+
157
+ The `--summarize` flag requires `uv` and a local MLX-compatible model. No JS dependencies are added.
158
+
99
159
  ## Configuration
100
160
 
101
161
  Create `dotmd.config.mjs` at your project root (or run `dotmd init`):
@@ -115,8 +175,8 @@ export const lifecycle = {
115
175
  skipWarningsFor: ['archived'],
116
176
  };
117
177
 
118
- export const readme = {
119
- path: 'docs/plans/README.md',
178
+ export const index = {
179
+ path: 'docs/docs.md',
120
180
  startMarker: '<!-- GENERATED:dotmd:start -->',
121
181
  endMarker: '<!-- GENERATED:dotmd:end -->',
122
182
  };
@@ -178,9 +238,11 @@ Available: `onArchive`, `onStatusChange`, `onTouch`.
178
238
  - **Zero dependencies** — pure Node.js builtins (`fs`, `path`, `child_process`)
179
239
  - **No build step** — ships as plain ESM, runs directly
180
240
  - **Git-aware** — detects frontmatter date drift vs git history, uses `git mv` for archives
241
+ - **Dry-run everything** — preview any mutation with `--dry-run` / `-n`
181
242
  - **Configurable everything** — statuses, taxonomy, lifecycle, validation rules, display
182
243
  - **Hook system** — extend with JS functions, no plugin framework to learn
183
244
  - **LLM-friendly** — `dotmd context` generates compact briefings for AI assistants
245
+ - **Shell completion** — bash and zsh via `dotmd completions`
184
246
 
185
247
  ## License
186
248
 
package/bin/dotmd.mjs CHANGED
@@ -12,6 +12,8 @@ import { runStatus, runArchive, runTouch } from '../src/lifecycle.mjs';
12
12
  import { runInit } from '../src/init.mjs';
13
13
  import { runNew } from '../src/new.mjs';
14
14
  import { runCompletions } from '../src/completions.mjs';
15
+ import { runWatch } from '../src/watch.mjs';
16
+ import { runDiff } from '../src/diff.mjs';
15
17
  import { die, warn } from '../src/util.mjs';
16
18
 
17
19
  const __filename = fileURLToPath(import.meta.url);
@@ -33,6 +35,8 @@ Commands:
33
35
  status <file> <status> Transition document status
34
36
  archive <file> Archive (status + move + index regen)
35
37
  touch <file> Bump updated date
38
+ watch [command] Re-run a command on file changes
39
+ diff [file] Show changes since last updated date
36
40
  new <name> Create a new document with frontmatter
37
41
  init Create starter config + docs directory
38
42
  completions <shell> Output shell completion script (bash, zsh)
@@ -97,6 +101,27 @@ Options:
97
101
  The filename is derived from <name> by slugifying it.
98
102
  Use --dry-run (-n) to preview without creating the file.`,
99
103
 
104
+ watch: `dotmd watch [command] — re-run a command on file changes
105
+
106
+ Watches the docs root for .md file changes and re-runs the specified
107
+ command. Defaults to 'list' if no command given.
108
+
109
+ Examples:
110
+ dotmd watch # re-run list on changes
111
+ dotmd watch check # re-run check on changes
112
+ dotmd watch context # live briefing`,
113
+
114
+ diff: `dotmd diff [file] — show changes since last updated date
115
+
116
+ Shows git diffs for docs that changed after their frontmatter updated date.
117
+ Without a file argument, shows all drifted docs.
118
+
119
+ Options:
120
+ --stat Summary only (files changed, insertions/deletions)
121
+ --since <date> Override: diff since this date instead of frontmatter
122
+ --summarize Generate AI summary using local MLX model
123
+ --model <name> MLX model to use (default: mlx-community/Llama-3.2-3B-Instruct-4bit)`,
124
+
100
125
  init: `dotmd init — create starter config and docs directory
101
126
 
102
127
  Creates dotmd.config.mjs, docs/, and docs/docs.md in the current
@@ -167,6 +192,10 @@ async function main() {
167
192
  return;
168
193
  }
169
194
 
195
+ // Watch and diff (handle their own index building)
196
+ if (command === 'watch') { runWatch(restArgs, config); return; }
197
+ if (command === 'diff') { runDiff(restArgs, config); return; }
198
+
170
199
  // Lifecycle commands
171
200
  if (command === 'status') { runStatus(restArgs, config, { dryRun }); return; }
172
201
  if (command === 'archive') { runArchive(restArgs, config, { dryRun }); return; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Zero-dependency CLI for managing markdown documents with YAML frontmatter — index, query, validate, lifecycle.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -2,7 +2,7 @@ import { die } from './util.mjs';
2
2
 
3
3
  const COMMANDS = [
4
4
  'list', 'json', 'check', 'coverage', 'context', 'focus', 'query',
5
- 'index', 'status', 'archive', 'touch', 'init', 'new', 'completions',
5
+ 'index', 'status', 'archive', 'touch', 'watch', 'diff', 'init', 'new', 'completions',
6
6
  ];
7
7
 
8
8
  const GLOBAL_FLAGS = ['--config', '--dry-run', '--verbose', '--help', '--version'];
@@ -15,6 +15,7 @@ const COMMAND_FLAGS = {
15
15
  list: ['--verbose'],
16
16
  coverage: ['--json'],
17
17
  new: ['--status', '--title'],
18
+ diff: ['--stat', '--since', '--summarize', '--model'],
18
19
  };
19
20
 
20
21
  function bashCompletion() {
package/src/diff.mjs ADDED
@@ -0,0 +1,114 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
+ import { asString, toRepoPath, resolveDocPath, die, warn } from './util.mjs';
5
+ import { gitDiffSince } from './git.mjs';
6
+ import { buildIndex } from './index.mjs';
7
+ import { bold, dim, green } from './color.mjs';
8
+
9
+ export function runDiff(argv, config) {
10
+ // Parse flags
11
+ let file = null;
12
+ let stat = false;
13
+ let sinceOverride = null;
14
+ let summarize = false;
15
+ let model = 'mlx-community/Llama-3.2-3B-Instruct-4bit';
16
+
17
+ for (let i = 0; i < argv.length; i++) {
18
+ if (argv[i] === '--stat') { stat = true; continue; }
19
+ if (argv[i] === '--summarize') { summarize = true; continue; }
20
+ if (argv[i] === '--since' && argv[i + 1]) { sinceOverride = argv[++i]; continue; }
21
+ if (argv[i] === '--model' && argv[i + 1]) { model = argv[++i]; continue; }
22
+ if (!argv[i].startsWith('-')) { file = argv[i]; }
23
+ }
24
+
25
+ if (file) {
26
+ // Single file mode
27
+ const filePath = resolveDocPath(file, config);
28
+ if (!filePath) {
29
+ die(`File not found: ${file}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
30
+ return;
31
+ }
32
+
33
+ const raw = readFileSync(filePath, 'utf8');
34
+ const { frontmatter } = extractFrontmatter(raw);
35
+ const parsed = parseSimpleFrontmatter(frontmatter);
36
+ const since = sinceOverride ?? asString(parsed.updated);
37
+
38
+ if (!since) {
39
+ die(`No updated date found in ${file} and no --since provided.`);
40
+ return;
41
+ }
42
+
43
+ const relPath = toRepoPath(filePath, config.repoRoot);
44
+ const diffOutput = gitDiffSince(relPath, since, config.repoRoot, { stat });
45
+
46
+ if (!diffOutput) {
47
+ process.stdout.write(`No changes since ${since} for ${relPath}\n`);
48
+ return;
49
+ }
50
+
51
+ printFileDiff(relPath, since, diffOutput, { summarize, model, config });
52
+ } else {
53
+ // All drifted docs mode
54
+ const index = buildIndex(config);
55
+ const drifted = [];
56
+
57
+ for (const doc of index.docs) {
58
+ if (!doc.updated) continue;
59
+ const relPath = doc.path;
60
+ const since = sinceOverride ?? doc.updated;
61
+ const diffOutput = gitDiffSince(relPath, since, config.repoRoot, { stat });
62
+ if (diffOutput && diffOutput.trim()) {
63
+ drifted.push({ relPath, since, diffOutput });
64
+ }
65
+ }
66
+
67
+ if (drifted.length === 0) {
68
+ process.stdout.write('No drifted docs.\n');
69
+ return;
70
+ }
71
+
72
+ process.stdout.write(bold(`${drifted.length} doc(s) with changes since their updated date:\n\n`));
73
+ for (const { relPath, since, diffOutput } of drifted) {
74
+ printFileDiff(relPath, since, diffOutput, { summarize, model, config });
75
+ }
76
+ }
77
+ }
78
+
79
+ function printFileDiff(relPath, since, diffOutput, opts) {
80
+ process.stdout.write(bold(relPath) + dim(` (updated: ${since})`) + '\n');
81
+
82
+ if (opts.summarize) {
83
+ const summary = summarizeWithMLX(diffOutput, relPath, opts.model);
84
+ if (summary) {
85
+ process.stdout.write(dim(` Summary: ${summary}`) + '\n');
86
+ } else {
87
+ warn(' Summary unavailable (model call failed)');
88
+ }
89
+ }
90
+
91
+ process.stdout.write(diffOutput);
92
+ process.stdout.write('\n');
93
+ }
94
+
95
+ function summarizeWithMLX(diffText, filePath, model) {
96
+ const prompt = `Summarize this git diff in 1-2 sentences. Focus on what changed semantically, not line counts.\n\nFile: ${filePath}\n\n${diffText.slice(0, 4000)}`;
97
+
98
+ const result = spawnSync('uv', [
99
+ 'run', '--with', 'mlx-lm',
100
+ 'python3', '-m', 'mlx_lm', 'generate',
101
+ '--model', model,
102
+ '--prompt', prompt,
103
+ '--max-tokens', '150',
104
+ '--verbose', 'false',
105
+ ], { encoding: 'utf8', timeout: 120000 });
106
+
107
+ if (result.status !== 0) {
108
+ return null;
109
+ }
110
+
111
+ const output = result.stdout.trim();
112
+ const lines = output.split('\n').filter(l => !l.includes('Fetching') && !l.includes('Warning:') && !l.includes('=========='));
113
+ return lines.join(' ').trim() || null;
114
+ }
package/src/git.mjs CHANGED
@@ -16,3 +16,20 @@ export function gitMv(source, target, repoRoot) {
16
16
  });
17
17
  return { status: result.status, stderr: result.stderr };
18
18
  }
19
+
20
+ export function gitDiffSince(relPath, sinceDate, repoRoot, opts = {}) {
21
+ // Find the last commit at or before sinceDate
22
+ const baseline = spawnSync('git', [
23
+ 'log', '-1', '--before=' + sinceDate + 'T23:59:59', '--format=%H', '--', relPath
24
+ ], { cwd: repoRoot, encoding: 'utf8' });
25
+
26
+ const baseRef = baseline.stdout.trim();
27
+ if (!baseRef) return null;
28
+
29
+ const diffArgs = ['diff', baseRef, 'HEAD'];
30
+ if (opts.stat) diffArgs.push('--stat');
31
+ diffArgs.push('--', relPath);
32
+
33
+ const result = spawnSync('git', diffArgs, { cwd: repoRoot, encoding: 'utf8' });
34
+ return result.stdout || null;
35
+ }
package/src/lifecycle.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, die } from './util.mjs';
4
+ import { asString, toRepoPath, die, resolveDocPath } from './util.mjs';
5
5
  import { gitMv } from './git.mjs';
6
6
  import { buildIndex, collectDocFiles } from './index.mjs';
7
7
  import { renderIndexFile, writeIndex } from './index-file.mjs';
@@ -188,19 +188,6 @@ export function runTouch(argv, config, opts = {}) {
188
188
  config.hooks.onTouch?.({ path: toRepoPath(filePath, config.repoRoot) }, { path: toRepoPath(filePath, config.repoRoot), date: today });
189
189
  }
190
190
 
191
- function resolveDocPath(input, config) {
192
- if (!input) return null;
193
- if (path.isAbsolute(input)) return existsSync(input) ? input : null;
194
-
195
- let candidate = path.resolve(config.repoRoot, input);
196
- if (existsSync(candidate)) return candidate;
197
-
198
- candidate = path.resolve(config.docsRoot, input);
199
- if (existsSync(candidate)) return candidate;
200
-
201
- return null;
202
- }
203
-
204
191
  export function updateFrontmatter(filePath, updates) {
205
192
  const raw = readFileSync(filePath, 'utf8');
206
193
  if (!raw.startsWith('---\n')) throw new Error(`${filePath} has no frontmatter block.`);
package/src/util.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { existsSync } from 'node:fs';
1
2
  import path from 'node:path';
2
3
  import { dim } from './color.mjs';
3
4
 
@@ -58,3 +59,16 @@ export function die(message) {
58
59
  process.stderr.write(`${message}\n`);
59
60
  process.exitCode = 1;
60
61
  }
62
+
63
+ export function resolveDocPath(input, config) {
64
+ if (!input) return null;
65
+ if (path.isAbsolute(input)) return existsSync(input) ? input : null;
66
+
67
+ let candidate = path.resolve(config.repoRoot, input);
68
+ if (existsSync(candidate)) return candidate;
69
+
70
+ candidate = path.resolve(config.docsRoot, input);
71
+ if (existsSync(candidate)) return candidate;
72
+
73
+ return null;
74
+ }
package/src/watch.mjs ADDED
@@ -0,0 +1,48 @@
1
+ import { watch } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { dim } from './color.mjs';
5
+
6
+ export function runWatch(argv, config) {
7
+ const subCommand = argv.length > 0 ? argv : ['list'];
8
+ const cliPath = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'bin', 'dotmd.mjs');
9
+
10
+ let lastRun = 0;
11
+ const DEBOUNCE = 300;
12
+
13
+ function run() {
14
+ const now = Date.now();
15
+ if (now - lastRun < DEBOUNCE) return;
16
+ lastRun = now;
17
+
18
+ // Clear terminal
19
+ process.stdout.write('\x1b[2J\x1b[H');
20
+ process.stderr.write(dim(`[${new Date().toLocaleTimeString()}] dotmd ${subCommand.join(' ')}`) + '\n\n');
21
+
22
+ spawnSync(process.execPath, [cliPath, ...subCommand], {
23
+ stdio: 'inherit',
24
+ cwd: process.cwd(),
25
+ });
26
+ }
27
+
28
+ // Run once immediately
29
+ run();
30
+
31
+ process.stderr.write(dim(`\nWatching ${config.docsRoot} for changes... (Ctrl+C to stop)`) + '\n');
32
+
33
+ // Watch for changes
34
+ const watcher = watch(config.docsRoot, { recursive: true }, (eventType, filename) => {
35
+ if (filename && filename.endsWith('.md')) {
36
+ run();
37
+ }
38
+ });
39
+
40
+ // Clean exit
41
+ process.on('SIGINT', () => {
42
+ watcher.close();
43
+ process.exit(0);
44
+ });
45
+
46
+ // Keep alive
47
+ setInterval(() => {}, 1 << 30);
48
+ }