@friedbotstudio/create-baseline 0.4.0 → 0.6.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/README.md CHANGED
@@ -41,7 +41,7 @@ A discipline layer for Claude Code. Hooks at every tool boundary, a workflow tha
41
41
 
42
42
  ## What this is
43
43
 
44
- The Claude Code Baseline is a repository overlay shipped via `npx @friedbotstudio/create-baseline ./target`. It installs **22 hooks** at Claude's tool boundaries, **37 skills** organised into nine categories, **1 subagent** for parallel work in isolated worktrees, an **11-phase workflow** from intake to commit, and **3 user-typed consent gates** that Claude cannot forge.
44
+ The Claude Code Baseline is a repository overlay shipped via `npx @friedbotstudio/create-baseline ./target`. It installs **22 hooks** at Claude's tool boundaries, **38 skills** organised into ten categories, **1 subagent** for parallel work in isolated worktrees, an **11-phase workflow** from intake to commit, and **3 user-typed consent gates** that Claude cannot forge.
45
45
 
46
46
  Every soft engineering rule a team usually repeats every session — *don't push, don't `--amend`, don't self-approve specs, don't skip phases, don't mock internal modules* — becomes a structural guarantee because the hooks run **outside Claude's tool boundary**. Claude cannot disable a hook with a flag, cannot write a consent marker, cannot reorder the phases without an explicit exception that triage records on disk.
47
47
 
package/bin/cli.js CHANGED
@@ -7,7 +7,7 @@ import { existsSync } from 'node:fs';
7
7
 
8
8
  import * as io from '../src/cli/io.js';
9
9
  import { scanSentinels } from '../src/cli/conflict.js';
10
- import { freshInstall, forceInstall } from '../src/cli/install.js';
10
+ import { freshInstall, forceInstall, COPY_EXCLUDE } from '../src/cli/install.js';
11
11
  import { threeWayMerge } from '../src/cli/merge.js';
12
12
  import { loadManifest, buildManifestFromDir } from '../src/cli/manifest.js';
13
13
  import { fetchPlantumlIfMissing, FETCH_OUTCOMES } from '../src/cli/plantuml.js';
@@ -18,7 +18,7 @@ const PKG_ROOT = resolve(__dirname, '..');
18
18
 
