@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 +11 -1
- package/cli.js +31 -5
- package/package.json +1 -1
- package/src/prompts.js +37 -1
- package/src/scaffold.js +103 -2
- package/src/utils.js +53 -3
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
|
|
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
|
|
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: '
|
|
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
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 {
|
|
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
|
-
|
|
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
|
+
}
|