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 +71 -9
- package/bin/dotmd.mjs +68 -2
- package/package.json +4 -1
- package/src/completions.mjs +98 -0
- package/src/config.mjs +27 -1
- package/src/diff.mjs +114 -0
- package/src/git.mjs +17 -0
- package/src/lifecycle.mjs +4 -17
- package/src/new.mjs +54 -0
- package/src/util.mjs +19 -0
- package/src/watch.mjs +48 -0
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
|
|
17
|
-
dotmd
|
|
18
|
-
dotmd
|
|
19
|
-
dotmd
|
|
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
|
-
- **
|
|
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
|
|
119
|
-
path: 'docs/
|
|
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 {
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|