dotmd-cli 0.1.0 → 0.2.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/bin/dotmd.mjs CHANGED
@@ -10,7 +10,9 @@ 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 { die, warn } from '../src/util.mjs';
14
16
 
15
17
  const __filename = fileURLToPath(import.meta.url);
16
18
  const __dirname = path.dirname(__filename);
@@ -31,11 +33,14 @@ Commands:
31
33
  status <file> <status> Transition document status
32
34
  archive <file> Archive (status + move + index regen)
33
35
  touch <file> Bump updated date
36
+ new <name> Create a new document with frontmatter
34
37
  init Create starter config + docs directory
38
+ completions <shell> Output shell completion script (bash, zsh)
35
39
 
36
40
  Options:
37
41
  --config <path> Explicit config file path
38
42
  --dry-run, -n Preview changes without writing anything
43
+ --verbose Show config details and doc count
39
44
  --help, -h Show help
40
45
  --version, -v Show version`,
41
46
 
@@ -81,6 +86,17 @@ With --write, updates the configured index file in place.
81
86
 
82
87
  Use --dry-run (-n) with --write to preview without writing.`,
83
88
 
89
+ new: `dotmd new <name> — create a new document
90
+
91
+ Creates a new markdown document with frontmatter in the docs root.
92
+
93
+ Options:
94
+ --status <s> Set initial status (default: active)
95
+ --title <t> Override the document title
96
+
97
+ The filename is derived from <name> by slugifying it.
98
+ Use --dry-run (-n) to preview without creating the file.`,
99
+
84
100
  init: `dotmd init — create starter config and docs directory
85
101
 
86
102
  Creates dotmd.config.mjs, docs/, and docs/docs.md in the current
@@ -108,12 +124,17 @@ async function main() {
108
124
  return;
109
125
  }
110
126
 
111
- // Init doesn't need config
127
+ // Init and completions don't need config
112
128
  if (command === 'init') {
113
129
  runInit(process.cwd());
114
130
  return;
115
131
  }
116
132
 
133
+ if (command === 'completions') {
134
+ runCompletions(args.slice(1));
135
+ return;
136
+ }
137
+
117
138
  // Extract --config flag
118
139
  let explicitConfig = null;
119
140
  for (let i = 0; i < args.length; i++) {
@@ -124,10 +145,21 @@ async function main() {
124
145
  }
125
146
 
126
147
  const dryRun = args.includes('--dry-run') || args.includes('-n');
148
+ const verbose = args.includes('--verbose');
127
149
 
128
150
  const config = await resolveConfig(process.cwd(), explicitConfig);
129
151
  const restArgs = args.slice(1);
130
152
 
153
+ if (!config.configFound && command !== 'init') {
154
+ warn('No dotmd config found — using defaults. Run `dotmd init` to create one.');
155
+ }
156
+
157
+ if (verbose) {
158
+ process.stderr.write(`Config: ${config.configPath ?? 'none'}\n`);
159
+ process.stderr.write(`Docs root: ${config.docsRoot}\n`);
160
+ process.stderr.write(`Repo root: ${config.repoRoot}\n`);
161
+ }
162
+
131
163
  // Preset aliases
132
164
  if (config.presets[command]) {
133
165
  const index = buildIndex(config);
@@ -139,9 +171,14 @@ async function main() {
139
171
  if (command === 'status') { runStatus(restArgs, config, { dryRun }); return; }
140
172
  if (command === 'archive') { runArchive(restArgs, config, { dryRun }); return; }
141
173
  if (command === 'touch') { runTouch(restArgs, config, { dryRun }); return; }
174
+ if (command === 'new') { runNew(restArgs, config, { dryRun }); return; }
142
175
 
143
176
  const index = buildIndex(config);
144
177
 
178
+ if (verbose) {
179
+ process.stderr.write(`Docs found: ${index.docs.length}\n`);
180
+ }
181
+
145
182
  if (command === 'json') {
146
183
  process.stdout.write(`${JSON.stringify(index, null, 2)}\n`);
147
184
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.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,97 @@
1
+ import { die } from './util.mjs';
2
+
3
+ const COMMANDS = [
4
+ 'list', 'json', 'check', 'coverage', 'context', 'focus', 'query',
5
+ 'index', 'status', 'archive', 'touch', '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
+ };
19
+
20
+ function bashCompletion() {
21
+ return `# dotmd bash completion
22
+ # Add to ~/.bashrc: eval "$(dotmd completions bash)"
23
+ _dotmd() {
24
+ local cur prev cmd
25
+ cur="\${COMP_WORDS[COMP_CWORD]}"
26
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
27
+
28
+ # Find the subcommand
29
+ cmd=""
30
+ for ((i=1; i < COMP_CWORD; i++)); do
31
+ case "\${COMP_WORDS[i]}" in
32
+ -*) ;;
33
+ *) cmd="\${COMP_WORDS[i]}"; break ;;
34
+ esac
35
+ done
36
+
37
+ # Complete commands if no subcommand yet
38
+ if [[ -z "$cmd" ]]; then
39
+ COMPREPLY=( $(compgen -W "${COMMANDS.join(' ')} ${GLOBAL_FLAGS.join(' ')}" -- "$cur") )
40
+ return
41
+ fi
42
+
43
+ # Per-command flag completion
44
+ case "$cmd" in
45
+ ${Object.entries(COMMAND_FLAGS).map(([cmd, flags]) =>
46
+ ` ${cmd}) COMPREPLY=( $(compgen -W "${flags.join(' ')} ${GLOBAL_FLAGS.join(' ')}" -- "$cur") ) ;;`
47
+ ).join('\n')}
48
+ *) COMPREPLY=( $(compgen -W "${GLOBAL_FLAGS.join(' ')}" -- "$cur") ) ;;
49
+ esac
50
+ }
51
+ complete -F _dotmd dotmd`;
52
+ }
53
+
54
+ function zshCompletion() {
55
+ return `# dotmd zsh completion
56
+ # Add to ~/.zshrc: eval "$(dotmd completions zsh)"
57
+ _dotmd() {
58
+ local -a commands global_flags
59
+ commands=(
60
+ ${COMMANDS.map(c => ` '${c}'`).join('\n')}
61
+ )
62
+ global_flags=(
63
+ ${GLOBAL_FLAGS.map(f => ` '${f}'`).join('\n')}
64
+ )
65
+
66
+ if (( CURRENT == 2 )); then
67
+ _describe 'command' commands
68
+ _describe 'flag' global_flags
69
+ return
70
+ fi
71
+
72
+ local cmd=\${words[2]}
73
+ case "$cmd" in
74
+ ${Object.entries(COMMAND_FLAGS).map(([cmd, flags]) =>
75
+ ` ${cmd}) _values 'flags' ${flags.map(f => `'${f}'`).join(' ')} ;;`
76
+ ).join('\n')}
77
+ esac
78
+
79
+ _describe 'flag' global_flags
80
+ }
81
+ compdef _dotmd dotmd`;
82
+ }
83
+
84
+ export function runCompletions(argv) {
85
+ const shell = argv[0];
86
+ if (!shell) {
87
+ die('Usage: dotmd completions <bash|zsh>');
88
+ return;
89
+ }
90
+ if (shell === 'bash') {
91
+ process.stdout.write(bashCompletion() + '\n');
92
+ } else if (shell === 'zsh') {
93
+ process.stdout.write(zshCompletion() + '\n');
94
+ } else {
95
+ die(`Unsupported shell: ${shell}\nSupported: bash, zsh`);
96
+ }
97
+ }
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/lifecycle.mjs CHANGED
@@ -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
 
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,5 @@
1
1
  import path from 'node:path';
2
+ import { dim } from './color.mjs';
2
3
 
3
4
  export function escapeTable(value) {
4
5
  return String(value).replace(/\|/g, '\\|');
@@ -49,6 +50,10 @@ export function toRepoPath(absolutePath, repoRoot) {
49
50
  return path.relative(repoRoot, absolutePath).split(path.sep).join('/');
50
51
  }
51
52
 
53
+ export function warn(message) {
54
+ process.stderr.write(`${dim(message)}\n`);
55
+ }
56
+
52
57
  export function die(message) {
53
58
  process.stderr.write(`${message}\n`);
54
59
  process.exitCode = 1;