19
19
  const HELP_TEXT = `Usage:
20
20
  create-baseline <target> [options] install the baseline
21
- create-baseline upgrade [target] three-way merge against an installed baseline
21
+ create-baseline upgrade [target] three-tier merge (mechanical / semantic / binary-prompt)
22
22
  create-baseline doctor [target] report drift in an installed target
23
23
 
24
24
  Materializes the Claude Code baseline (.claude/, CLAUDE.md, .mcp.json,
@@ -31,10 +31,15 @@ Install modes:
31
31
 
32
32
  Upgrade:
33
33
  Replaces the prior --merge flag. Reads <target>/.claude/.baseline-manifest.json
34
- and runs a three-way merge against the shipped template. Prunes baseline files
35
- removed upstream that the user hadn't touched; customized-stale files are
36
- preserved (exit 3) or interactively resolved when stdout is a TTY (keep
37
- mine / take theirs / abort).
34
+ and runs a three-tier merge against the shipped template:
35
+ - tier 1 (binary prompt): customized files prompt "Keep your version / Use
36
+ new baseline / Show diff" in TTY mode (exit 3 on any skipped).
37
+ - tier 2 (mechanical): files routed through git merge-file --diff3 with
38
+ BASE recovered from .claude/.baseline-prior/ cache or npm fallback;
39
+ clean merges land silently, conflicts surface with markers (exit 4).
40
+ - tier 3 (semantic): staged at .claude/state/upgrade/<ts>/ for the
41
+ /upgrade-project Claude Code skill to reconcile (exit 5).
42
+ Prunes baseline files removed upstream that the user hadn't touched.
38
43
  --dry-run Print intended actions without writing.
39
44
 
40
45
  Doctor:
@@ -100,7 +105,13 @@ function getTemplateDir() {
100
105
  async function listShippedFiles(templateDir) {
101
106
  const out = [];
102
107
  await walkFiles(templateDir, templateDir, out);
103
- return out;
108
+ // COPY_EXCLUDE was used to keep the legacy `manifest.json` at the template
109
+ // root from being copied to the consumer target root. The manifest now
110
+ // ships at `.claude/manifest.json` so no path-level exclusion is needed;
111
+ // COPY_EXCLUDE is currently empty but the filter stays so future entries
112
+ // (e.g., dev-only artifacts that accidentally leak into obj/template) can
113
+ // be added in one place. See src/cli/install.js → COPY_EXCLUDE.
114
+ return out.filter((p) => !COPY_EXCLUDE.includes(p));
104
115
  }
105
116
 
106
117
  async function walkFiles(dir, base, acc) {
@@ -111,6 +122,34 @@ async function walkFiles(dir, base, acc) {
111
122
  }
112
123
  }
113
124
 
125
+ // Renders a usage error and the canonical HELP_TEXT through the branded TUI
126
+ // when stderr is a TTY, falling back to plain `Error: <msg>` + help body
127
+ // otherwise. Every non-success exit from `main()` flows through here so the
128
+ // user always sees usage guidance alongside the failure.
129
+ async function usageError(msg) {
130
+ const version = await readPackageVersion();
131
+ const meta = await import('../src/cli/tui/meta.js');
132
+ meta.renderUsageError(msg, HELP_TEXT, version);
133
+ }
134
+
135
+ // Translates Node's parseArgs error noise into a short, branded message.
136
+ // Node emits e.g. `Unknown option '--upgrade'. To specify a positional ...`
137
+ // which leaks library implementation detail; we collapse it and, where we
138
+ // can identify the user's likely intent (typing `--upgrade` for the
139
+ // `upgrade` subcommand), we hint at the correct shape.
140
+ function friendlyParseArgsMessage(rawMessage) {
141
+ const firstLine = (rawMessage || '').split('\n')[0];
142
+ if (/Unknown option ['"]?--upgrade['"]?/.test(firstLine)) {
143
+ return 'Did you mean `create-baseline upgrade <target>`? `upgrade` is a subcommand, not a flag.';
144
+ }
145
+ if (/Unknown option ['"]?--doctor['"]?/.test(firstLine)) {
146
+ return 'Did you mean `create-baseline doctor <target>`? `doctor` is a subcommand, not a flag.';
147
+ }
148
+ const unknown = /Unknown option ['"]?([^'"]+?)['"]?(?:\.|$)/.exec(firstLine);
149
+ if (unknown) return `Unknown option '${unknown[1]}'.`;
150
+ return firstLine;
151
+ }
152
+
114
153
  async function dispatchInstall(target, values, templateDir) {
115
154
  const dryRun = !!values['dry-run'];
116
155
  if (process.stdout.isTTY && !values.force && !dryRun) {
@@ -136,13 +175,13 @@ async function runPlainInstall(target, values, templateDir) {
136
175
  const dryRun = !!values['dry-run'];
137
176
  if (values.force) {
138
177
  if (!process.stdin.isTTY) {
139
- io.error('--force requires an interactive TTY for the confirmation prompt');
178
+ await usageError('--force requires an interactive TTY for the confirmation prompt');
140
179
  return 2;
141
180
  }
142
181
  if (!dryRun) {
143
182
  const answer = await io.ask("type 'overwrite' to proceed: ");
144
183
  if (answer.toLowerCase() !== 'overwrite') {
145
- io.error('confirmation declined');
184
+ await usageError('confirmation declined');
146
185
  return 1;
147
186
  }
148
187
  }
@@ -157,7 +196,7 @@ async function runPlainInstall(target, values, templateDir) {
157
196
  else await freshInstall(templateDir, target, { withNpmrc: !!values['with-npmrc'] });
158
197
  }
159
198
  } catch (err) {
160
- io.error(`install failed: ${err.message}`);
199
+ await usageError(`install failed: ${err.message}`);
161
200
  return 1;
162
201
  }
163
202
 
@@ -181,7 +220,7 @@ async function fetchPlantumlPlain(target, values) {
181
220
  return 0;
182
221
  }
183
222
  if (result.outcome === FETCH_OUTCOMES.ERRORED_REQUIRE_PLANTUML) {
184
- io.error(`--require-plantuml: ${result.reason}`);
223
+ await usageError(`--require-plantuml: ${result.reason}`);
185
224
  return 4;
186
225
  }
187
226
  return 0;
@@ -190,9 +229,16 @@ async function fetchPlantumlPlain(target, values) {
190
229
  async function dispatchUpgrade(target, values, templateDir) {
191
230
  const manifestPath = join(target, '.claude/.baseline-manifest.json');
192
231
  if (!existsSync(manifestPath)) {
193
- io.error(`No baseline manifest at ${manifestPath}. Run a fresh install first.`);
232
+ await usageError(`No baseline manifest at ${manifestPath}. Run a fresh install first.`);
194
233
  return 2;
195
234
  }
235
+ const { findPendingStage } = await import('../src/cli/upgrade-tiers.js');
236
+ const pending = await findPendingStage(target);
237
+ if (pending) {
238
+ const fileLines = pending.files.map((f) => ` - ${f}`).join('\n');
239
+ io.log(`Pending semantic-merge stage at ${pending.stage_ts}.\n${pending.files.length} file(s) awaiting reconciliation:\n${fileLines}\nOpen Claude Code and run /upgrade-project to reconcile.`);
240
+ return 5;
241
+ }
196
242
  if (process.stdout.isTTY) {
197
243
  const tui = await import('../src/cli/tui/upgrade.js');
198
244
  return tui.run({
@@ -207,17 +253,32 @@ async function runPlainUpgrade(target, values, templateDir, manifestPath) {
207
253
  const oldManifest = await loadManifest(manifestPath);
208
254
  const tplFiles = await listShippedFiles(templateDir);
209
255
  const newManifest = await buildManifestFromDir(templateDir, tplFiles);
256
+ await overlayShippedTiers(templateDir, newManifest);
210
257
  if (values['dry-run']) {
211
258
  io.log(`Would upgrade ${tplFiles.length} files into ${target}`);
212
259
  return 0;
213
260
  }
214
261
  const report = await threeWayMerge(templateDir, target, oldManifest, newManifest);
215
262
  for (const action of report.actions) {
216
- io.log(`${action.kind.padEnd(24)} ${action.path}`);
263
+ io.log(`${action.kind.padEnd(28)} ${action.path}`);
217
264
  }
218
265
  return report.exitCode;
219
266
  }
220
267
 
268
+ async function overlayShippedTiers(templateDir, newManifest) {
269
+ const shippedPath = join(templateDir, '.claude/manifest.json');
270
+ if (!existsSync(shippedPath)) return;
271
+ const { readFile: rf } = await import('node:fs/promises');
272
+ const shipped = JSON.parse(await rf(shippedPath, 'utf8'));
273
+ if (!shipped?.files) return;
274
+ for (const rel of Object.keys(newManifest.files)) {
275
+ const shippedEntry = shipped.files[rel];
276
+ if (shippedEntry && typeof shippedEntry === 'object' && typeof shippedEntry.tier === 'string') {
277
+ newManifest.files[rel] = { sha256: newManifest.files[rel], tier: shippedEntry.tier };
278
+ }
279
+ }
280
+ }
281
+
221
282
  async function dispatchDoctor(positionals, values) {
222
283
  const target = resolve(positionals[1] ?? '.');
223
284
  const report = await runDoctor(target, { strict: !!values.strict });
@@ -245,10 +306,10 @@ async function main(argv) {
245
306
  });
246
307
  } catch (err) {
247
308
  if (/--merge/.test(err.message)) {
248
- io.error('--merge has been removed; use `create-baseline upgrade <target>` instead.');
309
+ await usageError('--merge has been removed; use `create-baseline upgrade <target>` instead.');
249
310
  return 2;
250
311
  }
251
- io.error(err.message);
312
+ await usageError(friendlyParseArgsMessage(err.message));
252
313
  return 2;
253
314
  }
254
315
 
@@ -285,23 +346,34 @@ async function main(argv) {
285
346
  try {
286
347
  templateDir = getTemplateDir();
287
348
  } catch (err) {
288
- io.error(err.message);
349
+ await usageError(err.message);
289
350
  return 2;
290
351
  }
291
352
  return await dispatchUpgrade(target, values, templateDir);
292
353
  }
293
354
 
294
355
  if (values['no-plantuml'] && values['require-plantuml']) {
295
- io.error('--no-plantuml and --require-plantuml are mutually exclusive');
356
+ await usageError('--no-plantuml and --require-plantuml are mutually exclusive');
296
357
  return 2;
297
358
  }
298
359
  if (positionals.length === 0) {
299
- io.error('missing required <target> argument');
300
- io.error(HELP_TEXT);
360
+ // TTY landing: render the branded splash (skills.sh-style marquee) so the
361
+ // empty invocation reads as "here's what this tool does" instead of an
362
+ // angry error. Non-TTY keeps the strict error+help+exit-2 contract so
363
+ // scripts and CI still detect the missing argument.
364
+ if (process.stdout.isTTY) {
365
+ const splash = await import('../src/cli/tui/splash.js');
366
+ process.stdout.write(splash.renderSplash({
367
+ tryLine: 'npx @friedbotstudio/create-baseline ./my-project',
368
+ discoverUrl: 'https://baseline.friedbotstudio.com/',
369
+ }));
370
+ return 0;
371
+ }
372
+ await usageError('missing required <target> argument');
301
373
  return 2;
302
374
  }
303
375
  if (positionals.length > 1) {
304
- io.error(`unexpected positional arguments: ${positionals.slice(1).join(', ')}`);
376
+ await usageError(`unexpected positional arguments: ${positionals.slice(1).join(', ')}`);
305
377
  return 2;
306
378
  }
307
379
 
@@ -310,15 +382,17 @@ async function main(argv) {
310
382
  try {
311
383
  templateDir = getTemplateDir();
312
384
  } catch (err) {
313
- io.error(err.message);
385
+ await usageError(err.message);
314
386
  return 2;
315
387
  }
316
388
 
317
389
  const sentinels = await scanSentinels(target);
318
390
  const hasConflict = sentinels.length > 0;
319
391
  if (hasConflict && !values.force) {
320
- io.error(`existing baseline detected at ${target}: ${sentinels.join(', ')}`);
321
- io.error('pass --force to overwrite or use `create-baseline upgrade <target>` to three-way merge');
392
+ await usageError(
393
+ `existing baseline detected at ${target}: ${sentinels.join(', ')}. ` +
394
+ 'Pass --force to overwrite or use `create-baseline upgrade <target>` to three-way merge.'
395
+ );
322
396
  return 1;
323
397
  }
324
398