@natjswenson/devlog 0.1.9 → 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/CHANGELOG.md +72 -0
- package/README.md +35 -20
- package/SKILL.md +322 -71
- package/bin/devlog.js +140 -44
- package/config.example.json +9 -4
- package/examples/react/README.md +1 -1
- package/examples/react/useDevLogEntries.js +10 -6
- package/package.json +6 -3
- package/preview/demo.js +32 -25
- package/voice/voice-notes.example.md +16 -0
- package/voice/voice-profile.example.md +37 -0
package/bin/devlog.js
CHANGED
|
@@ -19,13 +19,19 @@ import kleur from 'kleur';
|
|
|
19
19
|
//
|
|
20
20
|
// For strict-token fields (project keys, repo names, branch names), separate
|
|
21
21
|
// allowlist regexes apply additional structural constraints.
|
|
22
|
-
const SHELL_QUOTE_BREAK = /[;&|`$()<>{}[\]*?!#~"'\\\n\r]/;
|
|
23
|
-
const RE_GH_USER = /^[a-z0-9][a-z0-9-]*$/i;
|
|
24
|
-
const RE_REPO_NAME = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
25
|
-
const RE_OWNER_REPO = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i;
|
|
26
|
-
const RE_PROJECT_KEY = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
27
|
-
const RE_BRANCH = /^[a-z0-9][a-z0-9._/-]*$/i;
|
|
28
|
-
|
|
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
|
|
29
35
|
|
|
30
36
|
const require = createRequire(import.meta.url);
|
|
31
37
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -34,6 +40,9 @@ const CONFIG_DIR = join(homedir(), '.claude', 'skills', 'devlog');
|
|
|
34
40
|
const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
|
|
35
41
|
const SKILL_DEST = join(CONFIG_DIR, 'SKILL.md');
|
|
36
42
|
const PREVIEW_DIR = join(PACKAGE_ROOT, 'preview');
|
|
43
|
+
const VOICE_SRC_DIR = join(PACKAGE_ROOT, 'voice');
|
|
44
|
+
const VOICE_DEST_DIR = join(CONFIG_DIR, 'voice');
|
|
45
|
+
const GHOSTWRITER_VOICE_DIR = join(homedir(), '.claude', 'skills', 'ghostwriter', 'voice');
|
|
37
46
|
|
|
38
47
|
const log = {
|
|
39
48
|
info: (msg) => console.log(msg),
|
|
@@ -69,7 +78,7 @@ function tryExecArgs(cmd, args) {
|
|
|
69
78
|
}
|
|
70
79
|
}
|
|
71
80
|
|
|
72
|
-
function expandHome(p) {
|
|
81
|
+
export function expandHome(p) {
|
|
73
82
|
if (!p) return p;
|
|
74
83
|
if (p === '~') return homedir();
|
|
75
84
|
if (p.startsWith('~/')) return join(homedir(), p.slice(2));
|
|
@@ -93,7 +102,7 @@ function atomicWriteJSON(path, data) {
|
|
|
93
102
|
}
|
|
94
103
|
|
|
95
104
|
// Validate a config object before writing. Throws with a user-facing message on failure.
|
|
96
|
-
function validateConfig(config) {
|
|
105
|
+
export function validateConfig(config) {
|
|
97
106
|
if (!config || typeof config !== 'object') throw new Error('Config must be an object');
|
|
98
107
|
const required = ['targetRepo', 'gitAuthor', 'githubUser', 'projects'];
|
|
99
108
|
for (const k of required) {
|
|
@@ -113,6 +122,18 @@ function validateConfig(config) {
|
|
|
113
122
|
throw new Error(`branch must be a valid git branch name (no leading dash, no '..'): got ${JSON.stringify(config.branch)}`);
|
|
114
123
|
}
|
|
115
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
|
+
}
|
|
116
137
|
if (!Array.isArray(config.projects)) {
|
|
117
138
|
throw new Error('projects must be an array');
|
|
118
139
|
}
|
|
@@ -130,6 +151,22 @@ function validateConfig(config) {
|
|
|
130
151
|
if (!RE_OWNER_REPO.test(p.remote)) {
|
|
131
152
|
throw new Error(`project.remote must match <owner>/<repo>: ${JSON.stringify(p.remote)}`);
|
|
132
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
|
+
}
|
|
133
170
|
if ('label' in p) {
|
|
134
171
|
// Label is rendered as React text content only — never shell-interpolated,
|
|
135
172
|
// never used in URLs, never used as a filesystem path. React escapes all
|
|
@@ -197,7 +234,7 @@ async function confirmOverwrite(label, path) {
|
|
|
197
234
|
}
|
|
198
235
|
|
|
199
236
|
// ─── prompt validators (reused across init and add-project) ──────────────────
|
|
200
|
-
const VALIDATORS = {
|
|
237
|
+
export const VALIDATORS = {
|
|
201
238
|
gitAuthor: (v) => {
|
|
202
239
|
if (v.trim().length === 0) return 'Required';
|
|
203
240
|
if (SHELL_QUOTE_BREAK.test(v)) return 'Invalid characters (no quotes, backticks, dollar signs, semicolons, parens, or shell metacharacters)';
|
|
@@ -217,6 +254,20 @@ const VALIDATORS = {
|
|
|
217
254
|
return true;
|
|
218
255
|
},
|
|
219
256
|
ownerRepo: (v) => RE_OWNER_REPO.test(v.trim()) || 'Expected <owner>/<repo>, no leading dash, alphanumeric + ._- only',
|
|
257
|
+
voicePath: (v) => {
|
|
258
|
+
// Optional. Blank means "use ghostwriter's voice dir if present, else the bundled default".
|
|
259
|
+
if (!v || v.trim() === '') return true;
|
|
260
|
+
if (SHELL_QUOTE_BREAK.test(v)) return 'Invalid characters (no quotes, backticks, dollar signs, semicolons, parens, or shell metacharacters)';
|
|
261
|
+
if (v.trim().startsWith('-')) return 'Path cannot start with a dash';
|
|
262
|
+
return existsSync(expandHome(v.trim())) || 'Path does not exist';
|
|
263
|
+
},
|
|
264
|
+
tagPrefix: (v) => {
|
|
265
|
+
// Optional. Blank/`v` is the default. Used in `git tag --list '<prefix>*'`.
|
|
266
|
+
const t = (v || '').trim();
|
|
267
|
+
if (t === '') return true;
|
|
268
|
+
if (!RE_TAG_PREFIX.test(t) || FORBIDDEN_BRANCH_PARTS.test(t)) return 'Invalid prefix (no leading dash/slash, no "..", no shell metacharacters)';
|
|
269
|
+
return true;
|
|
270
|
+
},
|
|
220
271
|
label: (v) => {
|
|
221
272
|
// Label is React text content only — apostrophes and most punctuation are fine.
|
|
222
273
|
// Reject only control chars and overlong values.
|
|
@@ -262,6 +313,13 @@ async function promptForProject(defaults = {}) {
|
|
|
262
313
|
initial: (_p, values) => detectProjectRemote(expandHome(values.path)) || initialRemote,
|
|
263
314
|
validate: VALIDATORS.ownerRepo,
|
|
264
315
|
},
|
|
316
|
+
{
|
|
317
|
+
type: 'text',
|
|
318
|
+
name: 'tagPrefix',
|
|
319
|
+
message: 'Release tag prefix (optional, e.g. "v" or "myproject-v"):',
|
|
320
|
+
initial: defaults.tagPrefix || 'v',
|
|
321
|
+
validate: VALIDATORS.tagPrefix,
|
|
322
|
+
},
|
|
265
323
|
], { onCancel: () => process.exit(1) });
|
|
266
324
|
|
|
267
325
|
const out = {
|
|
@@ -270,6 +328,9 @@ async function promptForProject(defaults = {}) {
|
|
|
270
328
|
remote: answers.remote.trim(),
|
|
271
329
|
};
|
|
272
330
|
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
|
+
const tagPrefix = (answers.tagPrefix || '').trim();
|
|
333
|
+
if (tagPrefix && tagPrefix !== 'v') out.tagPrefix = tagPrefix;
|
|
273
334
|
return out;
|
|
274
335
|
}
|
|
275
336
|
|
|
@@ -282,12 +343,16 @@ async function cmdInit() {
|
|
|
282
343
|
gitAuthor: detectGitName() || '',
|
|
283
344
|
githubUser: detectGhUser() || '',
|
|
284
345
|
targetRepoName: 'daily-dev-log',
|
|
346
|
+
// Pre-fill the voice path with ghostwriter's voice dir if it's installed — that's
|
|
347
|
+
// the most likely place a user already keeps their voice profile.
|
|
348
|
+
voicePath: existsSync(GHOSTWRITER_VOICE_DIR) ? GHOSTWRITER_VOICE_DIR : '',
|
|
285
349
|
};
|
|
286
350
|
|
|
287
351
|
const answers = await prompts([
|
|
288
|
-
{ type: 'text', name: 'gitAuthor', message: 'Your name (
|
|
352
|
+
{ type: 'text', name: 'gitAuthor', message: 'Your name (retained for backward compatibility; not currently rendered on entries):', initial: defaults.gitAuthor, validate: VALIDATORS.gitAuthor },
|
|
289
353
|
{ type: 'text', name: 'githubUser', message: 'Your GitHub username:', initial: defaults.githubUser, validate: VALIDATORS.githubUser },
|
|
290
354
|
{ type: 'text', name: 'targetRepoName', message: 'Name of the repo where dev logs will be published:', initial: defaults.targetRepoName, validate: VALIDATORS.targetRepoName },
|
|
355
|
+
{ type: 'text', name: 'voicePath', message: 'Voice profile directory (optional — blank uses ghostwriter\'s if present, else the bundled default):', initial: defaults.voicePath, validate: VALIDATORS.voicePath },
|
|
291
356
|
], { onCancel: () => process.exit(1) });
|
|
292
357
|
|
|
293
358
|
// Optionally register projects in a loop. First time defaults to "yes".
|
|
@@ -313,11 +378,16 @@ async function cmdInit() {
|
|
|
313
378
|
}
|
|
314
379
|
|
|
315
380
|
const targetRepo = `${answers.githubUser}/${answers.targetRepoName}`;
|
|
381
|
+
// Store the expanded absolute path (consistent with project.path) so the persisted
|
|
382
|
+
// config never carries a `~` that would later trip the shell-quote-break check.
|
|
383
|
+
const rawVoicePath = (answers.voicePath || '').trim();
|
|
384
|
+
const voicePath = rawVoicePath ? expandHome(rawVoicePath) : '';
|
|
316
385
|
const config = validateConfig({
|
|
317
386
|
targetRepo,
|
|
318
387
|
branch: 'main',
|
|
319
388
|
gitAuthor: answers.gitAuthor,
|
|
320
389
|
githubUser: answers.githubUser,
|
|
390
|
+
...(voicePath ? { voicePath } : {}),
|
|
321
391
|
projects,
|
|
322
392
|
});
|
|
323
393
|
|
|
@@ -334,6 +404,7 @@ async function cmdInit() {
|
|
|
334
404
|
log.info(` Git author: ${config.gitAuthor}`);
|
|
335
405
|
log.info(` GitHub user: ${config.githubUser}`);
|
|
336
406
|
log.info(` Branch: ${config.branch}`);
|
|
407
|
+
log.info(` Voice profile: ${config.voicePath || '(ghostwriter if present, else bundled default)'}`);
|
|
337
408
|
log.info(` Projects: ${config.projects.length === 0 ? '(none — add later with `devlog add-project`)' : config.projects.map((p) => p.key).join(', ')}`);
|
|
338
409
|
log.info(` Skill location: ${CONFIG_DIR}`);
|
|
339
410
|
|
|
@@ -347,7 +418,7 @@ async function cmdInit() {
|
|
|
347
418
|
log.warn(`Repo github.com/${targetRepo} already exists. Will use it as-is.`);
|
|
348
419
|
} else {
|
|
349
420
|
log.step(`Creating github.com/${targetRepo}...`);
|
|
350
|
-
const r = spawnSync('gh', ['repo', 'create', targetRepo, '--public', '--description', '
|
|
421
|
+
const r = spawnSync('gh', ['repo', 'create', targetRepo, '--public', '--description', 'Release dev log', '--add-readme'], { stdio: 'inherit' });
|
|
351
422
|
if (r.status !== 0) {
|
|
352
423
|
log.err('Failed to create repo. Check `gh` permissions.');
|
|
353
424
|
process.exit(1);
|
|
@@ -374,16 +445,33 @@ async function cmdInit() {
|
|
|
374
445
|
log.warn('Skipped config.json');
|
|
375
446
|
}
|
|
376
447
|
|
|
448
|
+
// Install the bundled voice template as the fallback voice profile. The skill
|
|
449
|
+
// resolves voice in this order: config.voicePath → ghostwriter's voice dir →
|
|
450
|
+
// this bundled copy. Installing it guarantees the last fallback always exists.
|
|
451
|
+
if (!existsSync(VOICE_DEST_DIR)) {
|
|
452
|
+
mkdirSync(VOICE_DEST_DIR, { recursive: true, mode: 0o700 });
|
|
453
|
+
}
|
|
454
|
+
for (const [src, dest] of [['voice-profile.example.md', 'voice-profile.md'], ['voice-notes.example.md', 'voice-notes.md']]) {
|
|
455
|
+
const s = join(VOICE_SRC_DIR, src);
|
|
456
|
+
const d = join(VOICE_DEST_DIR, dest);
|
|
457
|
+
if (existsSync(s) && (await confirmOverwrite(`voice/${dest}`, d))) {
|
|
458
|
+
copyFileSync(s, d);
|
|
459
|
+
log.ok(`Installed voice/${dest} → ${d}`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
377
463
|
log.info('\n' + kleur.bold().green('Setup complete.') + '\n');
|
|
378
464
|
log.info('Next steps:');
|
|
379
465
|
if (config.projects.length === 0) {
|
|
380
466
|
log.info(` 1. Add a project: ${kleur.cyan('npx @natjswenson/devlog add-project')}`);
|
|
381
|
-
log.info(' 2.
|
|
467
|
+
log.info(' 2. Tag a release in the project (e.g. `git tag v0.1.0`)');
|
|
468
|
+
log.info(` 3. In Claude Code, run: ${kleur.cyan('/devlog')}`);
|
|
469
|
+
log.info(` 4. Preview locally: ${kleur.cyan('npx @natjswenson/devlog preview')}`);
|
|
382
470
|
} else {
|
|
383
|
-
log.info(' 1.
|
|
471
|
+
log.info(' 1. Tag a release in a registered project (e.g. `git tag v0.1.0`)');
|
|
472
|
+
log.info(` 2. In Claude Code, run: ${kleur.cyan('/devlog')}`);
|
|
473
|
+
log.info(` 3. Preview locally: ${kleur.cyan('npx @natjswenson/devlog preview')}`);
|
|
384
474
|
}
|
|
385
|
-
log.info(` 2. In Claude Code, run: ${kleur.cyan('/devlog')}`);
|
|
386
|
-
log.info(` 3. Preview locally: ${kleur.cyan('npx @natjswenson/devlog preview')}`);
|
|
387
475
|
log.info('');
|
|
388
476
|
}
|
|
389
477
|
|
|
@@ -458,11 +546,14 @@ async function cmdConfig() {
|
|
|
458
546
|
log.info(`Branch: ${config.branch || 'main'}`);
|
|
459
547
|
log.info(`Git author: ${config.gitAuthor || '?'}`);
|
|
460
548
|
log.info(`GitHub user: ${config.githubUser || '?'}`);
|
|
549
|
+
log.info(`Voice path: ${config.voicePath || kleur.dim('(ghostwriter if present, else bundled default)')}`);
|
|
461
550
|
log.info(`Projects (${(config.projects || []).length}):`);
|
|
462
551
|
for (const p of config.projects || []) {
|
|
463
552
|
log.info(` ${kleur.cyan(p.key)}${p.label ? ` (${p.label})` : ''}`);
|
|
464
553
|
log.info(kleur.dim(` path: ${p.path}`));
|
|
465
554
|
log.info(kleur.dim(` remote: github.com/${p.remote}`));
|
|
555
|
+
if (p.pathFilter) log.info(kleur.dim(` scope: ${p.pathFilter}/`));
|
|
556
|
+
log.info(kleur.dim(` tags: ${p.tagPrefix || 'v'}*`));
|
|
466
557
|
}
|
|
467
558
|
log.info('');
|
|
468
559
|
}
|
|
@@ -526,7 +617,7 @@ async function cmdPreview() {
|
|
|
526
617
|
// ─── help ────────────────────────────────────────────────────────────────────
|
|
527
618
|
function printHelp() {
|
|
528
619
|
console.log(`
|
|
529
|
-
${kleur.bold('@natjswenson/devlog')} v${readPackageVersion()} —
|
|
620
|
+
${kleur.bold('@natjswenson/devlog')} v${readPackageVersion()} — release dev log generator
|
|
530
621
|
|
|
531
622
|
Usage:
|
|
532
623
|
${kleur.cyan('npx @natjswenson/devlog init')} One-time setup: create your dev-log repo, install the skill, write config
|
|
@@ -542,31 +633,36 @@ Issues: https://github.com/natejswenson/devlog/issues
|
|
|
542
633
|
}
|
|
543
634
|
|
|
544
635
|
// ─── dispatch ────────────────────────────────────────────────────────────────
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
636
|
+
// Only run the CLI dispatch when this file is executed directly, not when it is
|
|
637
|
+
// imported (e.g. by the test suite). Importing the module must have no side effects.
|
|
638
|
+
const isMain = process.argv[1] === fileURLToPath(import.meta.url);
|
|
639
|
+
if (isMain) {
|
|
640
|
+
const arg = process.argv[2];
|
|
641
|
+
switch (arg) {
|
|
642
|
+
case 'init':
|
|
643
|
+
cmdInit();
|
|
644
|
+
break;
|
|
645
|
+
case 'add-project':
|
|
646
|
+
cmdAddProject();
|
|
647
|
+
break;
|
|
648
|
+
case 'config':
|
|
649
|
+
cmdConfig();
|
|
650
|
+
break;
|
|
651
|
+
case 'preview':
|
|
652
|
+
cmdPreview();
|
|
653
|
+
break;
|
|
654
|
+
case '-v':
|
|
655
|
+
case '--version':
|
|
656
|
+
console.log(readPackageVersion());
|
|
657
|
+
break;
|
|
658
|
+
case undefined:
|
|
659
|
+
case '-h':
|
|
660
|
+
case '--help':
|
|
661
|
+
printHelp();
|
|
662
|
+
break;
|
|
663
|
+
default:
|
|
664
|
+
log.err(`Unknown command: ${arg}`);
|
|
665
|
+
printHelp();
|
|
666
|
+
process.exit(1);
|
|
667
|
+
}
|
|
572
668
|
}
|
package/config.example.json
CHANGED
|
@@ -3,17 +3,22 @@
|
|
|
3
3
|
"branch": "main",
|
|
4
4
|
"gitAuthor": "Your Name",
|
|
5
5
|
"githubUser": "yourusername",
|
|
6
|
+
"voicePath": "~/.claude/skills/ghostwriter/voice",
|
|
6
7
|
"projects": [
|
|
7
8
|
{
|
|
8
9
|
"key": "midnight-side-quest",
|
|
9
10
|
"label": "Midnight Side Quest",
|
|
10
11
|
"path": "/Users/yourusername/code/midnight-side-quest",
|
|
11
|
-
"remote": "yourusername/midnight-side-quest"
|
|
12
|
+
"remote": "yourusername/midnight-side-quest",
|
|
13
|
+
"tagPrefix": "v"
|
|
12
14
|
},
|
|
13
15
|
{
|
|
14
|
-
"key": "
|
|
15
|
-
"
|
|
16
|
-
"
|
|
16
|
+
"key": "devlog",
|
|
17
|
+
"label": "Devlog",
|
|
18
|
+
"path": "/Users/yourusername/code/your-monorepo",
|
|
19
|
+
"remote": "yourusername/your-monorepo",
|
|
20
|
+
"pathFilter": "skills/devlog",
|
|
21
|
+
"tagPrefix": "devlog-v"
|
|
17
22
|
}
|
|
18
23
|
]
|
|
19
24
|
}
|
package/examples/react/README.md
CHANGED
|
@@ -3,11 +3,12 @@ import { DEVLOG_CONFIG as DEFAULT_CONFIG } from './devlog-config.js';
|
|
|
3
3
|
|
|
4
4
|
// Allowlist of frontmatter keys we recognize. Anything else is ignored —
|
|
5
5
|
// prevents prototype-pollution via crafted keys like `__proto__`.
|
|
6
|
-
|
|
6
|
+
// `version` is the release tag this entry corresponds to (e.g. "v0.2.0").
|
|
7
|
+
const FRONTMATTER_KEYS = new Set(['title', 'date', 'project', 'summary', 'version']);
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Parse YAML-ish frontmatter from a markdown string.
|
|
10
|
-
* Returns { metadata: { title, date, project, summary }, body: string }
|
|
11
|
+
* Returns { metadata: { title, date, project, summary, version }, body: string }
|
|
11
12
|
*/
|
|
12
13
|
function parseFrontmatter(raw) {
|
|
13
14
|
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
@@ -24,19 +25,22 @@ function parseFrontmatter(raw) {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
// Schema validation for fetched manifest. Reject anything that isn't shaped
|
|
27
|
-
// like { entries: [{ date, file, title, summary }, ...] } so a hostile
|
|
28
|
-
// to the dev-log repo can't crash the page.
|
|
28
|
+
// like { entries: [{ date, file, title, summary, version? }, ...] } so a hostile
|
|
29
|
+
// commit to the dev-log repo can't crash the page. `version` is optional and
|
|
30
|
+
// only kept when it's a clean tag-ish string.
|
|
29
31
|
function validateManifest(data) {
|
|
30
32
|
if (!data || typeof data !== 'object') return null;
|
|
31
33
|
if (!Array.isArray(data.entries)) return null;
|
|
32
34
|
const entries = [];
|
|
33
35
|
for (const e of data.entries) {
|
|
34
36
|
if (!e || typeof e !== 'object') continue;
|
|
35
|
-
const { date, file, title, summary } = e;
|
|
37
|
+
const { date, file, title, summary, version } = e;
|
|
36
38
|
if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
|
|
37
39
|
if (typeof file !== 'string' || !/^[a-zA-Z0-9._-]+\.md$/.test(file)) continue;
|
|
38
40
|
if (typeof title !== 'string' || typeof summary !== 'string') continue;
|
|
39
|
-
|
|
41
|
+
const entry = { date, file, title, summary };
|
|
42
|
+
if (typeof version === 'string' && /^[a-zA-Z0-9._-]+$/.test(version)) entry.version = version;
|
|
43
|
+
entries.push(entry);
|
|
40
44
|
}
|
|
41
45
|
return { entries };
|
|
42
46
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@natjswenson/devlog",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Release dev log generator \u2014 Claude Code skill + preview app for publishing version-release dev logs, written in your voice, to your site",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Nate Swenson",
|
|
7
7
|
"homepage": "https://github.com/natejswenson/devlog",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"claude-skill",
|
|
19
19
|
"build-in-public",
|
|
20
20
|
"git",
|
|
21
|
+
"release-notes",
|
|
21
22
|
"blog"
|
|
22
23
|
],
|
|
23
24
|
"type": "module",
|
|
@@ -28,6 +29,7 @@
|
|
|
28
29
|
"bin/",
|
|
29
30
|
"preview/",
|
|
30
31
|
"examples/",
|
|
32
|
+
"voice/",
|
|
31
33
|
"SKILL.md",
|
|
32
34
|
"SECURITY.md",
|
|
33
35
|
"CHANGELOG.md",
|
|
@@ -39,6 +41,7 @@
|
|
|
39
41
|
"node": ">=18"
|
|
40
42
|
},
|
|
41
43
|
"scripts": {
|
|
44
|
+
"test": "node --test \"tests/**/*.test.mjs\"",
|
|
42
45
|
"audit": "npm audit --audit-level=moderate"
|
|
43
46
|
},
|
|
44
47
|
"dependencies": {
|
|
@@ -49,6 +52,6 @@
|
|
|
49
52
|
"react-dom": "18.3.1",
|
|
50
53
|
"react-markdown": "9.1.0",
|
|
51
54
|
"remark-gfm": "4.0.1",
|
|
52
|
-
"vite": "8.0.
|
|
55
|
+
"vite": "8.0.16"
|
|
53
56
|
}
|
|
54
57
|
}
|
package/preview/demo.js
CHANGED
|
@@ -25,31 +25,33 @@ function offsetDate(daysAgo) {
|
|
|
25
25
|
|
|
26
26
|
const ENTRIES = [
|
|
27
27
|
{
|
|
28
|
+
version: 'v0.4.0',
|
|
28
29
|
date: offsetDate(0),
|
|
29
30
|
title: 'You are looking at fake data',
|
|
30
|
-
summary: "Hi. These aren't your
|
|
31
|
-
body: `## What
|
|
31
|
+
summary: "Hi. These aren't your releases. Your releases are 30 seconds away.",
|
|
32
|
+
body: `## What Shipped
|
|
32
33
|
|
|
33
34
|
Nothing. I'm a placeholder. A handsome one, but still a placeholder.
|
|
34
35
|
|
|
35
36
|
You're seeing this screen because the preview app couldn't find env vars
|
|
36
37
|
pointing at your dev-log repo. Once that's fixed, this entire feed gets
|
|
37
38
|
replaced with real entries, generated by the \`/devlog\` skill from your
|
|
38
|
-
actual
|
|
39
|
+
actual version releases — written in your voice.
|
|
39
40
|
|
|
40
41
|
## What's Next
|
|
41
42
|
|
|
42
43
|
You. Setting things up. Probably while half-watching a YouTube tutorial
|
|
43
44
|
about something unrelated. We believe in you.
|
|
44
45
|
|
|
45
|
-
##
|
|
46
|
+
## Commits
|
|
46
47
|
|
|
47
|
-
-
|
|
48
|
-
-
|
|
48
|
+
- make placeholder more passive-aggressive ([abcd123](#))
|
|
49
|
+
- add a tiny bit of charm ([def4567](#))
|
|
49
50
|
`,
|
|
50
51
|
},
|
|
51
52
|
{
|
|
52
|
-
|
|
53
|
+
version: 'v0.3.0',
|
|
54
|
+
date: offsetDate(2),
|
|
53
55
|
title: 'How to make this screen go away',
|
|
54
56
|
summary: 'Two paths: the lazy one (recommended) and the manual one (also fine).',
|
|
55
57
|
body: `## The lazy path
|
|
@@ -58,7 +60,7 @@ about something unrelated. We believe in you.
|
|
|
58
60
|
npx @natjswenson/devlog init
|
|
59
61
|
\`\`\`
|
|
60
62
|
|
|
61
|
-
Answer
|
|
63
|
+
Answer a few prompts. The CLI creates your dev-log repo on GitHub, installs
|
|
62
64
|
the Claude Code skill, and writes your config. Run \`npx @natjswenson/devlog preview\`
|
|
63
65
|
again. This entry vanishes. You feel powerful.
|
|
64
66
|
|
|
@@ -75,18 +77,20 @@ VITE_DEVLOG_PROJECTS=[{"key":"myproject","label":"My Project"}]
|
|
|
75
77
|
|
|
76
78
|
## What's Next
|
|
77
79
|
|
|
78
|
-
The real preview, with your real
|
|
80
|
+
The real preview, with your real releases. Try it.
|
|
79
81
|
`,
|
|
80
82
|
},
|
|
81
83
|
{
|
|
82
|
-
|
|
84
|
+
version: 'v0.2.0',
|
|
85
|
+
date: offsetDate(5),
|
|
83
86
|
title: "Why you'd actually want this",
|
|
84
|
-
summary: 'Build in public,
|
|
85
|
-
body: `## What
|
|
87
|
+
summary: 'Build in public, by release — with style, and without remembering to write blog posts.',
|
|
88
|
+
body: `## What Shipped
|
|
86
89
|
|
|
87
|
-
The whole point: you
|
|
88
|
-
The skill
|
|
89
|
-
fix typo again, ok actually fix it" but real
|
|
90
|
+
The whole point: you tag a release as usual. You run \`/devlog\` in Claude Code.
|
|
91
|
+
The skill finds tags that don't have an entry yet and writes a *narrative*
|
|
92
|
+
release note — not "fix typo, fix typo again, ok actually fix it" but real
|
|
93
|
+
prose about what shipped and why, in your voice.
|
|
90
94
|
|
|
91
95
|
That entry gets pushed to your dev-log repo. Your site (or this preview app,
|
|
92
96
|
deployed to Vercel/Netlify/Cloudflare) renders it.
|
|
@@ -95,29 +99,30 @@ The result: you ship in public without ever opening a blog post editor.
|
|
|
95
99
|
|
|
96
100
|
## What's Next
|
|
97
101
|
|
|
98
|
-
You'll set this up. You'll
|
|
102
|
+
You'll set this up. You'll tag a release on day one. You'll feel slightly
|
|
99
103
|
smug about it on the train tomorrow. We're rooting for you.
|
|
100
104
|
|
|
101
|
-
##
|
|
105
|
+
## Commits
|
|
102
106
|
|
|
103
|
-
-
|
|
107
|
+
- write hopeful pep talk ([eeee101](#))
|
|
104
108
|
`,
|
|
105
109
|
},
|
|
106
110
|
{
|
|
107
|
-
|
|
111
|
+
version: 'v0.1.0',
|
|
112
|
+
date: offsetDate(9),
|
|
108
113
|
title: "Things this is not",
|
|
109
114
|
summary: 'A short list, for the avoidance of disappointment.',
|
|
110
115
|
body: `## Not features
|
|
111
116
|
|
|
112
117
|
- A blog CMS. There are sixty of those. Use one if you want one.
|
|
113
118
|
- A social network. Please don't.
|
|
114
|
-
-
|
|
115
|
-
|
|
119
|
+
- A marketing-copy generator. The narratives come from *your* releases, in
|
|
120
|
+
*your* voice. Garbage in, garbage out.
|
|
116
121
|
- A way to make your past coding choices look better in retrospect. Sorry.
|
|
117
122
|
|
|
118
123
|
## Is features
|
|
119
124
|
|
|
120
|
-
- A way to ship
|
|
125
|
+
- A way to ship release notes without context-switching out of Claude Code.
|
|
121
126
|
- A static, no-backend pipeline (manifest.json + markdown on GitHub).
|
|
122
127
|
- Components you can drop into your own React site, or the preview app you
|
|
123
128
|
can deploy as a standalone dev log.
|
|
@@ -132,9 +137,10 @@ Replace this fake entry with a real one. It's right there. Just go.
|
|
|
132
137
|
const MANIFEST = {
|
|
133
138
|
entries: ENTRIES.map((e) => ({
|
|
134
139
|
date: e.date,
|
|
135
|
-
file: `${e.
|
|
140
|
+
file: `${e.version}.md`,
|
|
136
141
|
title: e.title,
|
|
137
142
|
summary: e.summary,
|
|
143
|
+
version: e.version,
|
|
138
144
|
})),
|
|
139
145
|
};
|
|
140
146
|
|
|
@@ -143,6 +149,7 @@ function entryMarkdown(entry) {
|
|
|
143
149
|
title: "${entry.title.replace(/"/g, '\\"')}"
|
|
144
150
|
date: ${entry.date}
|
|
145
151
|
project: ${DEMO_PROJECT_KEY}
|
|
152
|
+
version: ${entry.version}
|
|
146
153
|
summary: "${entry.summary.replace(/"/g, '\\"')}"
|
|
147
154
|
---
|
|
148
155
|
|
|
@@ -156,9 +163,9 @@ function demoResponse(url) {
|
|
|
156
163
|
headers: { 'content-type': 'application/json' },
|
|
157
164
|
});
|
|
158
165
|
}
|
|
159
|
-
const m = url.match(
|
|
166
|
+
const m = url.match(/\/([a-zA-Z0-9._-]+)\.md$/);
|
|
160
167
|
if (m) {
|
|
161
|
-
const entry = ENTRIES.find((e) => e.
|
|
168
|
+
const entry = ENTRIES.find((e) => e.version === m[1]);
|
|
162
169
|
if (entry) {
|
|
163
170
|
return new Response(entryMarkdown(entry), {
|
|
164
171
|
status: 200,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Voice notes — recent corrections
|
|
2
|
+
|
|
3
|
+
This file overrides `voice-profile.md` wherever they conflict. Put your most recent,
|
|
4
|
+
explicit corrections here — the things you keep having to fix in generated entries. Keep
|
|
5
|
+
it short and specific; it is read every time an entry is generated.
|
|
6
|
+
|
|
7
|
+
## Defaults (safe to keep)
|
|
8
|
+
- Write for the reader, not as a diary. Lead with the change and its impact, not "I".
|
|
9
|
+
- No em dashes; use a comma or semicolon.
|
|
10
|
+
- No tacked-on punchy filler lines and no rhetorical fragment lists for rhythm.
|
|
11
|
+
- Never invent metrics, motivations, or outcomes the commits don't support.
|
|
12
|
+
- A release entry is a release note, not a changelog dump — group commits into the
|
|
13
|
+
handful of changes that actually matter and explain why.
|
|
14
|
+
|
|
15
|
+
## Your corrections
|
|
16
|
+
(Append your own as they come up.)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Voice profile — (your name)
|
|
2
|
+
|
|
3
|
+
This is the fallback voice profile devlog uses when no other profile is found. It is
|
|
4
|
+
generic. Replace it with your own — or, better, point `voicePath` in `config.json` at a
|
|
5
|
+
richer profile you already maintain (e.g. ghostwriter's `voice/`). **`voice-notes.md` in
|
|
6
|
+
the same directory overrides this file wherever they conflict.**
|
|
7
|
+
|
|
8
|
+
devlog reads only `voice-profile.md` and `voice-notes.md`. It never reads `algorithm.md`
|
|
9
|
+
(LinkedIn reach tuning) — a dev log is not a LinkedIn feed, so reach rules do not apply.
|
|
10
|
+
|
|
11
|
+
## Voice & tone
|
|
12
|
+
Warm, practical, honest. A builder writing release notes for people who follow along — no
|
|
13
|
+
hype, no doom, no marketing gloss. Explain what shipped and why it matters in plain terms.
|
|
14
|
+
|
|
15
|
+
## Sentence rhythm & structure
|
|
16
|
+
- Short sentences, generous white space, one idea per line or per tiny paragraph.
|
|
17
|
+
- Lead with the change, then the reason. Build to a crisp takeaway.
|
|
18
|
+
- A short bullet list is fine to enumerate "what changed."
|
|
19
|
+
|
|
20
|
+
## Openers (how to start a release entry)
|
|
21
|
+
- A sharp statement of what shipped: "v0.3.0 makes the log release-driven."
|
|
22
|
+
- A short framing of the problem the release solves.
|
|
23
|
+
|
|
24
|
+
## Closers
|
|
25
|
+
- A reframe or a genuine forward-looking line about what's next. Not a forced question.
|
|
26
|
+
|
|
27
|
+
## Vocabulary & tics
|
|
28
|
+
- Plain, modern, conversational; contractions; no corporate jargon or buzzwords.
|
|
29
|
+
- Name real things: features, files, versions. Specifics over abstractions.
|
|
30
|
+
|
|
31
|
+
## Emoji & hashtags
|
|
32
|
+
- Emoji: sparing or none. Hashtags: none.
|
|
33
|
+
|
|
34
|
+
## Never do
|
|
35
|
+
- No hype words ("game-changer", "revolutionary"), no doom, no cynicism.
|
|
36
|
+
- No corporate jargon, no fake humility, no fabricated metrics or motivations.
|
|
37
|
+
- Don't pad. If a line isn't carrying weight, cut it.
|