dotmd-cli 0.64.1 → 0.64.3
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 +14 -0
- package/bin/dotmd.mjs +1 -0
- package/package.json +1 -1
- package/src/doctor.mjs +2 -1
- package/src/frontmatter.mjs +35 -9
- package/src/guard.mjs +17 -6
- package/src/init.mjs +8 -2
- package/src/lifecycle.mjs +4 -4
- package/src/new.mjs +2 -2
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ Index, query, validate, and lifecycle-manage any collection of `.md` files — p
|
|
|
9
9
|
```bash
|
|
10
10
|
npm install -g dotmd-cli # global — use `dotmd` anywhere
|
|
11
11
|
npm install -D dotmd-cli # project devDep — use via npm scripts
|
|
12
|
+
npx dotmd-cli init # try it without installing — scaffold a repo first
|
|
12
13
|
# requires Node.js >= 20
|
|
13
14
|
```
|
|
14
15
|
|
|
@@ -27,6 +28,19 @@ The plugin bundles the hooks (`SessionStart`/`SubagentStart` priming, a `PreTool
|
|
|
27
28
|
|
|
28
29
|
> **Upgrading to 0.57.0+:** per-repo `.claude/commands/{plans,docs,baton}.md` scaffolding is retired — that guidance now ships via the plugin's workflow skill and `/plans`, `/docs`, `/prompts`, `/baton` commands. On the next `dotmd hud` (SessionStart), dotmd removes those generated files (only banner-stamped `<!-- dotmd-generated -->` ones — your hand-authored command files are never touched). If you'd committed them, you'll see deletions to commit — that's expected. Run `claude plugin update dotmd@dotmd` to pick up `/baton`.
|
|
29
30
|
|
|
31
|
+
### Updating
|
|
32
|
+
|
|
33
|
+
The CLI and the Claude Code plugin are versioned in lockstep but ship as separate artifacts, so upgrading one can leave the other behind. `dotmd update` keeps them aligned:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
dotmd update # update both: npm CLI + the plugin
|
|
37
|
+
dotmd update --check # report CLI vs plugin versions, change nothing (no network)
|
|
38
|
+
dotmd update --cli-only # just the npm CLI
|
|
39
|
+
dotmd update --plugin-only # just the plugin (what to run after a plain npm upgrade)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`--plugin-only` is the usual fixup: after `npm i -g dotmd-cli@latest` the CLI is fresh but the plugin is stale, so run `dotmd update --plugin-only`, then restart the session (or `/reload-plugins`).
|
|
43
|
+
|
|
30
44
|
## Quick Start
|
|
31
45
|
|
|
32
46
|
```bash
|
package/bin/dotmd.mjs
CHANGED
|
@@ -255,6 +255,7 @@ Create & Export:
|
|
|
255
255
|
|
|
256
256
|
Setup:
|
|
257
257
|
init Create starter config + docs directory
|
|
258
|
+
update [--check|--cli-only|--plugin-only] Update the CLI + Claude Code plugin (--check reports skew, no network)
|
|
258
259
|
statuses [list|add|set|remove|migrate] Manage per-project status taxonomy
|
|
259
260
|
help statuses Full status vocabulary + unstuck-actions + transitions
|
|
260
261
|
watch [command] Re-run a command on file changes
|
package/package.json
CHANGED
package/src/doctor.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { checkClaudeCommands, removeGeneratedSlashCommands } from './claude-comm
|
|
|
11
11
|
import { runMigrateTemplate } from './migrate-template.mjs';
|
|
12
12
|
import { runMigratePrompts } from './migrate-prompts.mjs';
|
|
13
13
|
import { runFrontmatterFix } from './frontmatter-fix.mjs';
|
|
14
|
+
import { normalizeEol } from './frontmatter.mjs';
|
|
14
15
|
import { toRepoPath } from './util.mjs';
|
|
15
16
|
|
|
16
17
|
// Tunable thresholds for `dotmd doctor --statuses` conflation detection.
|
|
@@ -165,7 +166,7 @@ function findWorkflowDrift(config) {
|
|
|
165
166
|
for (const filePath of collectDocFiles(config)) {
|
|
166
167
|
let raw = '';
|
|
167
168
|
try { raw = readFileSync(filePath, 'utf8'); } catch { continue; }
|
|
168
|
-
if (!raw.startsWith('---\n')) docsWithoutFrontmatter.push(toRepoPath(filePath, config.repoRoot));
|
|
169
|
+
if (!normalizeEol(raw).startsWith('---\n')) docsWithoutFrontmatter.push(toRepoPath(filePath, config.repoRoot));
|
|
169
170
|
}
|
|
170
171
|
|
|
171
172
|
const planStatusGaps = [];
|
package/src/frontmatter.mjs
CHANGED
|
@@ -1,24 +1,40 @@
|
|
|
1
|
+
// Windows-authored (CRLF) docs otherwise slip past the LF-only fence detection
|
|
2
|
+
// below and read as having NO frontmatter — silently dropping them from the
|
|
3
|
+
// managed set (no type, no status). Normalizing CRLF→LF at every parse/rewrite
|
|
4
|
+
// boundary is the fix; the per-line value parser already strips a trailing \r,
|
|
5
|
+
// so only the fence scan was blind. A managed doc settles to LF the first time a
|
|
6
|
+
// dotmd verb rewrites it — content-preserving line-ending normalization, not
|
|
7
|
+
// corruption. For LF docs this is a no-op (the `\r` guard skips the replace), so
|
|
8
|
+
// existing behavior is byte-identical.
|
|
9
|
+
export function normalizeEol(text) {
|
|
10
|
+
return typeof text === 'string' && text.includes('\r') ? text.replace(/\r\n/g, '\n') : text;
|
|
11
|
+
}
|
|
12
|
+
|
|
1
13
|
export function extractFrontmatter(raw) {
|
|
2
|
-
|
|
3
|
-
|
|
14
|
+
const text = normalizeEol(raw);
|
|
15
|
+
if (!text.startsWith('---\n')) {
|
|
16
|
+
return { frontmatter: '', body: text };
|
|
4
17
|
}
|
|
5
18
|
|
|
6
|
-
const endMarker =
|
|
19
|
+
const endMarker = text.indexOf('\n---\n', 4);
|
|
7
20
|
if (endMarker === -1) {
|
|
8
|
-
return { frontmatter: '', body:
|
|
21
|
+
return { frontmatter: '', body: text };
|
|
9
22
|
}
|
|
10
23
|
|
|
11
24
|
return {
|
|
12
|
-
frontmatter:
|
|
13
|
-
body:
|
|
25
|
+
frontmatter: text.slice(4, endMarker),
|
|
26
|
+
body: text.slice(endMarker + 5),
|
|
14
27
|
};
|
|
15
28
|
}
|
|
16
29
|
|
|
17
30
|
export function replaceFrontmatter(raw, newFrontmatter) {
|
|
18
|
-
|
|
19
|
-
|
|
31
|
+
const text = normalizeEol(raw);
|
|
32
|
+
// No frontmatter to replace: return the ORIGINAL bytes untouched (a no-op
|
|
33
|
+
// rewrite must not normalize a file the caller didn't intend to change).
|
|
34
|
+
if (!text.startsWith('---\n')) return raw;
|
|
35
|
+
const endMarker = text.indexOf('\n---\n', 4);
|
|
20
36
|
if (endMarker === -1) return raw;
|
|
21
|
-
const body =
|
|
37
|
+
const body = text.slice(endMarker + 5);
|
|
22
38
|
return `---\n${newFrontmatter}\n---\n${body}`;
|
|
23
39
|
}
|
|
24
40
|
|
|
@@ -34,6 +50,16 @@ export function replaceFrontmatter(raw, newFrontmatter) {
|
|
|
34
50
|
// folded block scalar `key: >\n one line\n continues` → "one line continues"
|
|
35
51
|
// literal block scalar `key: |\n one\n two` → "one\ntwo"
|
|
36
52
|
// chomping indicators `>-`, `|-` (strip), `>+`, `|+` (keep), default (clip to one trailing \n)
|
|
53
|
+
//
|
|
54
|
+
// Supported-subset boundary (deliberately NOT a full YAML parser):
|
|
55
|
+
// - Scalars stay strings except literal `true`/`false`. Numbers, null, and
|
|
56
|
+
// dates are kept verbatim as strings; callers coerce where they need a type
|
|
57
|
+
// (so `1.0`, `2025-01-01`, version strings, and numeric-looking ids survive
|
|
58
|
+
// intact rather than silently changing type).
|
|
59
|
+
// - Duplicate keys keep the FIRST occurrence; later ones are ignored (and
|
|
60
|
+
// reported via the optional `warnings` array).
|
|
61
|
+
// - Nested maps / multi-level indentation are not parsed — only top-level
|
|
62
|
+
// keys, with one level of `- ` items under an array key.
|
|
37
63
|
export function parseSimpleFrontmatter(text, warnings) {
|
|
38
64
|
const data = {};
|
|
39
65
|
const seenDupKeys = new Set();
|
package/src/guard.mjs
CHANGED
|
@@ -27,6 +27,14 @@ const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'),
|
|
|
27
27
|
|
|
28
28
|
const SHELL_READERS = new Set(['cat', 'less', 'more', 'head', 'tail', 'bat', 'view', 'open']);
|
|
29
29
|
|
|
30
|
+
// Normalize path separators so the guard's `/`-based matching also fires on
|
|
31
|
+
// Windows backslash paths (`docs\prompts\foo.md`). Without this the PreToolUse
|
|
32
|
+
// status-edit/commit-prompt guards silently no-op on Windows — protection
|
|
33
|
+
// absent exactly where it's needed. On POSIX paths this is a no-op.
|
|
34
|
+
function toSlash(p) {
|
|
35
|
+
return typeof p === 'string' ? p.replace(/\\/g, '/') : p;
|
|
36
|
+
}
|
|
37
|
+
|
|
30
38
|
// A path that ends in .md and sits under a `prompts/` directory is a
|
|
31
39
|
// session-local saved prompt regardless of which doc root it belongs to —
|
|
32
40
|
// robust across repos without needing the resolved config. Archived prompts
|
|
@@ -34,20 +42,23 @@ const SHELL_READERS = new Set(['cat', 'less', 'more', 'head', 'tail', 'bat', 'vi
|
|
|
34
42
|
// committable history, NOT session-local, so they're explicitly excluded — the
|
|
35
43
|
// guard must not block committing or reading them.
|
|
36
44
|
function isPromptPath(p) {
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
if (
|
|
45
|
+
const s = toSlash(p);
|
|
46
|
+
if (typeof s !== 'string' || !s.endsWith('.md')) return false;
|
|
47
|
+
if (!/(^|\/)prompts\//.test(s)) return false;
|
|
48
|
+
if (/(^|\/)archived\//.test(s)) return false;
|
|
40
49
|
return true;
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
// Loose "is this a dotmd-managed doc" test: a .md file under one of the
|
|
44
53
|
// configured doc roots (default `docs/`). Used for the status-edit guard.
|
|
45
54
|
function isManagedDoc(p, config) {
|
|
46
|
-
|
|
55
|
+
const s = toSlash(p);
|
|
56
|
+
if (typeof s !== 'string' || !s.endsWith('.md')) return false;
|
|
47
57
|
const roots = config?.docsRoots || (config?.docsRoot ? [config.docsRoot] : ['docs']);
|
|
48
58
|
return roots.some(r => {
|
|
49
|
-
const
|
|
50
|
-
|
|
59
|
+
const rNorm = toSlash(r).replace(/\/+$/, '');
|
|
60
|
+
const base = rNorm.split('/').pop();
|
|
61
|
+
return s.includes(`/${base}/`) || s.startsWith(`${base}/`) || s.includes(rNorm);
|
|
51
62
|
});
|
|
52
63
|
}
|
|
53
64
|
|
package/src/init.mjs
CHANGED
|
@@ -190,9 +190,15 @@ function generateDetectedConfig(scan, rootPath) {
|
|
|
190
190
|
lines.push('');
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
if (scan.surfaces.size > 0) {
|
|
193
|
+
if (scan.surfaces.size > 0 || scan.modules.size > 0) {
|
|
194
194
|
lines.push('export const taxonomy = {');
|
|
195
|
-
|
|
195
|
+
// Emit the full detected set so every existing doc passes — taxonomy
|
|
196
|
+
// enforcement only flags values outside the list, and the scan collected
|
|
197
|
+
// all of them. New surfaces/modules added later warn until appended here.
|
|
198
|
+
if (scan.surfaces.size > 0)
|
|
199
|
+
lines.push(` surfaces: [${[...scan.surfaces].sort().map(s => `'${s}'`).join(', ')}],`);
|
|
200
|
+
if (scan.modules.size > 0)
|
|
201
|
+
lines.push(` modules: [${[...scan.modules].sort().map(m => `'${m}'`).join(', ')}],`);
|
|
196
202
|
lines.push('};');
|
|
197
203
|
lines.push('');
|
|
198
204
|
}
|
package/src/lifecycle.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter, normalizeEol } from './frontmatter.mjs';
|
|
4
4
|
import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escapeRegex, nowIso, suggestCandidates, emitFilesFooter, isArchivedPath } from './util.mjs';
|
|
5
5
|
import { gitMv, getGitLastModifiedBatch } from './git.mjs';
|
|
6
6
|
import { buildIndex, collectDocFiles, resolveDocArg } from './index.mjs';
|
|
@@ -935,7 +935,7 @@ function countRefsToUpdate(oldPath, newPath, config) {
|
|
|
935
935
|
// — never auto-creates the section (don't surprise users on old plans/docs).
|
|
936
936
|
export function appendVersionHistory(filePath, entry, { createSection = false } = {}) {
|
|
937
937
|
let raw;
|
|
938
|
-
try { raw = readFileSync(filePath, 'utf8'); } catch { return false; }
|
|
938
|
+
try { raw = normalizeEol(readFileSync(filePath, 'utf8')); } catch { return false; }
|
|
939
939
|
if (!raw.startsWith('---\n')) return false;
|
|
940
940
|
|
|
941
941
|
const endMarker = raw.indexOf('\n---\n', 4);
|
|
@@ -979,7 +979,7 @@ export function appendVersionHistory(filePath, entry, { createSection = false }
|
|
|
979
979
|
}
|
|
980
980
|
|
|
981
981
|
export function updateFrontmatter(filePath, updates) {
|
|
982
|
-
const raw = readFileSync(filePath, 'utf8');
|
|
982
|
+
const raw = normalizeEol(readFileSync(filePath, 'utf8'));
|
|
983
983
|
// Name the remedy in the error: this is where every status verb lands when a
|
|
984
984
|
// doc was created outside dotmd, and "no frontmatter block" alone left
|
|
985
985
|
// sessions retrying other verbs instead of fixing the doc.
|
|
@@ -1009,7 +1009,7 @@ export function updateFrontmatter(filePath, updates) {
|
|
|
1009
1009
|
// to updateFrontmatter when a block already exists so callers can hand it any
|
|
1010
1010
|
// file without pre-checking — the result is the same shape either way.
|
|
1011
1011
|
export function writeFrontmatter(filePath, fields) {
|
|
1012
|
-
const raw = readFileSync(filePath, 'utf8');
|
|
1012
|
+
const raw = normalizeEol(readFileSync(filePath, 'utf8'));
|
|
1013
1013
|
if (raw.startsWith('---\n')) {
|
|
1014
1014
|
updateFrontmatter(filePath, fields);
|
|
1015
1015
|
return;
|
package/src/new.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { toRepoPath, die, warn, nowIso, emitFilesFooter } from './util.mjs';
|
|
|
5
5
|
import { green, dim, bold } from './color.mjs';
|
|
6
6
|
import { isInteractive, promptText } from './prompt.mjs';
|
|
7
7
|
import { regenIndex } from './lifecycle.mjs';
|
|
8
|
-
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
8
|
+
import { extractFrontmatter, parseSimpleFrontmatter, normalizeEol } from './frontmatter.mjs';
|
|
9
9
|
|
|
10
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
11
|
const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -203,7 +203,7 @@ Status markers (put in heading text):
|
|
|
203
203
|
// trap 4. Returns `{ frontmatter: object|null, body: string }`.
|
|
204
204
|
function splitBodyFrontmatter(rawBody) {
|
|
205
205
|
if (!rawBody || typeof rawBody !== 'string') return { frontmatter: null, body: rawBody };
|
|
206
|
-
if (!rawBody.startsWith('---\n')) return { frontmatter: null, body: rawBody };
|
|
206
|
+
if (!normalizeEol(rawBody).startsWith('---\n')) return { frontmatter: null, body: rawBody };
|
|
207
207
|
const { frontmatter: fmText, body } = extractFrontmatter(rawBody);
|
|
208
208
|
if (!fmText) return { frontmatter: null, body: rawBody };
|
|
209
209
|
const parsed = parseSimpleFrontmatter(fmText);
|