@bluestep-systems/bspecs 0.11.1 → 0.12.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
@@ -46,11 +46,21 @@ devDependency; bare `npx b6p` resolves only inside a scaffolded project, where `
46
46
  From the parent directory where you want to create the project:
47
47
 
48
48
  ```sh
49
- bspecs
49
+ bspecs new
50
50
  ```
51
51
 
52
52
  The interactive wizard asks for the project name, client, and an optional description. When done, it generates the project directory with the full structure and (unless you opt out) runs `git init`.
53
53
 
54
+ ### Add the tooling to an existing project
55
+
56
+ Already have a project and just want the Claude Code tooling? From inside that project's directory:
57
+
58
+ ```sh
59
+ bspecs init
60
+ ```
61
+
62
+ `bspecs init` installs the full tooling tree **in place** and is strictly non-destructive: any file that already exists is left untouched. The one exception is `package.json` — the `@bluestep-systems/b6p-cli` devDependency (and the `b6p` script) are merged in, preserving everything else. It then writes the `bspecs.lock` so `bspecs sync` works going forward. At the end it prints a report of every file it skipped because the name already existed; to install the `bspecs` version of any of them, rename or move your local copy and run `bspecs init` again. The client-name prompt is optional — press Enter to default to `BlueStep Client`. (No `git init` — an existing project owns its own VCS.)
63
+
54
64
  ### Keep a project up to date
55
65
 
56
66
  When a new version of `bspecs` is published with improvements to skills, hooks, or instructions, update your global install and sync the project:
package/cli.js CHANGED
@@ -3,8 +3,8 @@ import { intro, outro, cancel, log } from '@clack/prompts';
3
3
  import { readFileSync } from 'fs';
4
4
  import { dirname, join } from 'path';
5
5
  import { fileURLToPath } from 'url';
6
- import { runPrompts } from './src/prompts.js';
7
- import { scaffold } from './src/scaffold.js';
6
+ import { runPrompts, runInitPrompts } from './src/prompts.js';
7
+ import { scaffold, init } from './src/scaffold.js';
8
8
  import { sync } from './src/sync.js';
9
9
 
10
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -12,17 +12,22 @@ const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8'));
12
12
 
13
13
  const HELP = `bspecs — spec-driven BlueStep development with AI agents
14
14
 
15
- Scaffold a new BlueStep project with Claude Code skills, hooks, and
15
+ Scaffold and maintain BlueStep projects with Claude Code skills, hooks, and
16
16
  project conventions for spec-driven development.
17
17
 
18
18
  Usage:
19
- bspecs Run the interactive scaffolder in the current directory.
19
+ bspecs new Scaffold a brand-new project in a new subdirectory.
20
+ bspecs init Install the tooling into the current directory (non-destructive).
20
21
  bspecs sync Sync infrastructure files in the current project.
21
22
  bspecs -v Print version.
22
23
  bspecs -h Print this help.
23
24
 
24
25
  Options for bspecs sync:
25
26
  --silent Suppress all output (used by the SessionStart hook).
