@natjswenson/devlog 0.4.2 → 0.5.1
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/SKILL.md +223 -405
- package/bin/devlog.js +319 -203
- package/config.example.json +4 -0
- package/evals/budget.mjs +60 -0
- package/evals/fixtures/bad-post.md +33 -0
- package/evals/fixtures/good-post.md +118 -0
- package/evals/fixtures/irreproducible-post.md +71 -0
- package/evals/judge_post.mjs +113 -0
- package/evals/run_eval.mjs +95 -0
- package/lib/config_ops.mjs +58 -0
- package/lib/core.mjs +206 -0
- package/lib/lint_post.mjs +152 -0
- package/lib/publish_entry.mjs +71 -0
- package/lib/scan.mjs +229 -0
- package/package.json +4 -1
- package/skill-invariants.json +66 -0
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,
|
|
4
|
-
import { homedir, tmpdir } from 'node:os';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, copyFileSync, realpathSync } 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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
99
|
+
config = readConfig();
|
|
100
|
+
validateConfig(config);
|
|
98
101
|
} catch (e) {
|
|
99
|
-
|
|
100
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
505
|
-
|
|
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
|
|
618
|
+
let validationError = null;
|
|
535
619
|
try {
|
|
536
620
|
validateConfig(config);
|
|
537
|
-
validationStatus = kleur.green('valid');
|
|
538
621
|
} catch (e) {
|
|
539
|
-
|
|
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: ${
|
|
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
|
-
|
|
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
|
-
|
|
623
|
-
${kleur.cyan('npx @natjswenson/devlog init')}
|
|
624
|
-
${kleur.cyan('npx @natjswenson/devlog add-project')}
|
|
625
|
-
${kleur.cyan('npx @natjswenson/devlog
|
|
626
|
-
${kleur.cyan('npx @natjswenson/devlog
|
|
627
|
-
${kleur.cyan('npx @natjswenson/devlog --
|
|
628
|
-
|
|
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
|
|
@@ -635,18 +725,44 @@ Issues: https://github.com/natejswenson/devlog/issues
|
|
|
635
725
|
// ─── dispatch ────────────────────────────────────────────────────────────────
|
|
636
726
|
// Only run the CLI dispatch when this file is executed directly, not when it is
|
|
637
727
|
// imported (e.g. by the test suite). Importing the module must have no side effects.
|
|
638
|
-
|
|
728
|
+
// Both sides are realpath'd: under npm/npx, argv[1] is the node_modules/.bin/devlog
|
|
729
|
+
// SYMLINK while import.meta.url is the resolved file — a naive === never matches
|
|
730
|
+
// and every npx invocation becomes a silent no-op.
|
|
731
|
+
const isMain = (() => {
|
|
732
|
+
if (!process.argv[1]) return false;
|
|
733
|
+
try {
|
|
734
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
735
|
+
} catch {
|
|
736
|
+
return false;
|
|
737
|
+
}
|
|
738
|
+
})();
|
|
639
739
|
if (isMain) {
|
|
640
740
|
const arg = process.argv[2];
|
|
741
|
+
const rest = process.argv.slice(3);
|
|
641
742
|
switch (arg) {
|
|
642
743
|
case 'init':
|
|
643
744
|
cmdInit();
|
|
644
745
|
break;
|
|
645
746
|
case 'add-project':
|
|
646
|
-
cmdAddProject();
|
|
747
|
+
cmdAddProject(rest);
|
|
748
|
+
break;
|
|
749
|
+
case 'remove-project':
|
|
750
|
+
cmdRemoveProject(rest);
|
|
751
|
+
break;
|
|
752
|
+
case 'set':
|
|
753
|
+
cmdSet(rest);
|
|
754
|
+
break;
|
|
755
|
+
case 'scan':
|
|
756
|
+
cmdScan(rest);
|
|
757
|
+
break;
|
|
758
|
+
case 'lint-post':
|
|
759
|
+
cmdLintPost(rest);
|
|
760
|
+
break;
|
|
761
|
+
case 'publish-entry':
|
|
762
|
+
cmdPublishEntry(rest);
|
|
647
763
|
break;
|
|
648
764
|
case 'config':
|
|
649
|
-
cmdConfig();
|
|
765
|
+
cmdConfig(rest);
|
|
650
766
|
break;
|
|
651
767
|
case 'preview':
|
|
652
768
|
cmdPreview();
|