@natjswenson/devlog 0.4.1 → 0.5.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/devlog.js CHANGED
@@ -1,48 +1,61 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn, spawnSync, execSync } from 'node:child_process';
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, renameSync, unlinkSync } from 'node:fs';
4
- import { homedir, tmpdir } from 'node:os';
3
+ import { existsSync, mkdirSync, readFileSync, copyFileSync } from 'node:fs';
5
4
  import { dirname, join, resolve, basename } from 'node:path';
6
5
  import { fileURLToPath } from 'node:url';
7
6
  import { createRequire } from 'node:module';
7
+ import { parseArgs } from 'node:util';
8
8
  import prompts from 'prompts';
9
9
  import kleur from 'kleur';
10
10
 
11
- // ─── shared validators (single source of truth, also used by SKILL.md guidance) ───
12
- //
13
- // SHELL_QUOTE_BREAK matches characters that can break out of a single-quoted
14
- // shell string OR are dangerous if quoting is omitted. The skill instructs the
15
- // LLM to single-quote every interpolated value; rejecting these chars upstream
16
- // guarantees that single-quoting is sufficient. Whitespace, dots, hyphens,
17
- // equals, and similar are NOT rejected — they're literal inside '...' and are
18
- // legitimate in human-readable fields like names and paths.
19
- //
20
- // For strict-token fields (project keys, repo names, branch names), separate
21
- // allowlist regexes apply additional structural constraints.
22
- export const SHELL_QUOTE_BREAK = /[;&|`$()<>{}[\]*?!#~"'\\\n\r]/;
23
- export const RE_GH_USER = /^[a-z0-9][a-z0-9-]*$/i;
24
- export const RE_REPO_NAME = /^[a-z0-9][a-z0-9._-]*$/i;
25
- export const RE_OWNER_REPO = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i;
26
- export const RE_PROJECT_KEY = /^[a-z0-9][a-z0-9._-]*$/i;
27
- export const RE_BRANCH = /^[a-z0-9][a-z0-9._/-]*$/i;
28
- // Repo-relative subdir used to scope `git log` to one skill in a monorepo.
29
- // Same shape as a branch: no leading dash/slash, no shell metacharacters.
30
- export const RE_PATH_FILTER = /^[a-z0-9][a-z0-9._/-]*$/i;
31
- // Git tag prefix that marks a project's releases (e.g. `v` or `devlog-v`).
32
- // Interpolated into `git tag --list '<tagPrefix>*'`; same safety as a path filter.
33
- export const RE_TAG_PREFIX = /^[a-z0-9][a-z0-9._/-]*$/i;
34
- export const FORBIDDEN_BRANCH_PARTS = /(^|\/)\.\.($|\/)/; // reject `..` as a path component
11
+ import {
12
+ SHELL_QUOTE_BREAK,
13
+ RE_GH_USER,
14
+ RE_REPO_NAME,
15
+ RE_OWNER_REPO,
16
+ RE_PROJECT_KEY,
17
+ RE_BRANCH,
18
+ RE_PATH_FILTER,
19
+ RE_TAG_PREFIX,
20
+ FORBIDDEN_BRANCH_PARTS,
21
+ CONFIG_DIR,
22
+ CONFIG_PATH,
23
+ expandHome,
24
+ execArgs,
25
+ atomicWriteJSON,
26
+ readConfig,
27
+ validateConfig,
28
+ resolveDeepDive,
29
+ } from '../lib/core.mjs';
30
+ import { scanAll } from '../lib/scan.mjs';
31
+ import { lintPost } from '../lib/lint_post.mjs';
32
+ import { publishEntry } from '../lib/publish_entry.mjs';
33
+ import { addProject, removeProject, setField, SETTABLE_FIELDS } from '../lib/config_ops.mjs';
34
+
35
+ // Re-export the shared validators so existing importers (tests, docs) keep a
36
+ // single canonical entry point; the definitions live in lib/core.mjs.
37
+ export {
38
+ SHELL_QUOTE_BREAK,
39
+ RE_GH_USER,
40
+ RE_REPO_NAME,
41
+ RE_OWNER_REPO,
42
+ RE_PROJECT_KEY,
43
+ RE_BRANCH,
44
+ RE_PATH_FILTER,
45
+ RE_TAG_PREFIX,
46
+ FORBIDDEN_BRANCH_PARTS,
47
+ expandHome,
48
+ validateConfig,
49
+ };
35
50
 
36
51
  const require = createRequire(import.meta.url);
37
52
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
38
53
  const SKILL_SRC = join(PACKAGE_ROOT, 'SKILL.md');
39
- const CONFIG_DIR = join(homedir(), '.claude', 'skills', 'devlog');
40
- const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
41
54
  const SKILL_DEST = join(CONFIG_DIR, 'SKILL.md');
42
55
  const PREVIEW_DIR = join(PACKAGE_ROOT, 'preview');
43
56
  const VOICE_SRC_DIR = join(PACKAGE_ROOT, 'voice');
44
57
  const VOICE_DEST_DIR = join(CONFIG_DIR, 'voice');
45
- const GHOSTWRITER_VOICE_DIR = join(homedir(), '.claude', 'skills', 'ghostwriter', 'voice');
58
+ const GHOSTWRITER_VOICE_DIR = join(expandHome('~'), '.claude', 'ghostwriter', 'voice');
46
59
 
47
60
  const log = {
48
61
  info: (msg) => console.log(msg),
@@ -58,7 +71,7 @@ function readPackageVersion() {
58
71
  return pkg.version;
59
72
  }
60
73
 
61
- // Hardcoded shell command, no user input. Use tryExecArgs for anything user-supplied.
74
+ // Hardcoded shell command, no user input. Use execArgs for anything user-supplied.
62
75
  function tryExec(cmd) {
63
76
  try {
64
77
  return execSync(cmd, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' }).trim();
@@ -67,127 +80,33 @@ function tryExec(cmd) {
67
80
  }
68
81
  }
69
82
 
70
- // argv-style invocation; no shell, so user-supplied args cannot inject.
71
- function tryExecArgs(cmd, args) {
72
- try {
73
- const r = spawnSync(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' });
74
- if (r.status !== 0) return null;
75
- return (r.stdout || '').trim();
76
- } catch {
77
- return null;
78
- }
79
- }
80
-
81
- export function expandHome(p) {
82
- if (!p) return p;
83
- if (p === '~') return homedir();
84
- if (p.startsWith('~/')) return join(homedir(), p.slice(2));
85
- return p;
83
+ // Machine-readable output for agent-driven commands: JSON on stdout, explicit
84
+ // exit code, no color.
85
+ function emitJSON(obj, exitCode = 0) {
86
+ console.log(JSON.stringify(obj, null, 2));
87
+ process.exit(exitCode);
86
88
  }
87
89
 
88
- // Atomic write: write to sibling tmp file then rename.
89
- // Prevents readers from seeing a half-written config if process is killed mid-write.
90
- // Uses `wx` (exclusive create) flag to prevent symlink-attack on shared filesystems
91
- // if an attacker pre-creates the tmp file, our write fails rather than following
92
- // the symlink to a sensitive target.
93
- function atomicWriteJSON(path, data) {
94
- const tmp = path + '.tmp.' + process.pid + '.' + Date.now();
95
- writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
90
+ function readValidConfigOrExit({ json = false } = {}) {
91
+ if (!existsSync(CONFIG_PATH)) {
92
+ if (json) emitJSON({ error: 'config-missing', path: CONFIG_PATH, hint: 'Run `npx @natjswenson/devlog init` first.' }, 1);
93
+ log.err(`No config found at ${CONFIG_PATH}`);
94
+ log.hint('Run `npx @natjswenson/devlog init` first.');
95
+ process.exit(1);
96
+ }
97
+ let config;
96
98
  try {
97
- renameSync(tmp, path);
99
+ config = readConfig();
100
+ validateConfig(config);
98
101
  } catch (e) {
99
- try { unlinkSync(tmp); } catch {}
100
- throw e;
101
- }
102
- }
103
-
104
- // Validate a config object before writing. Throws with a user-facing message on failure.
105
- export function validateConfig(config) {
106
- if (!config || typeof config !== 'object') throw new Error('Config must be an object');
107
- const required = ['targetRepo', 'gitAuthor', 'githubUser', 'projects'];
108
- for (const k of required) {
109
- if (!(k in config)) throw new Error(`Missing required field: ${k}`);
110
- }
111
- if (!RE_OWNER_REPO.test(config.targetRepo)) {
112
- throw new Error(`targetRepo must match <owner>/<repo>: got ${JSON.stringify(config.targetRepo)}`);
113
- }
114
- if (typeof config.gitAuthor !== 'string' || config.gitAuthor.length === 0 || SHELL_QUOTE_BREAK.test(config.gitAuthor)) {
115
- throw new Error(`gitAuthor must be non-empty and contain no shell metacharacters: got ${JSON.stringify(config.gitAuthor)}`);
116
- }
117
- if (!RE_GH_USER.test(config.githubUser)) {
118
- throw new Error(`githubUser must match GitHub username pattern: got ${JSON.stringify(config.githubUser)}`);
119
- }
120
- if ('branch' in config) {
121
- if (!RE_BRANCH.test(config.branch) || FORBIDDEN_BRANCH_PARTS.test(config.branch)) {
122
- throw new Error(`branch must be a valid git branch name (no leading dash, no '..'): got ${JSON.stringify(config.branch)}`);
123
- }
124
- }
125
- if ('voicePath' in config) {
126
- // Optional: directory holding the voice profile used to write entries. Read by
127
- // the skill with the Read tool only — never shell-interpolated — so the only
128
- // hard requirement is no shell metacharacters and no leading dash. A leading `~`
129
- // is allowed (the skill expands it); we test the expanded form so an absolute
130
- // path has no `~` left to trip the shell-quote-break check. Existence is checked
131
- // at prompt time (and at runtime, with a fallback chain), not here.
132
- const expanded = typeof config.voicePath === 'string' ? expandHome(config.voicePath) : config.voicePath;
133
- if (typeof config.voicePath !== 'string' || SHELL_QUOTE_BREAK.test(expanded) || expanded.trim().startsWith('-')) {
134
- throw new Error(`voicePath must be a path with no shell metacharacters and no leading dash: got ${JSON.stringify(config.voicePath)}`);
135
- }
136
- }
137
- if (!Array.isArray(config.projects)) {
138
- throw new Error('projects must be an array');
139
- }
140
- const seenKeys = new Set();
141
- for (const p of config.projects) {
142
- if (!p || typeof p !== 'object') throw new Error('Each project must be an object');
143
- if (!RE_PROJECT_KEY.test(p.key) || p.key.includes('..')) {
144
- throw new Error(`project.key invalid: ${JSON.stringify(p.key)}`);
145
- }
146
- if (seenKeys.has(p.key)) throw new Error(`Duplicate project key: ${JSON.stringify(p.key)}`);
147
- seenKeys.add(p.key);
148
- if (typeof p.path !== 'string' || SHELL_QUOTE_BREAK.test(p.path)) {
149
- throw new Error(`project.path invalid (must contain no shell metacharacters): ${JSON.stringify(p.path)}`);
150
- }
151
- if (!RE_OWNER_REPO.test(p.remote)) {
152
- throw new Error(`project.remote must match <owner>/<repo>: ${JSON.stringify(p.remote)}`);
153
- }
154
- if ('pathFilter' in p) {
155
- // Optional: scope this project's commits to a repo subdirectory (e.g. a
156
- // single skill in a monorepo). Interpolated into `git log -- <pathFilter>`,
157
- // so enforce the same no-metacharacter / no-`..` safety as branch names.
158
- if (typeof p.pathFilter !== 'string' || !RE_PATH_FILTER.test(p.pathFilter) || FORBIDDEN_BRANCH_PARTS.test(p.pathFilter)) {
159
- throw new Error(`project.pathFilter must be a repo-relative subdir (no leading dash/slash, no '..', no shell metacharacters): ${JSON.stringify(p.pathFilter)}`);
160
- }
161
- }
162
- if ('tagPrefix' in p) {
163
- // Optional: the prefix of the git tags that mark this project's releases
164
- // (e.g. `devlog-v`). Interpolated into `git tag --list '<tagPrefix>*'`, so
165
- // enforce the same no-metacharacter / no-`..` safety as path filters.
166
- if (typeof p.tagPrefix !== 'string' || !RE_TAG_PREFIX.test(p.tagPrefix) || FORBIDDEN_BRANCH_PARTS.test(p.tagPrefix)) {
167
- throw new Error(`project.tagPrefix must be a tag prefix (no leading dash/slash, no '..', no shell metacharacters): ${JSON.stringify(p.tagPrefix)}`);
168
- }
169
- }
170
- if ('label' in p) {
171
- // Label is rendered as React text content only — never shell-interpolated,
172
- // never used in URLs, never used as a filesystem path. React escapes all
173
- // text content. Therefore: any string is safe. Apostrophes (e.g.
174
- // "Mom I'm Bored") and unicode are legitimate label content.
175
- // INVARIANT: if a future change makes label flow into shell or innerHTML,
176
- // tighten this validation to SHELL_QUOTE_BREAK at the same time.
177
- if (typeof p.label !== 'string') throw new Error(`project.label must be a string if present`);
178
- if (p.label.length > 200) throw new Error(`project.label too long (max 200 chars)`);
179
- if (/[\x00-\x1f]/.test(p.label)) throw new Error(`project.label contains control characters`);
180
- }
102
+ if (json) emitJSON({ error: 'config-invalid', message: e.message, path: CONFIG_PATH }, 1);
103
+ log.err(`Config is invalid: ${e.message}`);
104
+ log.hint(`Edit ${CONFIG_PATH} or run \`devlog init\` to recreate.`);
105
+ process.exit(1);
181
106
  }
