dotmd-cli 0.1.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
@@ -10,7 +10,11 @@ import { renderIndexFile, writeIndex } from '../src/index-file.mjs';
10
10
  import { runFocus, runQuery } from '../src/query.mjs';
11
11
  import { runStatus, runArchive, runTouch } from '../src/lifecycle.mjs';
12
12
  import { runInit } from '../src/init.mjs';
13
- import { die } from '../src/util.mjs';
13
+ import { runNew } from '../src/new.mjs';
14
+ import { runCompletions } from '../src/completions.mjs';
15
+ import { runWatch } from '../src/watch.mjs';
16
+ import { runDiff } from '../src/diff.mjs';
17
+ import { die, warn } from '../src/util.mjs';
14
18
 
15
19
  const __filename = fileURLToPath(import.meta.url);
16
20
  const __dirname = path.dirname(__filename);
@@ -31,11 +35,16 @@ Commands:
31
35
  status <file> <status> Transition document status
32
36
  archive <file> Archive (status + move + index regen)
33
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
40
+ new <name> Create a new document with frontmatter
34
41
  init Create starter config + docs directory
42
+ completions <shell> Output shell completion script (bash, zsh)
35
43
 
36
44
  Options:
37
45
  --config <path> Explicit config file path
38
46
  --dry-run, -n Preview changes without writing anything
47
+ --verbose Show config details and doc count
39
48
  --help, -h Show help
40
49
  --version, -v Show version`,
41
50
 
@@ -81,6 +90,38 @@ With --write, updates the configured index file in place.
81
90
 
82
91
  Use --dry-run (-n) with --write to preview without writing.`,
83
92
 
