dotmd-cli 0.3.0 → 0.4.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
@@ -83,6 +83,9 @@ dotmd index [--write] Generate/update docs.md index block
83
83
  dotmd status <file> <status> Transition document status
84
84
  dotmd archive <file> Archive (status + move + index regen)
85
85
  dotmd touch <file> Bump updated date
86
+ dotmd lint [--fix] Check and auto-fix frontmatter issues
87
+ dotmd rename <old> <new> Rename doc and update references
88
+ dotmd migrate <f> <old> <new> Batch update a frontmatter field
86
89
  dotmd watch [command] Re-run a command on file changes
87
90
  dotmd diff [file] Show changes since last updated date
88
91
  dotmd new <name> Create a new document with frontmatter
@@ -133,6 +136,44 @@ export const presets = {
133
136
 
134
137
  Then run `dotmd stale` or `dotmd mine` as shorthand.
135
138
 
139
+ ### Lint
140
+
141
+ Check docs for fixable frontmatter issues and optionally auto-fix them:
142
+
143
+ ```bash
144
+ dotmd lint # report issues
145
+ dotmd lint --fix # fix all issues
146
+ dotmd lint --fix --dry-run # preview fixes without writing
147
+ ```
148
+
149
+ Detected issues:
150
+ - Missing `updated` date on non-archived docs
151
+ - Status casing mismatch (e.g., `Active` → `active`)
152
+ - camelCase frontmatter keys (e.g., `nextStep` → `next_step`)
153
+ - Trailing whitespace in frontmatter values
154
+ - Missing newline at end of file
155
+
156
+ ### Rename
157
+
158
+ Rename a document and update all frontmatter references across your docs:
159
+
160
+ ```bash
161
+ dotmd rename old-name.md new-name # renames + updates refs
162
+ dotmd rename old-name.md new-name -n # preview without writing
163
+ ```
164
+
165
+ Uses `git mv` for the rename and scans all reference fields for the old filename. Body markdown links are warned about but not auto-fixed.
166
+
167
+ ### Migrate
168
+
169
+ Batch update a frontmatter field value across all docs:
170
+
171
+ ```bash
172
+ dotmd migrate status research exploration # rename a status
173
+ dotmd migrate module auth identity # rename a module
174
+ dotmd migrate module auth identity -n # preview
175
+ ```
176
+
136
177
  ### Watch Mode
137
178
 
138
179
  ```bash
@@ -231,7 +272,18 @@ export function onStatusChange(doc, { oldStatus, newStatus }) {
231
272
  }
232
273
  ```
233
274
 
234
- Available: `onArchive`, `onStatusChange`, `onTouch`.
275
+ Available: `onArchive`, `onStatusChange`, `onTouch`, `onNew`, `onRename`, `onLint`.
276
+
277
+ ### Summarize Hook
278
+
279
+ Override the diff summarizer (replaces the default MLX model call):
280
+
281
+ ```js
282
+ export function summarizeDiff(diffOutput, filePath) {
283
+ // call your preferred LLM, return a string summary
284
+ return `Changes in ${filePath}: ...`;
285
+ }
286
+ ```
235
287
 
236
288
  ## Features
237
289
 
package/bin/dotmd.mjs CHANGED
@@ -14,6 +14,9 @@ import { runNew } from '../src/new.mjs';
14
14
  import { runCompletions } from '../src/completions.mjs';
15
15
  import { runWatch } from '../src/watch.mjs';
16
16
  import { runDiff } from '../src/diff.mjs';
17
+ import { runLint } from '../src/lint.mjs';
18
+ import { runRename } from '../src/rename.mjs';
19
+ import { runMigrate } from '../src/migrate.mjs';
17
20
  import { die, warn } from '../src/util.mjs';
18
21
 
19
22
  const __filename = fileURLToPath(import.meta.url);
@@ -35,6 +38,9 @@ Commands:
35
38
  status <file> <status> Transition document status
36
39
  archive <file> Archive (status + move + index regen)
37
40
  touch <file> Bump updated date
41
+ lint [--fix] Check and auto-fix frontmatter issues
42
+ rename <old> <new> Rename doc and update references
43
+ migrate <f> <old> <new> Batch update a frontmatter field
38
44
  watch [command] Re-run a command on file changes
39
45
  diff [file] Show changes since last updated date
40
46
  new <name> Create a new document with frontmatter
@@ -122,6 +128,33 @@ Options:
122
128
  --summarize Generate AI summary using local MLX model
123
129
  --model <name> MLX model to use (default: mlx-community/Llama-3.2-3B-Instruct-4bit)`,
124
130
 
131
+ lint: `dotmd lint [--fix] — check and auto-fix frontmatter issues
132
+
133
+ Scans all docs for fixable problems: missing updated dates, status casing,
134
+ camelCase key names, trailing whitespace in values, missing EOF newline.
135
+
136
+ Without --fix, reports all issues. With --fix, applies fixes in place.
137
+ Use --dry-run (-n) with --fix to preview without writing anything.`,
138
+
139
+ rename: `dotmd rename <old> <new> — rename doc and update references
140
+
141
+ Renames a document using git mv and updates all frontmatter references
142
+ in other docs that point to the old filename.
143
+
144
+ Body markdown links are warned about but not auto-fixed.
145
+ Use --dry-run (-n) to preview changes without writing anything.`,
146
+
147
+ migrate: `dotmd migrate <field> <old-value> <new-value> — batch update a frontmatter field
148
+
149
+ Finds all docs where the given field equals old-value and updates it
150
+ to new-value.
151
+
152
+ Examples:
153
+ dotmd migrate status research exploration
154
+ dotmd migrate module auth identity
155
+
156
+ Use --dry-run (-n) to preview changes without writing anything.`,
157
+
125
158
  init: `dotmd init — create starter config and docs directory
126
159
 
127
160
  Creates dotmd.config.mjs, docs/, and docs/docs.md in the current
@@ -179,6 +212,12 @@ async function main() {
179
212
  warn('No dotmd config found — using defaults. Run `dotmd init` to create one.');
180
213
  }
181
214
 
215
+ if (config.configWarnings && config.configWarnings.length > 0) {
216
+ for (const w of config.configWarnings) {
217
+ warn(w);
218
+ }
219
+ }
220
+
182
221
  if (verbose) {
183
222
  process.stderr.write(`Config: ${config.configPath ?? 'none'}\n`);
184
223
  process.stderr.write(`Docs root: ${config.docsRoot}\n`);
@@ -201,6 +240,9 @@ async function main() {
201
240
  if (command === 'archive') { runArchive(restArgs, config, { dryRun }); return; }
202
241
  if (command === 'touch') { runTouch(restArgs, config, { dryRun }); return; }
203
242
  if (command === 'new') { runNew(restArgs, config, { dryRun }); return; }
243
+ if (command === 'lint') { runLint(restArgs, config, { dryRun }); return; }
244
+ if (command === 'rename') { runRename(restArgs, config, { dryRun }); return; }
245
+ if (command === 'migrate') { runMigrate(restArgs, config, { dryRun }); return; }
204
246
 
205
247
  const index = buildIndex(config);
206
248
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.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,8 @@ 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', 'watch', 'diff', 'init', 'new', 'completions',
5
+ 'index', 'status', 'archive', 'touch', 'lint', 'rename', 'migrate',
6
+ 'watch', 'diff', 'init', 'new', 'completions',
6
7
  ];
7
8
 
8
9
  const GLOBAL_FLAGS = ['--config', '--dry-run', '--verbose', '--help', '--version'];
@@ -16,6 +17,9 @@ const COMMAND_FLAGS = {
16
17
  coverage: ['--json'],
17
18
  new: ['--status', '--title'],
18
19
  diff: ['--stat', '--since', '--summarize', '--model'],
20
+ lint: ['--fix'],
21
+ rename: [],
22
+ migrate: [],
19
23
  };
20
24
 
21
25
  function bashCompletion() {
package/src/config.mjs CHANGED
@@ -76,6 +76,50 @@ function findConfigFile(startDir) {
76
76
  return null;
77
77
  }
78
78
 
79
+ const VALID_CONFIG_KEYS = new Set(Object.keys(DEFAULTS));
80
+
81
+ function validateConfig(userConfig, config, validStatuses, indexPath) {
82
+ const warnings = [];
83
+
84
+ // statuses.order must be array
85
+ if (config.statuses && config.statuses.order !== undefined && !Array.isArray(config.statuses.order)) {
86
+ warnings.push('Config: statuses.order must be an array.');
87
+ }
88
+
89
+ // archiveDir must be string
90
+ if (config.archiveDir !== undefined && typeof config.archiveDir !== 'string') {
91
+ warnings.push('Config: archiveDir must be a string.');
92
+ }
93
+
94
+ // lifecycle.archiveStatuses values must exist in validStatuses
95
+ if (config.lifecycle?.archiveStatuses) {
96
+ for (const s of config.lifecycle.archiveStatuses) {
97
+ if (!validStatuses.has(s)) {
98
+ warnings.push(`Config: lifecycle.archiveStatuses contains unknown status '${s}'.`);
99
+ }
100
+ }
101
+ }
102
+
103
+ // taxonomy.surfaces must be null or array
104
+ if (config.taxonomy?.surfaces !== undefined && config.taxonomy.surfaces !== null && !Array.isArray(config.taxonomy.surfaces)) {
105
+ warnings.push('Config: taxonomy.surfaces must be null or an array.');
106
+ }
107
+
108
+ // index path file exists (if configured)
109
+ if (indexPath && !existsSync(indexPath)) {
110
+ warnings.push(`Config: index path does not exist: ${indexPath}`);
111
+ }
112
+
113
+ // Unknown top-level user config keys
114
+ for (const key of Object.keys(userConfig)) {
115
+ if (!VALID_CONFIG_KEYS.has(key)) {
116
+ warnings.push(`Config: unknown key '${key}'.`);
117
+ }
118
+ }
119
+
120
+ return warnings;
121
+ }
122
+
79
123
  function deepMerge(defaults, overrides) {
80
124
  const result = { ...defaults };
81
125
  for (const [key, value] of Object.entries(overrides)) {
@@ -119,6 +163,7 @@ export async function resolveConfig(cwd, explicitConfigPath) {
119
163
  indexPath: null, indexStartMarker: '<!-- GENERATED:dotmd:start -->', indexEndMarker: '<!-- GENERATED:dotmd:end -->', archivedHighlightLimit: 8,
120
164
  context: defaults.context, display: defaults.display,
121
165
  referenceFields: defaults.referenceFields, presets: defaults.presets, hooks: {},
166
+ configWarnings: [],
122
167
  };
123
168
  }
124
169
 
@@ -188,6 +233,8 @@ export async function resolveConfig(cwd, explicitConfigPath) {
188
233
  const skipStaleFor = new Set(lifecycle.skipStaleFor);
189
234
  const skipWarningsFor = new Set(lifecycle.skipWarningsFor);
190
235
 
236
+ const configWarnings = validateConfig(userConfig, config, validStatuses, indexPath);
237
+
191
238
  return {
192
239
  raw: config,
193
240
 
@@ -219,5 +266,6 @@ export async function resolveConfig(cwd, explicitConfigPath) {
219
266
  referenceFields: config.referenceFields,
220
267
  presets: config.presets,
221
268
  hooks,
269
+ configWarnings,
222
270
  };
223
271
  }
package/src/diff.mjs CHANGED
@@ -80,7 +80,9 @@ function printFileDiff(relPath, since, diffOutput, opts) {
80
80
  process.stdout.write(bold(relPath) + dim(` (updated: ${since})`) + '\n');
81
81
 
82
82
  if (opts.summarize) {
83
- const summary = summarizeWithMLX(diffOutput, relPath, opts.model);
83
+ const summary = opts.config?.hooks?.summarizeDiff
84
+ ? opts.config.hooks.summarizeDiff(diffOutput, relPath)
85
+ : summarizeWithMLX(diffOutput, relPath, opts.model);
84
86
  if (summary) {
85
87
  process.stdout.write(dim(` Summary: ${summary}`) + '\n');
86
88
  } else {
package/src/lint.mjs ADDED
@@ -0,0 +1,186 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
+ import { asString, toRepoPath } from './util.mjs';
4
+ import { buildIndex, collectDocFiles } from './index.mjs';
5
+ import { updateFrontmatter } from './lifecycle.mjs';
6
+ import { bold, green, yellow, dim } from './color.mjs';
7
+
8
+ const KEY_RENAMES = {
9
+ nextStep: 'next_step',
10
+ currentState: 'current_state',
11
+ auditLevel: 'audit_level',
12
+ sourceOfTruth: 'source_of_truth',
13
+ relatedPlans: 'related_plans',
14
+ supportsPlans: 'supports_plans',
15
+ };
16
+
17
+ export function runLint(argv, config, opts = {}) {
18
+ const { dryRun } = opts;
19
+ const fix = argv.includes('--fix');
20
+ const allFiles = collectDocFiles(config);
21
+ const fixable = [];
22
+
23
+ for (const filePath of allFiles) {
24
+ const raw = readFileSync(filePath, 'utf8');
25
+ const { frontmatter } = extractFrontmatter(raw);
26
+ if (!frontmatter) continue;
27
+ const parsed = parseSimpleFrontmatter(frontmatter);
28
+ const repoPath = toRepoPath(filePath, config.repoRoot);
29
+ const fixes = [];
30
+
31
+ // Missing updated
32
+ if (!asString(parsed.updated) && asString(parsed.status) && !config.lifecycle.skipWarningsFor.has(asString(parsed.status))) {
33
+ const today = new Date().toISOString().slice(0, 10);
34
+ fixes.push({ field: 'updated', oldValue: null, newValue: today, type: 'add' });
35
+ }
36
+
37
+ // Status casing
38
+ const status = asString(parsed.status);
39
+ if (status && status !== status.toLowerCase() && config.validStatuses.has(status.toLowerCase())) {
40
+ fixes.push({ field: 'status', oldValue: status, newValue: status.toLowerCase(), type: 'update' });
41
+ }
42
+
43
+ // Key renames
44
+ for (const [oldKey, newKey] of Object.entries(KEY_RENAMES)) {
45
+ if (parsed[oldKey] !== undefined && parsed[newKey] === undefined) {
46
+ fixes.push({ field: oldKey, oldValue: oldKey, newValue: newKey, type: 'rename-key' });
47
+ }
48
+ }
49
+
50
+ // Trailing whitespace in values
51
+ for (const line of frontmatter.split('\n')) {
52
+ const m = line.match(/^([A-Za-z0-9_-]+):(.+\S)\s+$/);
53
+ if (m) {
54
+ fixes.push({ field: m[1], oldValue: m[2] + line.slice(line.indexOf(m[2]) + m[2].length), newValue: m[2], type: 'trim' });
55
+ }
56
+ }
57
+
58
+ // Missing newline at EOF
59
+ if (!raw.endsWith('\n')) {
60
+ fixes.push({ field: '(eof)', oldValue: 'missing', newValue: 'newline', type: 'eof' });
61
+ }
62
+
63
+ if (fixes.length > 0) {
64
+ fixable.push({ filePath, repoPath, fixes });
65
+ }
66
+ }
67
+
68
+ // Also get non-fixable issues from index
69
+ const index = buildIndex(config);
70
+ const nonFixable = [...index.errors, ...index.warnings];
71
+
72
+ if (!fix) {
73
+ // Report mode
74
+ if (fixable.length > 0) {
75
+ process.stdout.write(bold(`${fixable.length} file(s) with fixable issues:\n\n`));
76
+ for (const { repoPath, fixes } of fixable) {
77
+ process.stdout.write(` ${repoPath}\n`);
78
+ for (const f of fixes) {
79
+ if (f.type === 'rename-key') {
80
+ process.stdout.write(dim(` ${f.oldValue} → ${f.newValue}\n`));
81
+ } else if (f.type === 'eof') {
82
+ process.stdout.write(dim(` missing newline at end of file\n`));
83
+ } else if (f.type === 'add') {
84
+ process.stdout.write(dim(` add ${f.field}: ${f.newValue}\n`));
85
+ } else {
86
+ process.stdout.write(dim(` ${f.field}: ${f.oldValue} → ${f.newValue}\n`));
87
+ }
88
+ }
89
+ }
90
+ process.stdout.write(`\nRun ${bold('dotmd lint --fix')} to auto-fix.\n`);
91
+ }
92
+
93
+ if (nonFixable.length > 0) {
94
+ process.stdout.write(`\n${yellow(`${nonFixable.length} non-fixable issue(s)`)} (manual attention needed):\n`);
95
+ for (const issue of nonFixable) {
96
+ process.stdout.write(` ${issue.path}: ${issue.message}\n`);
97
+ }
98
+ }
99
+
100
+ if (fixable.length === 0 && nonFixable.length === 0) {
101
+ process.stdout.write(green('No issues found.') + '\n');
102
+ }
103
+ return;
104
+ }
105
+
106
+ // Fix mode
107
+ const prefix = dryRun ? dim('[dry-run] ') : '';
108
+ let totalFixes = 0;
109
+
110
+ for (const { filePath, repoPath, fixes } of fixable) {
111
+ const updates = {};
112
+ const keyRenames = [];
113
+ let needsEofFix = false;
114
+ const trimFixes = [];
115
+
116
+ for (const f of fixes) {
117
+ if (f.type === 'rename-key') {
118
+ keyRenames.push(f);
119
+ } else if (f.type === 'eof') {
120
+ needsEofFix = true;
121
+ } else if (f.type === 'trim') {
122
+ trimFixes.push(f);
123
+ } else {
124
+ updates[f.field] = f.newValue;
125
+ }
126
+ }
127
+
128
+ if (!dryRun) {
129
+ // Apply key renames and trim fixes via raw string manipulation
130
+ if (keyRenames.length > 0 || trimFixes.length > 0) {
131
+ let raw = readFileSync(filePath, 'utf8');
132
+ const { frontmatter: fm } = extractFrontmatter(raw);
133
+ let newFm = fm;
134
+ for (const kr of keyRenames) {
135
+ const regex = new RegExp(`^${kr.oldValue}:`, 'm');
136
+ newFm = newFm.replace(regex, `${kr.newValue}:`);
137
+ }
138
+ for (const tf of trimFixes) {
139
+ const regex = new RegExp(`^(${escapeRegex(tf.field)}:)${escapeRegex(tf.oldValue)}$`, 'm');
140
+ newFm = newFm.replace(regex, `$1${tf.newValue}`);
141
+ }
142
+ if (newFm !== fm) {
143
+ raw = raw.replace(`---\n${fm}\n---`, `---\n${newFm}\n---`);
144
+ writeFileSync(filePath, raw, 'utf8');
145
+ }
146
+ }
147
+
148
+ // Apply value updates via updateFrontmatter
149
+ if (Object.keys(updates).length > 0) {
150
+ updateFrontmatter(filePath, updates);
151
+ }
152
+
153
+ // EOF fix
154
+ if (needsEofFix) {
155
+ const current = readFileSync(filePath, 'utf8');
156
+ if (!current.endsWith('\n')) {
157
+ writeFileSync(filePath, current + '\n', 'utf8');
158
+ }
159
+ }
160
+ }
161
+
162
+ process.stdout.write(`${prefix}${green('Fixed')}: ${repoPath} (${fixes.length} issue${fixes.length > 1 ? 's' : ''})\n`);
163
+ for (const f of fixes) {
164
+ if (f.type === 'rename-key') {
165
+ process.stdout.write(`${prefix} ${dim(`${f.oldValue} → ${f.newValue}`)}\n`);
166
+ } else if (f.type === 'eof') {
167
+ process.stdout.write(`${prefix} ${dim('added newline at EOF')}\n`);
168
+ } else if (f.type === 'add') {
169
+ process.stdout.write(`${prefix} ${dim(`add ${f.field}: ${f.newValue}`)}\n`);
170
+ } else {
171
+ process.stdout.write(`${prefix} ${dim(`${f.field}: ${f.oldValue} → ${f.newValue}`)}\n`);
172
+ }
173
+ }
174
+ totalFixes += fixes.length;
175
+
176
+ if (!dryRun) {
177
+ config.hooks.onLint?.({ path: repoPath, fixes });
178
+ }
179
+ }
180
+
181
+ process.stdout.write(`\n${prefix}${totalFixes} fix${totalFixes !== 1 ? 'es' : ''} applied across ${fixable.length} file(s).\n`);
182
+ }
183
+
184
+ function escapeRegex(str) {
185
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
186
+ }
@@ -0,0 +1,56 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
+ import { asString, toRepoPath, die } from './util.mjs';
4
+ import { collectDocFiles } from './index.mjs';
5
+ import { updateFrontmatter } from './lifecycle.mjs';
6
+ import { bold, green, dim } from './color.mjs';
7
+
8
+ export function runMigrate(argv, config, opts = {}) {
9
+ const { dryRun } = opts;
10
+
11
+ // Parse positional args (skip flags)
12
+ const positional = [];
13
+ for (let i = 0; i < argv.length; i++) {
14
+ if (argv[i].startsWith('-')) continue;
15
+ positional.push(argv[i]);
16
+ }
17
+
18
+ const field = positional[0];
19
+ const oldValue = positional[1];
20
+ const newValue = positional[2];
21
+
22
+ if (!field || !oldValue || !newValue) {
23
+ die('Usage: dotmd migrate <field> <old-value> <new-value>');
24
+ return;
25
+ }
26
+
27
+ const allFiles = collectDocFiles(config);
28
+ const matches = [];
29
+
30
+ for (const filePath of allFiles) {
31
+ const raw = readFileSync(filePath, 'utf8');
32
+ const { frontmatter } = extractFrontmatter(raw);
33
+ if (!frontmatter) continue;
34
+ const parsed = parseSimpleFrontmatter(frontmatter);
35
+ const current = asString(parsed[field]);
36
+ if (current === oldValue) {
37
+ matches.push({ filePath, repoPath: toRepoPath(filePath, config.repoRoot) });
38
+ }
39
+ }
40
+
41
+ if (matches.length === 0) {
42
+ process.stdout.write(`No docs found with ${bold(field)}: ${oldValue}\n`);
43
+ return;
44
+ }
45
+
46
+ const prefix = dryRun ? dim('[dry-run] ') : '';
47
+
48
+ for (const { filePath, repoPath } of matches) {
49
+ if (!dryRun) {
50
+ updateFrontmatter(filePath, { [field]: newValue });
51
+ }
52
+ process.stdout.write(`${prefix}${green('Updated')}: ${repoPath} (${field}: ${oldValue} → ${newValue})\n`);
53
+ }
54
+
55
+ process.stdout.write(`\n${prefix}${matches.length} file(s) ${dryRun ? 'would be ' : ''}updated.\n`);
56
+ }
package/src/new.mjs CHANGED
@@ -51,4 +51,6 @@ export function runNew(argv, config, opts = {}) {
51
51
 
52
52
  writeFileSync(filePath, content, 'utf8');
53
53
  process.stdout.write(`${green('Created')}: ${repoPath}\n`);
54
+
55
+ config.hooks.onNew?.({ path: repoPath, status, title: docTitle });
54
56
  }
package/src/rename.mjs ADDED
@@ -0,0 +1,149 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
+ import { toRepoPath, resolveDocPath, die, warn } from './util.mjs';
5
+ import { collectDocFiles } from './index.mjs';
6
+ import { gitMv } from './git.mjs';
7
+ import { green, dim, yellow } from './color.mjs';
8
+
9
+ export function runRename(argv, config, opts = {}) {
10
+ const { dryRun } = opts;
11
+
12
+ // Parse positional args (skip flags)
13
+ const positional = [];
14
+ for (let i = 0; i < argv.length; i++) {
15
+ if (argv[i].startsWith('-')) continue;
16
+ positional.push(argv[i]);
17
+ }
18
+
19
+ const oldInput = positional[0];
20
+ const newInput = positional[1];
21
+
22
+ if (!oldInput || !newInput) {
23
+ die('Usage: dotmd rename <old> <new>');
24
+ return;
25
+ }
26
+
27
+ // Resolve old path
28
+ const oldPath = resolveDocPath(oldInput, config);
29
+ if (!oldPath) {
30
+ die(`File not found: ${oldInput}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
31
+ return;
32
+ }
33
+
34
+ // Compute new path in same directory as old
35
+ const oldDir = path.dirname(oldPath);
36
+ let newBasename = newInput;
37
+ // If newInput contains a path separator, use just the basename
38
+ if (newInput.includes('/') || newInput.includes(path.sep)) {
39
+ newBasename = path.basename(newInput);
40
+ }
41
+ // Add .md if not present
42
+ if (!newBasename.endsWith('.md')) {
43
+ newBasename += '.md';
44
+ }
45
+ const newPath = path.join(oldDir, newBasename);
46
+
47
+ if (existsSync(newPath)) {
48
+ die(`Target already exists: ${toRepoPath(newPath, config.repoRoot)}`);
49
+ return;
50
+ }
51
+
52
+ const oldRepoPath = toRepoPath(oldPath, config.repoRoot);
53
+ const newRepoPath = toRepoPath(newPath, config.repoRoot);
54
+ const oldBasename = path.basename(oldPath);
55
+
56
+ // Scan for references in other docs
57
+ const allFiles = collectDocFiles(config);
58
+ const allRefFields = [
59
+ ...(config.referenceFields.bidirectional || []),
60
+ ...(config.referenceFields.unidirectional || []),
61
+ ];
62
+ const refUpdates = [];
63
+ const bodyWarnings = [];
64
+
65
+ for (const filePath of allFiles) {
66
+ if (filePath === oldPath) continue;
67
+ const raw = readFileSync(filePath, 'utf8');
68
+ const { frontmatter, body } = extractFrontmatter(raw);
69
+ if (!frontmatter) continue;
70
+
71
+ // Check frontmatter reference fields for old basename
72
+ let hasRef = false;
73
+ for (const line of frontmatter.split('\n')) {
74
+ if (line.includes(oldBasename)) {
75
+ hasRef = true;
76
+ break;
77
+ }
78
+ }
79
+
80
+ if (hasRef) {
81
+ refUpdates.push(filePath);
82
+ }
83
+
84
+ // Check body for markdown links containing old basename
85
+ if (body && body.includes(oldBasename)) {
86
+ bodyWarnings.push(toRepoPath(filePath, config.repoRoot));
87
+ }
88
+ }
89
+
90
+ if (dryRun) {
91
+ const prefix = dim('[dry-run]');
92
+ process.stdout.write(`${prefix} Would rename: ${oldRepoPath} → ${newRepoPath}\n`);
93
+ if (refUpdates.length > 0) {
94
+ process.stdout.write(`${prefix} Would update references in ${refUpdates.length} file(s):\n`);
95
+ for (const f of refUpdates) {
96
+ process.stdout.write(`${prefix} ${toRepoPath(f, config.repoRoot)}\n`);
97
+ }
98
+ }
99
+ if (bodyWarnings.length > 0) {
100
+ process.stdout.write(`\n${yellow('Body links referencing old name')} (manual update needed):\n`);
101
+ for (const p of bodyWarnings) {
102
+ process.stdout.write(` ${p}\n`);
103
+ }
104
+ }
105
+ return;
106
+ }
107
+
108
+ // Perform git mv
109
+ const result = gitMv(oldPath, newPath, config.repoRoot);
110
+ if (result.status !== 0) {
111
+ die(result.stderr || 'git mv failed.');
112
+ return;
113
+ }
114
+
115
+ // Update references in frontmatter of other docs
116
+ let updatedCount = 0;
117
+ for (const filePath of refUpdates) {
118
+ let raw = readFileSync(filePath, 'utf8');
119
+ const { frontmatter: fm } = extractFrontmatter(raw);
120
+ if (!fm) continue;
121
+
122
+ const newFm = fm.split('\n').map(line => {
123
+ if (line.includes(oldBasename)) {
124
+ return line.split(oldBasename).join(newBasename);
125
+ }
126
+ return line;
127
+ }).join('\n');
128
+
129
+ if (newFm !== fm) {
130
+ raw = raw.replace(`---\n${fm}\n---`, `---\n${newFm}\n---`);
131
+ writeFileSync(filePath, raw, 'utf8');
132
+ updatedCount++;
133
+ }
134
+ }
135
+
136
+ process.stdout.write(`${green('Renamed')}: ${oldRepoPath} → ${newRepoPath}\n`);
137
+ if (updatedCount > 0) {
138
+ process.stdout.write(`Updated references in ${updatedCount} file(s).\n`);
139
+ }
140
+
141
+ if (bodyWarnings.length > 0) {
142
+ process.stdout.write(`\n${yellow('Body links referencing old name')} (manual update needed):\n`);
143
+ for (const p of bodyWarnings) {
144
+ process.stdout.write(` ${p}\n`);
145
+ }
146
+ }
147
+
148
+ config.hooks.onRename?.({ oldPath: oldRepoPath, newPath: newRepoPath, referencesUpdated: updatedCount });
149
+ }