27
+
28
+ bspecs init never overwrites an existing file (package.json has the b6p-cli
29
+ devDependency merged in) and reports what it skipped so you can rename/move and
30
+ re-run. Its client-name prompt is optional — press Enter for "BlueStep Client".
26
31
  `;
27
32
 
28
33
  function parseArgs(argv) {
@@ -31,7 +36,9 @@ function parseArgs(argv) {
31
36
  if (flags.has('-v') || flags.has('--version')) return { mode: 'version' };
32
37
  if (flags.has('-h') || flags.has('--help')) return { mode: 'help' };
33
38
  if (positional[0] === 'sync') return { mode: 'sync', silent: flags.has('--silent') };
34
- return { mode: 'interactive' };
39
+ if (positional[0] === 'new') return { mode: 'new' };
40
+ if (positional[0] === 'init') return { mode: 'init' };
41
+ return { mode: 'help' };
35
42
  }
36
43
 
37
44
  async function main() {
@@ -52,6 +59,25 @@ async function main() {
52
59
 
53
60
  intro('bspecs — spec-driven BlueStep development with AI agents');
54
61
 
62
+ if (mode === 'init') {
63
+ const answers = await runInitPrompts();
64
+ if (!answers) {
65
+ cancel('Cancelled.');
66
+ process.exit(0);
67
+ }
68
+
69
+ await init(answers);
70
+
71
+ outro(
72
+ `bspecs tooling installed in ${process.cwd()}\n` +
73
+ ` Next steps:\n` +
74
+ ` npm install (if it did not run automatically)\n` +
75
+ ` npx -p @bluestep-systems/b6p-cli b6p auth set (platform credentials, once per machine)`
76
+ );
77
+ return;
78
+ }
79
+
80
+ // mode === 'new'
55
81
  const answers = await runPrompts();
56
82
  if (!answers) {
57
83
  cancel('Cancelled.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bluestep-systems/bspecs",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "Spec-driven BlueStep development with AI agents — scaffolder and project conventions for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {
package/src/prompts.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { text, confirm, isCancel, cancel } from '@clack/prompts';
2
2
  import { existsSync } from 'fs';
3
- import { join } from 'path';
3
+ import { basename, join } from 'path';
4
4
 
5
5
  function titleCase(s) {
6
6
  return s
@@ -72,3 +72,39 @@ export async function runPrompts() {
72
72
  initGit,
73
73
  };
74
74
  }
75
+
76
+ // Prompts for `bspecs init` (install into the current directory). No folder-name
77
+ // question — the project name defaults to the cwd basename — and no git prompt.
78
+ // Client name is optional, falling back to 'BlueStep Client' on an empty Enter.
79
+ export async function runInitPrompts() {
80
+ const cwd = process.cwd();
81
+
82
+ const clientName = bail(
83
+ await text({
84
+ message: 'Client name (optional — press Enter for "BlueStep Client")',
85
+ placeholder: 'BlueStep Client',
86
+ })
87
+ );
88
+
89
+ const projectDescription = bail(
90
+ await text({
91
+ message: 'Project description (optional — gives Claude project context)',
92
+ placeholder: 'What does this project do? Press Enter to skip.',
93
+ })
94
+ );
95
+
96
+ const proceed = bail(
97
+ await confirm({
98
+ message: `Install bspecs tooling into ${cwd}?`,
99
+ initialValue: true,
100
+ })
101
+ );
102
+
103
+ if (!proceed) return null;
104
+
105
+ return {
106
+ projectName: basename(cwd),
107
+ clientName: (clientName && clientName.trim()) || 'BlueStep Client',
108
+ projectDescription: projectDescription || '',
109
+ };
110
+ }
package/src/scaffold.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { execSync } from 'child_process';
2
- import { join, dirname } from 'path';
2
+ import { join, dirname, basename, relative } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { readFileSync, writeFileSync, existsSync } from 'fs';
5
5
  import { log } from '@clack/prompts';
6
- import { ensureDir, copyTemplateTree, applyTemplate, TEMPLATES_DIR, sha256 } from './utils.js';
6
+ import { ensureDir, copyTemplateTree, applyTemplate, writeFile, mergePackageJson, TEMPLATES_DIR, sha256 } from './utils.js';
7
7
  import { SYNC_TARGETS } from './sync.js';
8
8
 
9
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -79,6 +79,11 @@ export async function scaffold(answers) {
79
79
  );
80
80
  }
81
81
 
82
+ printAuthReminder();
83
+ }
84
+
85
+ // One-time-per-machine BlueStep credential reminder, shared by `scaffold` and `init`.
86
+ function printAuthReminder() {
82
87
  log.info(
83
88
  [
84
89
  'Next step — set your BlueStep platform credentials (required, once per machine):',
@@ -93,6 +98,102 @@ export async function scaffold(answers) {
93
98
  );
94
99
  }
95
100
 
101
+ // `bspecs init`: install the template tree into the current directory without
102
+ // overwriting anything that already exists (package.json is the one exception —
103
+ // its devDependencies are merged). Writes the lock so `bspecs sync` works after.
104
+ export async function init(answers) {
105
+ const projectDir = process.cwd();
106
+
107
+ const vars = {
108
+ PROJECT_NAME: answers.projectName,
109
+ CLIENT_NAME: answers.clientName,
110
+ PROJECT_DESCRIPTION: answers.projectDescription,
111
+ SCAFFOLD_DATE: new Date().toISOString().split('T')[0],
112
+ };
113
+
114
+ const collect = { written: [], skipped: [] };
115
+
116
+ copyTemplateTree('root', projectDir, vars, {
117
+ skipExisting: true,
118
+ collect,
119
+ exclude: ['package.json.template'],
120
+ });
121
+ copyTemplateTree('claude', join(projectDir, '.claude'), vars, {
122
+ skipExisting: true,
123
+ collect,
124
+ makeExecutable: true,
125
+ });
126
+ copyTemplateTree('module', join(projectDir, '.claude', 'templates'), vars, {
127
+ skipExisting: true,
128
+ collect,
129
+ });
130
+
131
+ const pkgStatus = handlePackageJson(projectDir, vars, collect);
132
+
133
+ writeBspecsLock(projectDir, vars);
134
+
135
+ log.success('Tooling installed.');
136
+
137
+ checkPrettierOnPath();
138
+ installDependencies(basename(projectDir), projectDir);
139
+ printAuthReminder();
140
+
141
+ reportInstall(projectDir, collect, pkgStatus);
142
+
143
+ return { collect, pkgStatus };
144
+ }
145
+
146
+ // End-of-`init` summary. Lists every file left untouched because it already
147
+ // existed, with guidance to rename/move and re-run for the pristine version.
148
+ function reportInstall(projectDir, collect, pkgStatus) {
149
+ const parts = [`${collect.written.length} added`];
150
+ if (pkgStatus === 'merged') parts.push('1 merged (package.json)');
151
+ parts.push(`${collect.skipped.length} skipped`);
152
+ log.info(`Install summary: ${parts.join(', ')}.`);
153
+
154
+ if (collect.skipped.length > 0) {
155
+ const list = collect.skipped.map((p) => ' ' + relative(projectDir, p)).join('\n');
156
+ log.warn(
157
+ 'These files already existed and were left untouched:\n' +
158
+ list +
159
+ '\n\nTo install the bspecs version of any of them, rename or move your local copy and run `bspecs init` again.'
160
+ );
161
+ }
162
+ }
163
+
164
+ // package.json is the one file `init` may modify: if absent we write the template;
165
+ // if present we merge in the missing b6p-cli devDependency (mergePackageJson fails
166
+ // soft on malformed JSON). Returns 'written' | 'merged' | 'unchanged' | 'merge-failed'.
167
+ function handlePackageJson(projectDir, vars, collect) {
168
+ const dest = join(projectDir, 'package.json');
169
+ const rendered = applyTemplate(
170
+ readFileSync(join(TEMPLATES_DIR, 'root', 'package.json.template'), 'utf8'),
171
+ vars
172
+ );
173
+
174
+ if (!existsSync(dest)) {
175
+ writeFile(dest, rendered);
176
+ collect.written.push(dest);
177
+ return 'written';
178
+ }
179
+
180
+ const existing = readFileSync(dest, 'utf8');
181
+ const merged = mergePackageJson(existing, rendered);
182
+ if (merged === null) {
183
+ log.warn(
184
+ 'Existing package.json is not valid JSON — left untouched. Add the b6p CLI by hand:\n' +
185
+ ' "devDependencies": { "@bluestep-systems/b6p-cli": "^0.1.0" }'
186
+ );
187
+ collect.skipped.push(dest);
188
+ return 'merge-failed';
189
+ }
190
+ if (merged !== existing) {
191
+ writeFile(dest, merged);
192
+ return 'merged';
193
+ }
194
+ return 'unchanged';
195
+ }
196
+
96
197
  // Detect whether the freshly created project directory sits inside an existing
97
198
  // git repository (a parent has a .git). Running `git init` here would nest a
98
199
  // repo inside another, which surprises users and breaks the implementer agent's
package/src/utils.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, readdirSync, statSync } from 'fs';
2
2
  import { createHash } from 'node:crypto';
3
- import { dirname, join } from 'path';
3
+ import { dirname, join, relative } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
 
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -25,11 +25,22 @@ export function readTemplate(relativePath) {
25
25
  return readFileSync(join(TEMPLATES_DIR, relativePath), 'utf8');
26
26
  }
27
27
 
28
+ // Extra opts (all default off, so plain calls overwrite everything as before):
29
+ // skipExisting leaves existing dest files untouched (bspecs init); collect is a
30
+ // { written, skipped } accumulator of absolute paths; exclude lists source-relative
31
+ // paths (forward-slashed) to omit so the caller can handle them separately.
28
32
  export function copyTemplateTree(srcRel, destAbs, vars, opts = {}) {
29
- const { makeExecutable = false, stripTemplateExt = true } = opts;
33
+ const {
34
+ makeExecutable = false,
35
+ stripTemplateExt = true,
36
+ skipExisting = false,
37
+ collect = null,
38
+ exclude = [],
39
+ } = opts;
30
40
  const srcAbs = join(TEMPLATES_DIR, srcRel);
31
41
  if (!existsSync(srcAbs)) return;
32
- walk(srcAbs, srcAbs, destAbs, vars, { makeExecutable, stripTemplateExt });
42
+ const skip = new Set(exclude);
43
+ walk(srcAbs, srcAbs, destAbs, vars, { makeExecutable, stripTemplateExt, skipExisting, collect, skip });
33
44
  }
34
45
 
35
46
  function walk(rootSrc, src, dest, vars, opts) {
@@ -39,16 +50,26 @@ function walk(rootSrc, src, dest, vars, opts) {
39
50
  if (stats.isDirectory()) {
40
51
  walk(rootSrc, srcEntry, join(dest, entry), vars, opts);
41
52
  } else {
53
+ const srcRelPath = relative(rootSrc, srcEntry).split('\\').join('/');
54
+ if (opts.skip.has(srcRelPath)) continue;
55
+
42
56
  const targetName = opts.stripTemplateExt && entry.endsWith('.template')
43
57
  ? entry.slice(0, -'.template'.length)
44
58
  : entry;
45
59
  const destFile = join(dest, targetName);
60
+
61
+ if (opts.skipExisting && existsSync(destFile)) {
62
+ if (opts.collect) opts.collect.skipped.push(destFile);
63
+ continue;
64
+ }
65
+
46
66
  const raw = readFileSync(srcEntry, 'utf8');
47
67
  const rendered = applyTemplate(raw, vars);
48
68
  writeFile(destFile, rendered);
49
69
  if (opts.makeExecutable && destFile.endsWith('.sh')) {
50
70
  try { chmodSync(destFile, 0o755); } catch { /* Windows can't chmod, ignore */ }
51
71
  }
72
+ if (opts.collect) opts.collect.written.push(destFile);
52
73
  }
53
74
  }
54
75
  }
@@ -93,3 +114,32 @@ export function exists(path) {
93
114
  export function sha256(str) {
94
115
  return createHash('sha256').update(str, 'utf8').digest('hex');
95
116
  }
117
+
118
+ // Add only-missing template devDependencies and the `b6p` script into an existing
119
+ // package.json (bspecs init); existing values are never changed. Returns merged
120
+ // JSON text, or null if the existing content isn't valid JSON (caller fails soft).
121
+ export function mergePackageJson(existingContent, templateContent) {
122
+ let existing;
123
+ try {
124
+ existing = JSON.parse(existingContent);
125
+ } catch {
126
+ return null;
127
+ }
128
+ const template = JSON.parse(templateContent);
129
+
130
+ const tplDeps = template.devDependencies || {};
131
+ if (Object.keys(tplDeps).length > 0) {
132
+ existing.devDependencies = existing.devDependencies || {};
133
+ for (const [name, version] of Object.entries(tplDeps)) {
134
+ if (!(name in existing.devDependencies)) existing.devDependencies[name] = version;
135
+ }
136
+ }
137
+
138
+ const tplScripts = template.scripts || {};
139
+ if ('b6p' in tplScripts) {
140
+ existing.scripts = existing.scripts || {};
141
+ if (!('b6p' in existing.scripts)) existing.scripts.b6p = tplScripts.b6p;
142
+ }
143
+
144
+ return JSON.stringify(existing, null, 2) + '\n';
145
+ }