93
+ new: `dotmd new <name> — create a new document
94
+
95
+ Creates a new markdown document with frontmatter in the docs root.
96
+
97
+ Options:
98
+ --status <s> Set initial status (default: active)
99
+ --title <t> Override the document title
100
+
101
+ The filename is derived from <name> by slugifying it.
102
+ Use --dry-run (-n) to preview without creating the file.`,
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
+
84
125
  init: `dotmd init — create starter config and docs directory
85
126
 
86
127
  Creates dotmd.config.mjs, docs/, and docs/docs.md in the current
@@ -108,12 +149,17 @@ async function main() {
108
149
  return;
109
150
  }
110
151
 
111
- // Init doesn't need config
152
+ // Init and completions don't need config
112
153
  if (command === 'init') {
113
154
  runInit(process.cwd());
114
155
  return;
115
156
  }
116
157
 
158
+ if (command === 'completions') {
159
+ runCompletions(args.slice(1));
160
+ return;
161
+ }
162
+
117
163
  // Extract --config flag
118
164
  let explicitConfig = null;
119
165
  for (let i = 0; i < args.length; i++) {
@@ -124,10 +170,21 @@ async function main() {
124
170
  }
125
171
 
126
172
  const dryRun = args.includes('--dry-run') || args.includes('-n');
173
+ const verbose = args.includes('--verbose');
127
174
 
128
175
  const config = await resolveConfig(process.cwd(), explicitConfig);
129
176
  const restArgs = args.slice(1);
130
177
 
178
+ if (!config.configFound && command !== 'init') {
179
+ warn('No dotmd config found — using defaults. Run `dotmd init` to create one.');
180
+ }
181
+
182
+ if (verbose) {
183
+ process.stderr.write(`Config: ${config.configPath ?? 'none'}\n`);
184
+ process.stderr.write(`Docs root: ${config.docsRoot}\n`);
185
+ process.stderr.write(`Repo root: ${config.repoRoot}\n`);
186
+ }
187
+
131
188
  // Preset aliases
132
189
  if (config.presets[command]) {
133
190
  const index = buildIndex(config);
@@ -135,13 +192,22 @@ async function main() {
135
192
  return;
136
193
  }
137
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
+
138
199
  // Lifecycle commands
139
200
  if (command === 'status') { runStatus(restArgs, config, { dryRun }); return; }
140
201
  if (command === 'archive') { runArchive(restArgs, config, { dryRun }); return; }
141
202
  if (command === 'touch') { runTouch(restArgs, config, { dryRun }); return; }
203
+ if (command === 'new') { runNew(restArgs, config, { dryRun }); return; }
142
204
 
143
205
  const index = buildIndex(config);
144
206
 
207
+ if (verbose) {
208
+ process.stderr.write(`Docs found: ${index.docs.length}\n`);
209
+ }
210
+
145
211
  if (command === 'json') {
146
212
  process.stdout.write(`${JSON.stringify(index, null, 2)}\n`);
147
213
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.1.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",
@@ -31,6 +31,9 @@
31
31
  "url": "git+https://github.com/beyond-dev/platform.git",
32
32
  "directory": "packages/dotmd"
33
33
  },
34
+ "scripts": {
35
+ "test": "node --test test/*.test.mjs"
36
+ },
34
37
  "engines": {
35
38
  "node": ">=18"
36
39
  }
@@ -0,0 +1,98 @@
1
+ import { die } from './util.mjs';
2
+
3
+ const COMMANDS = [
4
+ 'list', 'json', 'check', 'coverage', 'context', 'focus', 'query',
5
+ 'index', 'status', 'archive', 'touch', 'watch', 'diff', 'init', 'new', 'completions',
6
+ ];
7
+
8
+ const GLOBAL_FLAGS = ['--config', '--dry-run', '--verbose', '--help', '--version'];
9
+
10
+ const COMMAND_FLAGS = {
11
+ query: ['--status', '--keyword', '--module', '--surface', '--domain', '--owner',
12
+ '--updated-since', '--stale', '--has-next-step', '--has-blockers',
13
+ '--checklist-open', '--sort', '--limit', '--all', '--git', '--json'],
14
+ index: ['--write'],
15
+ list: ['--verbose'],
16
+ coverage: ['--json'],
17
+ new: ['--status', '--title'],
18
+ diff: ['--stat', '--since', '--summarize', '--model'],
19
+ };
20
+
21
+ function bashCompletion() {
22
+ return `# dotmd bash completion
23
+ # Add to ~/.bashrc: eval "$(dotmd completions bash)"
24
+ _dotmd() {
25
+ local cur prev cmd
26
+ cur="\${COMP_WORDS[COMP_CWORD]}"
27
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
28
+
29
+ # Find the subcommand
30
+ cmd=""
31
+ for ((i=1; i < COMP_CWORD; i++)); do
32
+ case "\${COMP_WORDS[i]}" in
33
+ -*) ;;
34
+ *) cmd="\${COMP_WORDS[i]}"; break ;;
35
+ esac
36
+ done
37
+
38
+ # Complete commands if no subcommand yet
39
+ if [[ -z "$cmd" ]]; then
40
+ COMPREPLY=( $(compgen -W "${COMMANDS.join(' ')} ${GLOBAL_FLAGS.join(' ')}" -- "$cur") )
41
+ return
42
+ fi
43
+
44
+ # Per-command flag completion
45
+ case "$cmd" in
46
+ ${Object.entries(COMMAND_FLAGS).map(([cmd, flags]) =>
47
+ ` ${cmd}) COMPREPLY=( $(compgen -W "${flags.join(' ')} ${GLOBAL_FLAGS.join(' ')}" -- "$cur") ) ;;`
48
+ ).join('\n')}
49
+ *) COMPREPLY=( $(compgen -W "${GLOBAL_FLAGS.join(' ')}" -- "$cur") ) ;;
50
+ esac
51
+ }
52
+ complete -F _dotmd dotmd`;
53
+ }
54
+
55
+ function zshCompletion() {
56
+ return `# dotmd zsh completion
57
+ # Add to ~/.zshrc: eval "$(dotmd completions zsh)"
58
+ _dotmd() {
59
+ local -a commands global_flags
60
+ commands=(
61
+ ${COMMANDS.map(c => ` '${c}'`).join('\n')}
62
+ )
63
+ global_flags=(
64
+ ${GLOBAL_FLAGS.map(f => ` '${f}'`).join('\n')}
65
+ )
66
+
67
+ if (( CURRENT == 2 )); then
68
+ _describe 'command' commands
69
+ _describe 'flag' global_flags
70
+ return
71
+ fi
72
+
73
+ local cmd=\${words[2]}
74
+ case "$cmd" in
75
+ ${Object.entries(COMMAND_FLAGS).map(([cmd, flags]) =>
76
+ ` ${cmd}) _values 'flags' ${flags.map(f => `'${f}'`).join(' ')} ;;`
77
+ ).join('\n')}
78
+ esac
79
+
80
+ _describe 'flag' global_flags
81
+ }
82
+ compdef _dotmd dotmd`;
83
+ }
84
+
85
+ export function runCompletions(argv) {
86
+ const shell = argv[0];
87
+ if (!shell) {
88
+ die('Usage: dotmd completions <bash|zsh>');
89
+ return;
90
+ }
91
+ if (shell === 'bash') {
92
+ process.stdout.write(bashCompletion() + '\n');
93
+ } else if (shell === 'zsh') {
94
+ process.stdout.write(zshCompletion() + '\n');
95
+ } else {
96
+ die(`Unsupported shell: ${shell}\nSupported: bash, zsh`);
97
+ }
98
+ }
package/src/config.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
+ import { die, warn } from './util.mjs';
4
5
 
5
6
  const CONFIG_FILENAMES = ['dotmd.config.mjs', '.dotmd.config.mjs', 'dotmd.config.js'];
6
7
 
@@ -99,7 +100,27 @@ export async function resolveConfig(cwd, explicitConfigPath) {
99
100
 
100
101
  if (configPath && existsSync(configPath)) {
101
102
  const configUrl = pathToFileURL(configPath).href;
102
- const mod = await import(configUrl);
103
+ let mod;
104
+ try {
105
+ mod = await import(configUrl);
106
+ } catch (err) {
107
+ die('Failed to load config: ' + configPath + '\n' + err.message + '\nRun `dotmd init` to create a starter config.');
108
+ // Return defaults so caller can still function
109
+ const defaults = deepMerge(DEFAULTS, {});
110
+ const defaultStatusOrder = defaults.statuses.order;
111
+ return {
112
+ raw: defaults, docsRoot: cwd, repoRoot: cwd, configDir: cwd,
113
+ configPath: configPath ?? null, configFound: Boolean(configPath),
114
+ archiveDir: defaults.archiveDir, excludeDirs: new Set(defaults.excludeDirs),
115
+ docsRootPrefix: '', statusOrder: defaultStatusOrder,
116
+ validStatuses: new Set(defaultStatusOrder), staleDaysByStatus: {},
117
+ lifecycle: { archiveStatuses: new Set(defaults.lifecycle.archiveStatuses), skipStaleFor: new Set(defaults.lifecycle.skipStaleFor), skipWarningsFor: new Set(defaults.lifecycle.skipWarningsFor) },
118
+ validSurfaces: null, moduleRequiredStatuses: new Set(),
119
+ indexPath: null, indexStartMarker: '<!-- GENERATED:dotmd:start -->', indexEndMarker: '<!-- GENERATED:dotmd:end -->', archivedHighlightLimit: 8,
120
+ context: defaults.context, display: defaults.display,
121
+ referenceFields: defaults.referenceFields, presets: defaults.presets, hooks: {},
122
+ };
123
+ }
103
124
 
104
125
  configDir = path.dirname(configPath);
105
126
 
@@ -123,6 +144,10 @@ export async function resolveConfig(cwd, explicitConfigPath) {
123
144
 
124
145
  const docsRoot = path.resolve(configDir, config.root);
125
146
 
147
+ if (!existsSync(docsRoot)) {
148
+ warn('Docs root does not exist: ' + docsRoot);
149
+ }
150
+
126
151
  // Find repo root by walking up looking for .git
127
152
  let repoRoot = configDir;
128
153
  {
@@ -170,6 +195,7 @@ export async function resolveConfig(cwd, explicitConfigPath) {
170
195
  repoRoot,
171
196
  configDir,
172
197
  configPath: configPath ?? null,
198
+ configFound: Boolean(configPath),
173
199
  archiveDir: config.archiveDir,
174
200
  excludeDirs: new Set(config.excludeDirs),
175
201
  docsRootPrefix,
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';
@@ -16,7 +16,7 @@ export function runStatus(argv, config, opts = {}) {
16
16
  if (!config.validStatuses.has(newStatus)) { die(`Invalid status: ${newStatus}\nValid: ${[...config.validStatuses].join(', ')}`); return; }
17
17
 
18
18
  const filePath = resolveDocPath(input, config);
19
- if (!filePath) { die(`File not found: ${input}`); return; }
19
+ if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); return; }
20
20
 
21
21
  const raw = readFileSync(filePath, 'utf8');
22
22
  const { frontmatter } = extractFrontmatter(raw);
@@ -92,7 +92,7 @@ export function runArchive(argv, config, opts = {}) {
92
92
  if (!input) { die('Usage: dotmd archive <file>'); return; }
93
93
 
94
94
  const filePath = resolveDocPath(input, config);
95
- if (!filePath) { die(`File not found: ${input}`); return; }
95
+ if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); return; }
96
96
  if (filePath.includes(`/${config.archiveDir}/`)) { die(`Already archived: ${toRepoPath(filePath, config.repoRoot)}`); return; }
97
97
 
98
98
  const raw = readFileSync(filePath, 'utf8');
@@ -173,7 +173,7 @@ export function runTouch(argv, config, opts = {}) {
173
173
  if (!input) { die('Usage: dotmd touch <file>'); return; }
174
174
 
175
175
  const filePath = resolveDocPath(input, config);
176
- if (!filePath) { die(`File not found: ${input}`); return; }
176
+ if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); return; }
177
177
 
178
178
  const today = new Date().toISOString().slice(0, 10);
179
179
 
@@ -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/new.mjs ADDED
@@ -0,0 +1,54 @@
1
+ import { existsSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { toRepoPath, die } from './util.mjs';
4
+ import { green, dim } from './color.mjs';
5
+
6
+ export function runNew(argv, config, opts = {}) {
7
+ const { dryRun } = opts;
8
+
9
+ // Parse args
10
+ const positional = [];
11
+ let status = 'active';
12
+ let title = null;
13
+ for (let i = 0; i < argv.length; i++) {
14
+ if (argv[i] === '--status' && argv[i + 1]) { status = argv[++i]; continue; }
15
+ if (argv[i] === '--title' && argv[i + 1]) { title = argv[++i]; continue; }
16
+ if (!argv[i].startsWith('-')) positional.push(argv[i]);
17
+ }
18
+
19
+ const name = positional[0];
20
+ if (!name) { die('Usage: dotmd new <name> [--status <s>] [--title <t>]'); return; }
21
+
22
+ // Validate status
23
+ if (!config.validStatuses.has(status)) {
24
+ die(`Invalid status: ${status}\nValid: ${[...config.validStatuses].join(', ')}`);
25
+ return;
26
+ }
27
+
28
+ // Slugify
29
+ const slug = name.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
30
+ if (!slug) { die('Name resolves to empty slug: ' + name); return; }
31
+
32
+ // Title
33
+ const docTitle = title ?? name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
34
+
35
+ // Path
36
+ const filePath = path.join(config.docsRoot, slug + '.md');
37
+ const repoPath = toRepoPath(filePath, config.repoRoot);
38
+
39
+ if (existsSync(filePath)) {
40
+ die(`File already exists: ${repoPath}`);
41
+ return;
42
+ }
43
+
44
+ if (dryRun) {
45
+ process.stdout.write(`${dim('[dry-run]')} Would create: ${repoPath}\n`);
46
+ return;
47
+ }
48
+
49
+ const today = new Date().toISOString().slice(0, 10);
50
+ const content = `---\nstatus: ${status}\nupdated: ${today}\n---\n\n# ${docTitle}\n`;
51
+
52
+ writeFileSync(filePath, content, 'utf8');
53
+ process.stdout.write(`${green('Created')}: ${repoPath}\n`);
54
+ }
package/src/util.mjs CHANGED
@@ -1,4 +1,6 @@
1
+ import { existsSync } from 'node:fs';
1
2
  import path from 'node:path';
3
+ import { dim } from './color.mjs';
2
4
 
3
5
  export function escapeTable(value) {
4
6
  return String(value).replace(/\|/g, '\\|');
@@ -49,7 +51,24 @@ export function toRepoPath(absolutePath, repoRoot) {
49
51
  return path.relative(repoRoot, absolutePath).split(path.sep).join('/');
50
52
  }
51
53
 
54
+ export function warn(message) {
55
+ process.stderr.write(`${dim(message)}\n`);
56
+ }
57
+
52
58
  export function die(message) {
53
59
  process.stderr.write(`${message}\n`);
54
60
  process.exitCode = 1;
55
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
+ }