182
107
  return config;
183
108
  }
184
109
 
185
- function readConfig() {
186
- if (!existsSync(CONFIG_PATH)) return null;
187
- const raw = readFileSync(CONFIG_PATH, 'utf8');
188
- return JSON.parse(raw);
189
- }
190
-
191
110
  async function preflight() {
192
111
  const nodeMajor = parseInt(process.versions.node.split('.')[0], 10);
193
112
  if (nodeMajor < 18) {
@@ -216,7 +135,7 @@ function detectGitName() {
216
135
  }
217
136
 
218
137
  function detectProjectRemote(path) {
219
- const url = tryExecArgs('git', ['-C', path, 'remote', 'get-url', 'origin']);
138
+ const url = execArgs('git', ['-C', path, 'remote', 'get-url', 'origin']);
220
139
  if (!url) return null;
221
140
  const m = url.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
222
141
  return m ? m[1] : null;
@@ -328,9 +247,8 @@ async function promptForProject(defaults = {}) {
328
247
  remote: answers.remote.trim(),
329
248
  };
330
249
  if (answers.label && answers.label.trim()) out.label = answers.label.trim();
331
- // Only persist tagPrefix when it differs from the default `v` (keeps configs clean).
332
250
  const tagPrefix = (answers.tagPrefix || '').trim();
333
- if (tagPrefix && tagPrefix !== 'v') out.tagPrefix = tagPrefix;
251
+ if (tagPrefix) out.tagPrefix = tagPrefix;
334
252
  return out;
335
253
  }
336
254
 
@@ -357,9 +275,8 @@ async function cmdInit() {
357
275
 
358
276
  // Optionally register projects in a loop. First time defaults to "yes".
359
277
  const projects = [];
360
- let registerAnother = true;
361
278
  let firstPrompt = true;
362
- while (registerAnother) {
279
+ for (;;) {
363
280
  const { add } = await prompts({
364
281
  type: 'confirm',
365
282
  name: 'add',
@@ -373,6 +290,7 @@ async function cmdInit() {
373
290
  log.warn(`Skipped (duplicate key): ${p.key}`);
374
291
  continue;
375
292
  }
293
+ if (p.tagPrefix === 'v') delete p.tagPrefix;
376
294
  projects.push(p);
377
295
  log.ok(`Registered: ${p.key}`);
378
296
  }
@@ -413,7 +331,7 @@ async function cmdInit() {
413
331
  log.info('');
414
332
 
415
333
  // Repo create — argv form, no shell.
416
- const repoExists = tryExecArgs('gh', ['repo', 'view', targetRepo, '--json', 'name']) !== null;
334
+ const repoExists = execArgs('gh', ['repo', 'view', targetRepo, '--json', 'name']) !== null;
417
335
  if (repoExists) {
418
336
  log.warn(`Repo github.com/${targetRepo} already exists. Will use it as-is.`);
419
337
  } else {
@@ -441,8 +359,6 @@ async function cmdInit() {
441
359
  if (await confirmOverwrite('config.json', CONFIG_PATH)) {
442
360
  atomicWriteJSON(CONFIG_PATH, config);
443
361
  log.ok(`Wrote config → ${CONFIG_PATH}`);
444
- } else {
445
- log.warn('Skipped config.json');
446
362
  }
447
363
 
448
364
  // Install the bundled voice template as the fallback voice profile. The skill
@@ -476,48 +392,215 @@ async function cmdInit() {
476
392
  }
477
393
 
478
394
  // ─── add-project ─────────────────────────────────────────────────────────────
479
- async function cmdAddProject() {
480
- log.info(kleur.bold('\ndevlog add-project\n'));
481
- if (!existsSync(CONFIG_PATH)) {
482
- log.err(`No config found at ${CONFIG_PATH}`);
483
- log.hint('Run `npx @natjswenson/devlog init` first.');
484
- process.exit(1);
485
- }
395
+ async function cmdAddProject(rest) {
396
+ const { values } = parseArgs({
397
+ args: rest,
398
+ options: {
399
+ path: { type: 'string' },
400
+ key: { type: 'string' },
401
+ remote: { type: 'string' },
402
+ label: { type: 'string' },
403
+ 'tag-prefix': { type: 'string' },
404
+ 'path-filter': { type: 'string' },
405
+ yes: { type: 'boolean', default: false },
406
+ json: { type: 'boolean', default: false },
407
+ },
408
+ allowPositionals: false,
409
+ });
486
410
 
487
- let config;
488
- try {
489
- config = readConfig();
490
- validateConfig(config);
491
- } catch (e) {
492
- log.err(`Existing config is invalid: ${e.message}`);
493
- log.hint(`Edit ${CONFIG_PATH} or run \`devlog init\` to recreate.`);
494
- process.exit(1);
411
+ // Non-interactive (agent) path: --yes with at least --path. Everything else
412
+ // is auto-detected the same way the interactive prompts pre-fill.
413
+ if (values.yes) {
414
+ const config = readValidConfigOrExit({ json: true });
415
+ if (!values.path) emitJSON({ error: 'missing-flag', message: 'add-project --yes requires --path' }, 1);
416
+ const path = expandHome(values.path);
417
+ if (!existsSync(path)) emitJSON({ error: 'path-missing', message: `Path does not exist: ${path}` }, 1);
418
+ const key = values.key || basename(path);
419
+ const remote = values.remote || detectProjectRemote(path);
420
+ if (!remote) emitJSON({ error: 'remote-undetectable', message: 'No origin remote found; pass --remote <owner>/<repo>.' }, 1);
421
+ try {
422
+ const next = addProject(config, {
423
+ key,
424
+ path,
425
+ remote,
426
+ label: values.label,
427
+ tagPrefix: values['tag-prefix'],
428
+ pathFilter: values['path-filter'],
429
+ });
430
+ atomicWriteJSON(CONFIG_PATH, next);
431
+ emitJSON({ ok: true, added: next.projects.at(-1), projects: next.projects.map((p) => p.key) });
432
+ } catch (e) {
433
+ emitJSON({ error: 'invalid-project', message: e.message }, 1);
434
+ }
435
+ return;
495
436
  }
496
437
 
438
+ log.info(kleur.bold('\ndevlog add-project\n'));
439
+ const config = readValidConfigOrExit();
440
+
497
441
  if (config.projects.length > 0) {
498
442
  log.info(kleur.dim('Currently registered projects:'));
499
443
  for (const p of config.projects) log.info(kleur.dim(` - ${p.key} (${p.path})`));
500
444
  log.info('');
501
445
  }
502
446
 
503
- const newProject = await promptForProject();
504
- if (config.projects.find((p) => p.key === newProject.key)) {
505
- log.err(`Project key "${newProject.key}" is already registered.`);
447
+ const newProject = await promptForProject(values);
448
+ try {
449
+ const next = addProject(config, {
450
+ key: newProject.key,
451
+ path: newProject.path,
452
+ remote: newProject.remote,
453
+ label: newProject.label,
454
+ tagPrefix: newProject.tagPrefix,
455
+ });
456
+ atomicWriteJSON(CONFIG_PATH, next);
457
+ log.ok(`Added "${newProject.key}" to config.`);
458
+ } catch (e) {
459
+ log.err(e.message);
506
460
  log.hint('Pick a different key, or remove the existing entry first.');
507
461
  process.exit(1);
508
462
  }
509
-
510
- const newConfig = validateConfig({ ...config, projects: [...config.projects, newProject] });
511
- atomicWriteJSON(CONFIG_PATH, newConfig);
512
- log.ok(`Added "${newProject.key}" to config.`);
513
463
  log.info('');
514
464
  log.info(`Run ${kleur.cyan('/devlog ' + newProject.key)} in Claude Code to publish an entry for this project.`);
515
465
  log.info('');
516
466
  }
517
467
 
468
+ // ─── remove-project ──────────────────────────────────────────────────────────
469
+ async function cmdRemoveProject(rest) {
470
+ const { values, positionals } = parseArgs({
471
+ args: rest,
472
+ options: { yes: { type: 'boolean', default: false } },
473
+ allowPositionals: true,
474
+ });
475
+ const key = positionals[0];
476
+ const config = readValidConfigOrExit({ json: values.yes });
477
+ if (!key) emitJSON({ error: 'missing-arg', message: 'Usage: devlog remove-project <key> --yes' }, 1);
478
+
479
+ if (!values.yes) {
480
+ const { ok } = await prompts({
481
+ type: 'confirm',
482
+ name: 'ok',
483
+ message: `Remove project "${key}" from config? (published entries are NOT deleted)`,
484
+ initial: false,
485
+ }, { onCancel: () => process.exit(1) });
486
+ if (!ok) process.exit(0);
487
+ }
488
+
489
+ try {
490
+ const next = removeProject(config, key);
491
+ atomicWriteJSON(CONFIG_PATH, next);
492
+ emitJSON({ ok: true, removed: key, projects: next.projects.map((p) => p.key) });
493
+ } catch (e) {
494
+ emitJSON({ error: 'remove-failed', message: e.message }, 1);
495
+ }
496
+ }
497
+
498
+ // ─── set ─────────────────────────────────────────────────────────────────────
499
+ function cmdSet(rest) {
500
+ const { positionals } = parseArgs({ args: rest, options: {}, allowPositionals: true });
501
+ const [field, value] = positionals;
502
+ const config = readValidConfigOrExit({ json: true });
503
+ if (!field || value === undefined) {
504
+ emitJSON({ error: 'missing-arg', message: `Usage: devlog set <field> <value>. Settable: ${SETTABLE_FIELDS.join(', ')}` }, 1);
505
+ }
506
+ try {
507
+ const next = setField(config, field, value);
508
+ atomicWriteJSON(CONFIG_PATH, next);
509
+ emitJSON({ ok: true, field, config: next });
510
+ } catch (e) {
511
+ emitJSON({ error: 'set-failed', message: e.message }, 1);
512
+ }
513
+ }
514
+
515
+ // ─── scan ────────────────────────────────────────────────────────────────────
516
+ function cmdScan(rest) {
517
+ const { values } = parseArgs({
518
+ args: rest,
519
+ options: {
520
+ project: { type: 'string' },
521
+ 'no-fetch': { type: 'boolean', default: false },
522
+ // scan always emits JSON; the flag is accepted so `scan --json` (as
523
+ // SKILL.md spells it) is never a crash.
524
+ json: { type: 'boolean', default: true },
525
+ },
526
+ allowPositionals: false,
527
+ });
528
+ const config = readValidConfigOrExit({ json: true });
529
+ const result = scanAll(config, { projectKey: values.project || null, fetch: !values['no-fetch'] });
530
+ emitJSON(result, result.error ? 1 : 0);
531
+ }
532
+
533
+ // ─── lint-post ───────────────────────────────────────────────────────────────
534
+ function cmdLintPost(rest) {
535
+ const { values, positionals } = parseArgs({
536
+ args: rest,
537
+ options: { 'min-sources': { type: 'string' } },
538
+ allowPositionals: true,
539
+ });
540
+ const file = positionals[0];
541
+ if (!file) emitJSON({ error: 'missing-arg', message: 'Usage: devlog lint-post <file> [--min-sources N]' }, 2);
542
+
543
+ let minSources;
544
+ if (values['min-sources'] !== undefined) {
545
+ minSources = Number(values['min-sources']);
546
+ if (!Number.isInteger(minSources) || minSources < 1) {
547
+ emitJSON({ error: 'bad-flag', message: '--min-sources must be a positive integer' }, 2);
548
+ }
549
+ } else {
550
+ // Default from config when available; falls back to the shipped default.
551
+ let config = null;
552
+ try { config = readConfig(); } catch { /* unreadable config → defaults */ }
553
+ minSources = resolveDeepDive(config || {}).minSources;
554
+ }
555
+
556
+ let content;
557
+ try {
558
+ content = readFileSync(expandHome(file), 'utf8');
559
+ } catch (e) {
560
+ emitJSON({ error: 'unreadable', message: e.message }, 2);
561
+ }
562
+ const result = lintPost(content, { minSources, filename: file });
563
+ emitJSON({ ...result, minSources }, result.ok ? 0 : 1);
564
+ }
565
+
566
+ // ─── publish-entry ───────────────────────────────────────────────────────────
567
+ function cmdPublishEntry(rest) {
568
+ const { values } = parseArgs({
569
+ args: rest,
570
+ options: {
571
+ clone: { type: 'string' },
572
+ project: { type: 'string' },
573
+ version: { type: 'string' },
574
+ entry: { type: 'string' },
575
+ },
576
+ allowPositionals: false,
577
+ });
578
+ for (const flag of ['clone', 'project', 'version', 'entry']) {
579
+ if (!values[flag]) emitJSON({ error: 'missing-flag', message: `publish-entry requires --${flag}` }, 1);
580
+ }
581
+ try {
582
+ const result = publishEntry({
583
+ cloneDir: expandHome(values.clone),
584
+ project: values.project,
585
+ version: values.version,
586
+ entryPath: expandHome(values.entry),
587
+ });
588
+ emitJSON({ ok: true, ...result });
589
+ } catch (e) {
590
+ emitJSON({ error: 'publish-failed', message: e.message }, 1);
591
+ }
592
+ }
593
+
518
594
  // ─── config (view) ───────────────────────────────────────────────────────────
519
- async function cmdConfig() {
595
+ async function cmdConfig(rest) {
596
+ const { values } = parseArgs({
597
+ args: rest,
598
+ options: { json: { type: 'boolean', default: false } },
599
+ allowPositionals: false,
600
+ });
601
+
520
602
  if (!existsSync(CONFIG_PATH)) {
603
+ if (values.json) emitJSON({ error: 'config-missing', path: CONFIG_PATH }, 1);
521
604
  log.err(`No config found at ${CONFIG_PATH}`);
522
605
  log.hint('Run `npx @natjswenson/devlog init` first.');
523
606
  process.exit(1);
@@ -527,26 +610,38 @@ async function cmdConfig() {
527
610
  try {
528
611
  config = readConfig();
529
612
  } catch (e) {
613
+ if (values.json) emitJSON({ error: 'config-unreadable', message: e.message, path: CONFIG_PATH }, 1);
530
614
  log.err(`Failed to read config: ${e.message}`);
531
615
  process.exit(1);
532
616
  }
533
617
 
534
- let validationStatus;
618
+ let validationError = null;
535
619
  try {
536
620
  validateConfig(config);
537
- validationStatus = kleur.green('valid');
538
621
  } catch (e) {
539
- validationStatus = kleur.red('INVALID — ' + e.message);
622
+ validationError = e.message;
623
+ }
624
+
625
+ if (values.json) {
626
+ emitJSON({
627
+ path: CONFIG_PATH,
628
+ valid: !validationError,
629
+ ...(validationError ? { validationError } : {}),
630
+ deepDive: resolveDeepDive(config),
631
+ config,
632
+ }, validationError ? 1 : 0);
540
633
  }
541
634
 
542
635
  log.info('');
543
636
  log.info(kleur.bold(`Config: ${CONFIG_PATH}`));
544
- log.info(`Status: ${validationStatus}`);
637
+ log.info(`Status: ${validationError ? kleur.red('INVALID — ' + validationError) : kleur.green('valid')}`);
545
638
  log.info(`Target repo: ${kleur.cyan(`github.com/${config.targetRepo || '?'}`)}`);
546
639
  log.info(`Branch: ${config.branch || 'main'}`);
547
640
  log.info(`Git author: ${config.gitAuthor || '?'}`);
548
641
  log.info(`GitHub user: ${config.githubUser || '?'}`);
549
642
  log.info(`Voice path: ${config.voicePath || kleur.dim('(ghostwriter if present, else bundled default)')}`);
643
+ const dd = resolveDeepDive(config);
644
+ log.info(`Deep dive: ${dd.minSources}+ sources; domains: ${dd.topicDomains.join(', ')}`);
550
645
  log.info(`Projects (${(config.projects || []).length}):`);
551
646
  for (const p of config.projects || []) {
552
647
  log.info(` ${kleur.cyan(p.key)}${p.label ? ` (${p.label})` : ''}`);
@@ -560,21 +655,7 @@ async function cmdConfig() {
560
655
 
561
656
  // ─── preview ─────────────────────────────────────────────────────────────────
562
657
  async function cmdPreview() {
563
- if (!existsSync(CONFIG_PATH)) {
564
- log.err(`No config found at ${CONFIG_PATH}`);
565
- log.hint('Run `npx @natjswenson/devlog init` first.');
566
- process.exit(1);
567
- }
568
-
569
- let config;
570
- try {
571
- config = readConfig();
572
- validateConfig(config);
573
- } catch (e) {
574
- log.err(`Config validation failed: ${e.message}`);
575
- log.hint(`Edit ${CONFIG_PATH} or run \`devlog config\` to inspect.`);
576
- process.exit(1);
577
- }
658
+ const config = readValidConfigOrExit();
578
659
 
579
660
  const [owner, repo] = config.targetRepo.split('/');
580
661
  const branch = config.branch || 'main';
@@ -619,13 +700,22 @@ function printHelp() {
619
700
  console.log(`
620
701
  ${kleur.bold('@natjswenson/devlog')} v${readPackageVersion()} — release dev log generator
621
702
 
622
- Usage:
623
- ${kleur.cyan('npx @natjswenson/devlog init')} One-time setup: create your dev-log repo, install the skill, write config
624
- ${kleur.cyan('npx @natjswenson/devlog add-project')} Register an additional project in your config
625
- ${kleur.cyan('npx @natjswenson/devlog config')} Show your current config (with validation)
626
- ${kleur.cyan('npx @natjswenson/devlog preview')} Run a local preview of your published dev log
627
- ${kleur.cyan('npx @natjswenson/devlog --help')}
628
- ${kleur.cyan('npx @natjswenson/devlog --version')}
703
+ Setup & config:
704
+ ${kleur.cyan('npx @natjswenson/devlog init')} One-time setup: create your dev-log repo, install the skill, write config
705
+ ${kleur.cyan('npx @natjswenson/devlog add-project')} Register a project (interactive; add --yes --path <p> for non-interactive)
706
+ ${kleur.cyan('npx @natjswenson/devlog remove-project <key> --yes')} Unregister a project (entries stay published)
707
+ ${kleur.cyan('npx @natjswenson/devlog set <field> <value>')} Update one config field (${SETTABLE_FIELDS.join(', ')})
708
+ ${kleur.cyan('npx @natjswenson/devlog config [--json]')} Show current config (with validation)
709
+
710
+ Used by the /devlog skill:
711
+ ${kleur.cyan('npx @natjswenson/devlog scan [--project <key>]')} JSON plan of new releases needing entries
712
+ ${kleur.cyan('npx @natjswenson/devlog lint-post <file>')} Deterministic post-contract check
713
+ ${kleur.cyan('npx @natjswenson/devlog publish-entry ...')} Copy a drafted entry into the clone + update manifest (never overwrites)
714
+
715
+ Preview:
716
+ ${kleur.cyan('npx @natjswenson/devlog preview')} Run a local preview of your published dev log
717
+
718
+ ${kleur.cyan('npx @natjswenson/devlog --help')} | ${kleur.cyan('--version')}
629
719
 
630
720
  Docs: https://github.com/natejswenson/devlog
631
721
  Issues: https://github.com/natejswenson/devlog/issues
@@ -638,15 +728,31 @@ Issues: https://github.com/natejswenson/devlog/issues
638
728
  const isMain = process.argv[1] === fileURLToPath(import.meta.url);
639
729
  if (isMain) {
640
730
  const arg = process.argv[2];
731
+ const rest = process.argv.slice(3);
641
732
  switch (arg) {
642
733
  case 'init':
643
734
  cmdInit();
644
735
  break;
645
736
  case 'add-project':
646
- cmdAddProject();
737
+ cmdAddProject(rest);
738
+ break;
739
+ case 'remove-project':
740
+ cmdRemoveProject(rest);
741
+ break;
742
+ case 'set':
743
+ cmdSet(rest);
744
+ break;
745
+ case 'scan':
746
+ cmdScan(rest);
747
+ break;
748
+ case 'lint-post':
749
+ cmdLintPost(rest);
750
+ break;
751
+ case 'publish-entry':
752
+ cmdPublishEntry(rest);
647
753
  break;
648
754
  case 'config':
649
- cmdConfig();
755
+ cmdConfig(rest);
650
756
  break;
651
757
  case 'preview':
652
758
  cmdPreview();