@bluestep-systems/bspecs 0.10.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 +129 -0
- package/cli.js +74 -0
- package/package.json +30 -0
- package/src/prompts.js +74 -0
- package/src/scaffold.js +152 -0
- package/src/sync.js +123 -0
- package/src/utils.js +95 -0
- package/templates/claude/agents/b6p-code-review.md +81 -0
- package/templates/claude/agents/b6p-commenter.md +59 -0
- package/templates/claude/agents/b6p-task-implementer.md +77 -0
- package/templates/claude/hooks/block-generated-files.sh +16 -0
- package/templates/claude/hooks/block-tsc.sh +16 -0
- package/templates/claude/hooks/prettier-on-save.sh +21 -0
- package/templates/claude/instructions/b6p-platform.md.template +185 -0
- package/templates/claude/instructions/bsjs-development.md.template +430 -0
- package/templates/claude/instructions/conventions/always-snapshot.md.template +25 -0
- package/templates/claude/instructions/conventions/blueiq-no-ai-branding.md.template +11 -0
- package/templates/claude/instructions/conventions/date-format.md.template +27 -0
- package/templates/claude/instructions/conventions/endpoint-approach.md.template +9 -0
- package/templates/claude/instructions/conventions/formula-patterns.md.template +71 -0
- package/templates/claude/instructions/conventions/no-global-dollar.md.template +9 -0
- package/templates/claude/instructions/conventions/push-inner-draft.md.template +21 -0
- package/templates/claude/instructions/conventions/separate-files.md.template +17 -0
- package/templates/claude/instructions/conventions/single-script.md.template +28 -0
- package/templates/claude/instructions/conventions/snapshot-integrity.md.template +23 -0
- package/templates/claude/instructions/conventions/top-level-const-tdz.md.template +33 -0
- package/templates/claude/instructions/conventions/ts-in-template-literal.md.template +48 -0
- package/templates/claude/instructions/conventions/tsc-rootdir.md.template +17 -0
- package/templates/claude/instructions/gotchas/common-gotchas.md.template +91 -0
- package/templates/claude/instructions/gotchas/fetched-resource-code.md.template +9 -0
- package/templates/claude/instructions/index.md.template +82 -0
- package/templates/claude/instructions/reference/api-patterns.md.template +487 -0
- package/templates/claude/instructions/reference/blueiq-credit-integration-playbook.md.template +31 -0
- package/templates/claude/instructions/reference/chronounit-months.md.template +37 -0
- package/templates/claude/instructions/reference/code-patterns.md.template +265 -0
- package/templates/claude/instructions/reference/component-library.md.template +217 -0
- package/templates/claude/instructions/reference/crm-dashboard-inspo.md.template +17 -0
- package/templates/claude/instructions/reference/csv-parsing.md.template +18 -0
- package/templates/claude/instructions/reference/dashboard-design-system.md.template +38 -0
- package/templates/claude/instructions/reference/datetime-field-write.md.template +27 -0
- package/templates/claude/instructions/reference/design-system.md.template +150 -0
- package/templates/claude/instructions/reference/dpn-dashboard-framework.md.template +29 -0
- package/templates/claude/instructions/reference/endpoint-method-call.md.template +10 -0
- package/templates/claude/instructions/reference/endpoint-no-delete-method.md.template +9 -0
- package/templates/claude/instructions/reference/endpoint-output-channel.md.template +23 -0
- package/templates/claude/instructions/reference/endpoint-urls.md.template +15 -0
- package/templates/claude/instructions/reference/entry-delete.md.template +40 -0
- package/templates/claude/instructions/reference/file-execution.md.template +113 -0
- package/templates/claude/instructions/reference/http-requester.md.template +37 -0
- package/templates/claude/instructions/reference/id-full-vs-short.md.template +15 -0
- package/templates/claude/instructions/reference/internal-loopback-fetch.md.template +24 -0
- package/templates/claude/instructions/reference/localdate-parse.md.template +16 -0
- package/templates/claude/instructions/reference/merge-report-memo-json.md.template +25 -0
- package/templates/claude/instructions/reference/merge-report-static-index.md.template +29 -0
- package/templates/claude/instructions/reference/merge-report-urls.md.template +67 -0
- package/templates/claude/instructions/reference/multi-entry-in-multi-entry.md.template +21 -0
- package/templates/claude/instructions/reference/named-controls-submit.md.template +11 -0
- package/templates/claude/instructions/reference/new-entry-id.md.template +30 -0
- package/templates/claude/instructions/reference/relationship-field-set.md.template +37 -0
- package/templates/claude/instructions/reference/send-message-abort.md.template +37 -0
- package/templates/claude/instructions/reference/session-cookie-forwarding.md.template +31 -0
- package/templates/claude/instructions/reference/singleselect-null-copy.md.template +21 -0
- package/templates/claude/instructions/reference/staff-query-permission-gating.md.template +27 -0
- package/templates/claude/instructions/reference/timefield-vs-datetimefield.md.template +13 -0
- package/templates/claude/instructions/reference/user-zone-id.md.template +16 -0
- package/templates/claude/settings.json.template +46 -0
- package/templates/claude/skills/b6p-audit/SKILL.md +82 -0
- package/templates/claude/skills/b6p-pull/SKILL.md +123 -0
- package/templates/claude/skills/b6p-push/SKILL.md +70 -0
- package/templates/claude/skills/bug-fix/SKILL.md +28 -0
- package/templates/claude/skills/spec-create/SKILL.md +60 -0
- package/templates/claude/skills/spec-execute/SKILL.md +51 -0
- package/templates/claude/skills/spec-status/SKILL.md +20 -0
- package/templates/claude/skills/task-comment/SKILL.md +96 -0
- package/templates/claude/spec-templates/design.template.md +36 -0
- package/templates/claude/spec-templates/requirements.template.md +26 -0
- package/templates/claude/spec-templates/tasks.template.md +37 -0
- package/templates/module/README.md.template +46 -0
- package/templates/root/.gitignore.template +14 -0
- package/templates/root/.prettierrc.template +8 -0
- package/templates/root/CLAUDE.md.template +157 -0
- package/templates/root/README.md.template +58 -0
- package/templates/root/package.json.template +15 -0
package/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# @bluestep-systems/bspecs
|
|
2
|
+
|
|
3
|
+
CLI for scaffolding BlueStep projects with spec-driven development conventions for Claude Code.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
`bspecs` generates a project directory ready to use with:
|
|
8
|
+
|
|
9
|
+
- Claude Code skills (`/spec-create`, `/spec-execute`, `/b6p-pull`, `/b6p-push`, and more)
|
|
10
|
+
- BlueStep subagents — `b6p-task-implementer` (isolated task execution; `/spec-execute` delegates to it), `b6p-commenter` (component README), `b6p-code-review` (report-only review)
|
|
11
|
+
- Automatic hooks (prettier on save, generated-file blocking, `b6p` integration)
|
|
12
|
+
- Instructions for Claude Code (the template tree is the single source of truth)
|
|
13
|
+
- Spec templates (`requirements.md`, `design.md`, `tasks.md`)
|
|
14
|
+
- The `b6p` CLI wired into each project as a devDependency, invoked via `npx b6p` (no global install or shell/PATH detection)
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
`bspecs` and the `b6p` CLI it depends on are published to the **public npm registry**, so there is no
|
|
19
|
+
token or `~/.npmrc` setup — install in one command:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npm install -g @bluestep-systems/bspecs
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
This gives you the `bspecs` command for scaffolding projects. It does **not** put a `b6p` binary on
|
|
26
|
+
your global `PATH` — a dependency's bin is never globally reachable. Instead, every project you
|
|
27
|
+
scaffold declares `@bluestep-systems/b6p-cli` as a devDependency and the skills invoke it via
|
|
28
|
+
`npx b6p`. The scaffolder runs `npm install` in the new project for you (best-effort) to fetch `b6p`;
|
|
29
|
+
if it can't — e.g. you're offline — it prints the command to run by hand. That per-project install
|
|
30
|
+
resolves `@bluestep-systems/b6p-cli` anonymously from public npm, no token needed.
|
|
31
|
+
|
|
32
|
+
> **Not "zero setup."** Removing the npm token does **not** remove the one-time **BlueStep platform**
|
|
33
|
+
> credential step. Before the `/b6p-pull`, `/b6p-push`, or `/b6p-audit` skills work in a scaffolded
|
|
34
|
+
> project, run `npx b6p auth set` **once per machine** (credentials are stored globally in `~/.b6p`,
|
|
35
|
+
> not per project). This is unrelated to the npm registry and is still required — see the scaffolded
|
|
36
|
+
> project's own README.
|
|
37
|
+
|
|
38
|
+
> **Migrating from the old GitHub Packages install?** If you previously installed `bspecs` you likely
|
|
39
|
+
> have a line in `~/.npmrc` mapping the scope to GitHub Packages:
|
|
40
|
+
>
|
|
41
|
+
> ```ini
|
|
42
|
+
> @bluestep-systems:registry=https://npm.pkg.github.com
|
|
43
|
+
> ```
|
|
44
|
+
>
|
|
45
|
+
> Remove it (and the matching `//npm.pkg.github.com/:_authToken=...` line). Left in place it keeps
|
|
46
|
+
> routing `@bluestep-systems/*` to GitHub Packages and the public install will 404.
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
### Scaffold a new project
|
|
51
|
+
|
|
52
|
+
From the parent directory where you want to create the project:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
bspecs
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
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`.
|
|
59
|
+
|
|
60
|
+
### Keep a project up to date
|
|
61
|
+
|
|
62
|
+
When a new version of `bspecs` is published with improvements to skills, hooks, or instructions, update your global install and sync the project:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
npm update -g @bluestep-systems/bspecs
|
|
66
|
+
cd my-project
|
|
67
|
+
bspecs sync
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`bspecs sync` compares each infrastructure file against the state it was in when scaffolded. Files you have not modified locally are updated; files you have edited are left untouched with a warning. If you believe your local changes would be useful across all BlueStep projects, open an issue in this repo so they can be incorporated into the scaffolder.
|
|
71
|
+
|
|
72
|
+
Projects scaffolded with `bspecs 0.5.0` or later run `bspecs sync` automatically every time Claude Code opens the workspace — no manual action needed.
|
|
73
|
+
|
|
74
|
+
## Prerequisites
|
|
75
|
+
|
|
76
|
+
- **Node.js 18+**
|
|
77
|
+
- **`b6p` CLI** — required for the `/b6p-pull`, `/b6p-push`, and `/b6p-audit` skills. Scaffolded
|
|
78
|
+
projects declare it as a devDependency (`@bluestep-systems/b6p-cli`, resolved from public npm with
|
|
79
|
+
no token); the scaffolder runs `npm install` for you (re-run it by hand if that failed) and the
|
|
80
|
+
skills invoke it via `npx b6p` — no global install, no shell/PATH detection. Set your platform
|
|
81
|
+
credentials once per machine with `npx b6p auth set` (see Installation).
|
|
82
|
+
- **prettier** — required for the auto-format hook. `bspecs` warns if it is not found.
|
|
83
|
+
|
|
84
|
+
## Generated structure
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
my-project/
|
|
88
|
+
├── CLAUDE.md ← project instructions for Claude
|
|
89
|
+
├── README.md ← project documentation
|
|
90
|
+
├── .prettierrc
|
|
91
|
+
├── .gitignore
|
|
92
|
+
├── package.json ← declares the b6p-cli devDependency
|
|
93
|
+
└── .claude/
|
|
94
|
+
├── bspecs.lock ← lock file for bspecs sync
|
|
95
|
+
├── settings.json ← Claude Code permissions and hooks
|
|
96
|
+
├── hooks/ ← 3 scripts executed by Claude Code
|
|
97
|
+
├── skills/ ← 8 skills (/spec-create, /b6p-pull, etc.)
|
|
98
|
+
├── agents/ ← 3 BlueStep subagents (implementer, commenter, reviewer)
|
|
99
|
+
├── instructions/ ← development rules for Claude
|
|
100
|
+
├── spec-templates/ ← spec file templates
|
|
101
|
+
└── templates/ ← component scaffolding templates
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Proposing changes
|
|
105
|
+
|
|
106
|
+
### Global changes (improvements for all projects)
|
|
107
|
+
|
|
108
|
+
If you find something that should be improved in the skills, hooks, instructions, or templates — something useful across all BlueStep projects — open an issue or PR in this repo. Once merged and published as a new version, `bspecs sync` propagates the change to all existing projects automatically.
|
|
109
|
+
|
|
110
|
+
### Local changes (specific to your project)
|
|
111
|
+
|
|
112
|
+
If you need to adjust something only for your project (an extra permission in `settings.json`, a custom skill, changes to your `CLAUDE.md`), edit it directly in your repo. `bspecs sync` detects that those files were locally modified and leaves them untouched on future syncs.
|
|
113
|
+
|
|
114
|
+
## Publishing
|
|
115
|
+
|
|
116
|
+
The package is published to the **public npm registry** under the `@bluestep-systems` organization
|
|
117
|
+
(`access: public`). Only `cli.js`, `src/`, and `templates/` are included in the published package.
|
|
118
|
+
|
|
119
|
+
Releases are automated by GitHub Actions — there is no manual `npm publish`:
|
|
120
|
+
|
|
121
|
+
1. Bump `version` in `package.json` and commit.
|
|
122
|
+
2. Tag the commit `vX.Y.Z` (matching the new version) and push the tag.
|
|
123
|
+
3. `.github/workflows/publish.yml` fires on the tag, verifies the tag matches `package.json`, runs the
|
|
124
|
+
smoke checks, and publishes with `npm publish --provenance --access public`.
|
|
125
|
+
|
|
126
|
+
Publishing needs the `NPM_TOKEN` repo secret (an npm automation token with publish rights to
|
|
127
|
+
`@bluestep-systems`); the version guard fails the run early if the tag and `package.json` disagree.
|
|
128
|
+
`.github/workflows/ci.yml` runs the same smoke checks on every pull request and push to the default
|
|
129
|
+
branch.
|
package/cli.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { intro, outro, cancel, log } from '@clack/prompts';
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
import { dirname, join } from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { runPrompts } from './src/prompts.js';
|
|
7
|
+
import { scaffold } from './src/scaffold.js';
|
|
8
|
+
import { sync } from './src/sync.js';
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8'));
|
|
12
|
+
|
|
13
|
+
const HELP = `bspecs — spec-driven BlueStep development with AI agents
|
|
14
|
+
|
|
15
|
+
Scaffold a new BlueStep project with Claude Code skills, hooks, and
|
|
16
|
+
project conventions for spec-driven development.
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
bspecs Run the interactive scaffolder in the current directory.
|
|
20
|
+
bspecs sync Sync infrastructure files in the current project.
|
|
21
|
+
bspecs -v Print version.
|
|
22
|
+
bspecs -h Print this help.
|
|
23
|
+
|
|
24
|
+
Options for bspecs sync:
|
|
25
|
+
--silent Suppress all output (used by the SessionStart hook).
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
function parseArgs(argv) {
|
|
29
|
+
const flags = new Set(argv.filter(a => a.startsWith('-')));
|
|
30
|
+
const positional = argv.filter(a => !a.startsWith('-'));
|
|
31
|
+
if (flags.has('-v') || flags.has('--version')) return { mode: 'version' };
|
|
32
|
+
if (flags.has('-h') || flags.has('--help')) return { mode: 'help' };
|
|
33
|
+
if (positional[0] === 'sync') return { mode: 'sync', silent: flags.has('--silent') };
|
|
34
|
+
return { mode: 'interactive' };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function main() {
|
|
38
|
+
const { mode, silent } = parseArgs(process.argv.slice(2));
|
|
39
|
+
|
|
40
|
+
if (mode === 'version') {
|
|
41
|
+
console.log(pkg.version);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (mode === 'help') {
|
|
45
|
+
console.log(HELP);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (mode === 'sync') {
|
|
49
|
+
await sync({ silent });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
intro('bspecs — spec-driven BlueStep development with AI agents');
|
|
54
|
+
|
|
55
|
+
const answers = await runPrompts();
|
|
56
|
+
if (!answers) {
|
|
57
|
+
cancel('Cancelled.');
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await scaffold(answers);
|
|
62
|
+
|
|
63
|
+
outro(
|
|
64
|
+
`Project created at ${answers.projectName}\n` +
|
|
65
|
+
` Next steps:\n` +
|
|
66
|
+
` cd ${answers.projectName}\n` +
|
|
67
|
+
` wsl bash -lc 'b6p pull "<DAV URL>"' (creates the U-folder and component skeleton)`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
main().catch((err) => {
|
|
72
|
+
log.error(err.message || String(err));
|
|
73
|
+
process.exit(1);
|
|
74
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bluestep-systems/bspecs",
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "Spec-driven BlueStep development with AI agents — scaffolder and project conventions for Claude Code",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"bspecs": "./cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"cli.js",
|
|
11
|
+
"src/",
|
|
12
|
+
"templates/"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"start": "node cli.js"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@clack/prompts": "^0.9.0"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18.0.0"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/Bluestep-Systems/bspecs.git"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/prompts.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { text, confirm, isCancel, cancel } from '@clack/prompts';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
function titleCase(s) {
|
|
6
|
+
return s
|
|
7
|
+
.replace(/[-_]+/g, ' ')
|
|
8
|
+
.split(' ')
|
|
9
|
+
.filter(Boolean)
|
|
10
|
+
.map((w) => w[0].toUpperCase() + w.slice(1))
|
|
11
|
+
.join(' ');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function bail(value) {
|
|
15
|
+
if (isCancel(value)) {
|
|
16
|
+
cancel('Cancelled.');
|
|
17
|
+
process.exit(0);
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function runPrompts() {
|
|
23
|
+
const projectName = bail(
|
|
24
|
+
await text({
|
|
25
|
+
message: 'Project folder name',
|
|
26
|
+
placeholder: 'my-bluestep-project',
|
|
27
|
+
validate: (v) => {
|
|
28
|
+
if (!v || !v.trim()) return 'Project name is required';
|
|
29
|
+
if (/[<>:"/\\|?*]/.test(v)) return 'Invalid characters in name';
|
|
30
|
+
if (existsSync(join(process.cwd(), v))) return `Folder "${v}" already exists`;
|
|
31
|
+
return undefined;
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const clientName = bail(
|
|
37
|
+
await text({
|
|
38
|
+
message: 'Client name',
|
|
39
|
+
initialValue: titleCase(projectName),
|
|
40
|
+
validate: (v) => (v && v.trim() ? undefined : 'Client name is required'),
|
|
41
|
+
})
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const projectDescription = bail(
|
|
45
|
+
await text({
|
|
46
|
+
message: 'Project description (optional — gives Claude project context)',
|
|
47
|
+
placeholder: 'What does this project do? Press Enter to skip.',
|
|
48
|
+
})
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const initGit = bail(
|
|
52
|
+
await confirm({
|
|
53
|
+
message:
|
|
54
|
+
'Initialize a git repository? (skipping degrades the implementer agent, which relies on git diff)',
|
|
55
|
+
initialValue: true,
|
|
56
|
+
})
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const proceed = bail(
|
|
60
|
+
await confirm({
|
|
61
|
+
message: `Create project "${projectName}" in ${process.cwd()}?`,
|
|
62
|
+
initialValue: true,
|
|
63
|
+
})
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
if (!proceed) return null;
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
projectName,
|
|
70
|
+
clientName,
|
|
71
|
+
projectDescription: projectDescription || '',
|
|
72
|
+
initGit,
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/scaffold.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
5
|
+
import { log } from '@clack/prompts';
|
|
6
|
+
import { ensureDir, copyTemplateTree, applyTemplate, TEMPLATES_DIR, sha256 } from './utils.js';
|
|
7
|
+
import { SYNC_TARGETS } from './sync.js';
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
11
|
+
|
|
12
|
+
function writeBspecsLock(projectDir, vars) {
|
|
13
|
+
const files = {};
|
|
14
|
+
for (const target of SYNC_TARGETS) {
|
|
15
|
+
const templatePath = join(TEMPLATES_DIR, target.templateSrc);
|
|
16
|
+
if (!existsSync(templatePath)) continue;
|
|
17
|
+
const rendered = applyTemplate(readFileSync(templatePath, 'utf8'), vars);
|
|
18
|
+
files[target.destRel] = sha256(rendered);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const lock = {
|
|
22
|
+
bspecs_version: pkg.version,
|
|
23
|
+
synced_at: new Date().toISOString().split('T')[0],
|
|
24
|
+
vars: {
|
|
25
|
+
PROJECT_NAME: vars.PROJECT_NAME,
|
|
26
|
+
CLIENT_NAME: vars.CLIENT_NAME,
|
|
27
|
+
PROJECT_DESCRIPTION: vars.PROJECT_DESCRIPTION,
|
|
28
|
+
SCAFFOLD_DATE: vars.SCAFFOLD_DATE,
|
|
29
|
+
},
|
|
30
|
+
files,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
writeFileSync(join(projectDir, '.claude', 'bspecs.lock'), JSON.stringify(lock, null, 2) + '\n', 'utf8');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function scaffold(answers) {
|
|
37
|
+
const projectDir = join(process.cwd(), answers.projectName);
|
|
38
|
+
ensureDir(projectDir);
|
|
39
|
+
|
|
40
|
+
const vars = {
|
|
41
|
+
PROJECT_NAME: answers.projectName,
|
|
42
|
+
CLIENT_NAME: answers.clientName,
|
|
43
|
+
PROJECT_DESCRIPTION: answers.projectDescription,
|
|
44
|
+
SCAFFOLD_DATE: new Date().toISOString().split('T')[0],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
copyTemplateTree('root', projectDir, vars);
|
|
48
|
+
copyTemplateTree('claude', join(projectDir, '.claude'), vars, { makeExecutable: true });
|
|
49
|
+
copyTemplateTree('module', join(projectDir, '.claude', 'templates'), vars);
|
|
50
|
+
|
|
51
|
+
writeBspecsLock(projectDir, vars);
|
|
52
|
+
|
|
53
|
+
log.success('Files generated.');
|
|
54
|
+
|
|
55
|
+
checkPrettierOnPath();
|
|
56
|
+
installDependencies(answers.projectName, projectDir);
|
|
57
|
+
|
|
58
|
+
if (answers.initGit) {
|
|
59
|
+
if (isInsideGitRepo(projectDir)) {
|
|
60
|
+
log.warn(
|
|
61
|
+
'An existing git repository was found in a parent directory. Skipping git init to avoid nesting a repository inside another — initialize manually if that was intended.'
|
|
62
|
+
);
|
|
63
|
+
} else {
|
|
64
|
+
try {
|
|
65
|
+
execSync('git init', { cwd: projectDir, stdio: 'ignore' });
|
|
66
|
+
execSync('git add -A', { cwd: projectDir, stdio: 'ignore' });
|
|
67
|
+
execSync('git commit -m "chore: initial scaffold via bspecs"', {
|
|
68
|
+
cwd: projectDir,
|
|
69
|
+
stdio: 'ignore',
|
|
70
|
+
});
|
|
71
|
+
log.success('Git repository initialized.');
|
|
72
|
+
} catch (err) {
|
|
73
|
+
log.warn('Could not initialize git repository (git may not be installed).');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
log.warn(
|
|
78
|
+
'Skipped git init. The implementer agent relies on `git diff` to summarize its work — run `git init` in the project before using /spec-execute.'
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Detect whether the freshly created project directory sits inside an existing
|
|
84
|
+
// git repository (a parent has a .git). Running `git init` here would nest a
|
|
85
|
+
// repo inside another, which surprises users and breaks the implementer agent's
|
|
86
|
+
// `git diff`. Returns false if git is unavailable so the normal init path runs.
|
|
87
|
+
function isInsideGitRepo(dir) {
|
|
88
|
+
try {
|
|
89
|
+
const out = execSync('git rev-parse --is-inside-work-tree', {
|
|
90
|
+
cwd: dir,
|
|
91
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
92
|
+
});
|
|
93
|
+
return out.toString().trim() === 'true';
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Best-effort check that prettier is reachable, so we can warn that the
|
|
100
|
+
// prettier-on-save hook will be a no-op until it is installed. The hook runs
|
|
101
|
+
// inside WSL on Windows, so we probe WSL there in addition to the native PATH.
|
|
102
|
+
// Stderr is silenced because interactive shells often emit banners/warnings.
|
|
103
|
+
// Warning only — this never blocks the scaffold.
|
|
104
|
+
function checkPrettierOnPath() {
|
|
105
|
+
const probes = process.platform === 'win32'
|
|
106
|
+
? ['where prettier', 'wsl bash -lc "command -v prettier"', 'wsl zsh -ic "command -v prettier"']
|
|
107
|
+
: ['command -v prettier'];
|
|
108
|
+
for (const cmd of probes) {
|
|
109
|
+
try {
|
|
110
|
+
execSync(cmd, { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
111
|
+
return; // found
|
|
112
|
+
} catch {
|
|
113
|
+
// try the next probe
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const installCmd = process.platform === 'linux' || process.platform === 'darwin'
|
|
117
|
+
? 'npm i -g prettier'
|
|
118
|
+
: 'npm i -g prettier (in PowerShell) or wsl bash -lc "npm i -g prettier" (in WSL)';
|
|
119
|
+
log.warn(`prettier not found in either the native or WSL PATH. The prettier-on-save hook will be a no-op until you run: ${installCmd}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Install the project's dependencies on a best-effort basis. We attempt
|
|
123
|
+
// `npm install` so the b6p CLI (a devDependency) is present without a manual
|
|
124
|
+
// step, but it can legitimately fail and we must NOT assume it will succeed:
|
|
125
|
+
// @bluestep-systems/b6p-cli installs anonymously from the public npm registry
|
|
126
|
+
// (no token, no ~/.npmrc), so the realistic failure mode is the machine being
|
|
127
|
+
// offline at scaffold time. On any failure we fall back to printing the manual
|
|
128
|
+
// install reminder rather than failing the scaffold or leaving the project
|
|
129
|
+
// half-installed. The skills invoke `npx b6p`, so `node_modules/.bin/b6p` must
|
|
130
|
+
// exist before the first b6p skill runs — hence the reminder on failure.
|
|
131
|
+
function installDependencies(projectName, projectDir) {
|
|
132
|
+
log.info(`Installing dependencies in ${projectName} (npm install)…`);
|
|
133
|
+
try {
|
|
134
|
+
execSync('npm install', { cwd: projectDir, stdio: 'ignore' });
|
|
135
|
+
log.success('Dependencies installed — b6p is ready via `npx b6p`.');
|
|
136
|
+
} catch {
|
|
137
|
+
log.warn(
|
|
138
|
+
[
|
|
139
|
+
'Could not run `npm install` automatically. Install dependencies by hand',
|
|
140
|
+
'before using the b6p skills:',
|
|
141
|
+
'',
|
|
142
|
+
` cd ${projectName}`,
|
|
143
|
+
' npm install',
|
|
144
|
+
'',
|
|
145
|
+
'This fetches @bluestep-systems/b6p-cli (a devDependency) so the /b6p-pull,',
|
|
146
|
+
'/b6p-push, and /b6p-audit skills can run `npx b6p …`. It installs from the',
|
|
147
|
+
'public npm registry with no token or ~/.npmrc setup — the most common cause',
|
|
148
|
+
'of this failure is simply being offline.',
|
|
149
|
+
].join('\n')
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
package/src/sync.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, chmodSync } from 'fs';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { log } from '@clack/prompts';
|
|
5
|
+
import { TEMPLATES_DIR, applyTemplate, writeFile, sha256, enumerateClaudeTargets } from './utils.js';
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
9
|
+
|
|
10
|
+
// Files the scaffolder writes once but sync must NOT manage afterwards.
|
|
11
|
+
// Empty today; add a templateSrc path (forward-slashed, e.g.
|
|
12
|
+
// 'claude/skills/foo/SKILL.md') to opt a future scaffold-once file out of sync.
|
|
13
|
+
const SYNC_EXCLUDE = [];
|
|
14
|
+
|
|
15
|
+
// Every file under templates/claude/** is synced infrastructure (skills, hooks,
|
|
16
|
+
// settings, spec-templates, instructions). Derived by walking the tree rather
|
|
17
|
+
// than a hardcoded list, so new files flow into `bspecs sync` and bspecs.lock
|
|
18
|
+
// automatically — no hand-maintained array to drift. templates/root/ (user-owned
|
|
19
|
+
// CLAUDE.md/README) and templates/module/ (scaffold-once) live outside this tree
|
|
20
|
+
// and are excluded by construction. Claude-only: no .github mirror.
|
|
21
|
+
export const SYNC_TARGETS = enumerateClaudeTargets(SYNC_EXCLUDE);
|
|
22
|
+
|
|
23
|
+
function findProjectRoot(startDir) {
|
|
24
|
+
let dir = startDir;
|
|
25
|
+
while (true) {
|
|
26
|
+
if (existsSync(join(dir, '.claude', 'bspecs.lock'))) return dir;
|
|
27
|
+
const parent = dirname(dir);
|
|
28
|
+
if (parent === dir) return null;
|
|
29
|
+
dir = parent;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readLock(projectRoot) {
|
|
34
|
+
return JSON.parse(readFileSync(join(projectRoot, '.claude', 'bspecs.lock'), 'utf8'));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeLock(projectRoot, obj) {
|
|
38
|
+
writeFileSync(join(projectRoot, '.claude', 'bspecs.lock'), JSON.stringify(obj, null, 2) + '\n', 'utf8');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function syncFiles(projectRoot, lock, silent) {
|
|
42
|
+
const vars = lock.vars || {};
|
|
43
|
+
const newFiles = {};
|
|
44
|
+
let updated = 0;
|
|
45
|
+
let skipped = 0;
|
|
46
|
+
|
|
47
|
+
for (const target of SYNC_TARGETS) {
|
|
48
|
+
const templatePath = join(TEMPLATES_DIR, target.templateSrc);
|
|
49
|
+
if (!existsSync(templatePath)) continue;
|
|
50
|
+
|
|
51
|
+
const newContent = applyTemplate(readFileSync(templatePath, 'utf8'), vars);
|
|
52
|
+
const newHash = sha256(newContent);
|
|
53
|
+
const destAbs = join(projectRoot, target.destRel);
|
|
54
|
+
const lockHash = lock.files[target.destRel];
|
|
55
|
+
|
|
56
|
+
if (!existsSync(destAbs)) {
|
|
57
|
+
writeFile(destAbs, newContent);
|
|
58
|
+
if (destAbs.endsWith('.sh')) {
|
|
59
|
+
try { chmodSync(destAbs, 0o755); } catch { /* Windows */ }
|
|
60
|
+
}
|
|
61
|
+
newFiles[target.destRel] = newHash;
|
|
62
|
+
updated++;
|
|
63
|
+
if (!silent) log.info(` added ${target.destRel}`);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const diskHash = sha256(readFileSync(destAbs, 'utf8'));
|
|
68
|
+
|
|
69
|
+
if (lockHash !== undefined && diskHash !== lockHash) {
|
|
70
|
+
// Usuario editó este archivo localmente — no tocar
|
|
71
|
+
newFiles[target.destRel] = lockHash;
|
|
72
|
+
skipped++;
|
|
73
|
+
if (!silent) log.warn(` skipped ${target.destRel} (locally modified)`);
|
|
74
|
+
} else {
|
|
75
|
+
writeFile(destAbs, newContent);
|
|
76
|
+
if (destAbs.endsWith('.sh')) {
|
|
77
|
+
try { chmodSync(destAbs, 0o755); } catch { /* Windows */ }
|
|
78
|
+
}
|
|
79
|
+
newFiles[target.destRel] = newHash;
|
|
80
|
+
if (diskHash !== newHash) {
|
|
81
|
+
updated++;
|
|
82
|
+
if (!silent) log.info(` updated ${target.destRel}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { updated, skipped, newFiles };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function sync({ silent = false } = {}) {
|
|
91
|
+
try {
|
|
92
|
+
const projectRoot = findProjectRoot(process.cwd());
|
|
93
|
+
|
|
94
|
+
if (!projectRoot) {
|
|
95
|
+
if (!silent) {
|
|
96
|
+
console.error(
|
|
97
|
+
'No bspecs.lock found. Run bspecs sync from a project scaffolded with bspecs 0.5.0 or later.\n' +
|
|
98
|
+
'If this project was scaffolded earlier, re-run bspecs in the parent directory to re-scaffold.'
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const lock = readLock(projectRoot);
|
|
105
|
+
if (!silent) log.info(`Syncing infrastructure files in: ${projectRoot}`);
|
|
106
|
+
|
|
107
|
+
const { updated, skipped, newFiles } = syncFiles(projectRoot, lock, silent);
|
|
108
|
+
|
|
109
|
+
writeLock(projectRoot, {
|
|
110
|
+
bspecs_version: pkg.version,
|
|
111
|
+
synced_at: new Date().toISOString().split('T')[0],
|
|
112
|
+
vars: lock.vars || {},
|
|
113
|
+
files: newFiles,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (!silent) {
|
|
117
|
+
log.success(`Sync complete. Updated ${updated} file${updated !== 1 ? 's' : ''}, skipped ${skipped} (locally modified).`);
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if (!silent) throw err;
|
|
121
|
+
// En modo --silent (hook SessionStart): nunca bloquear el startup de Claude Code
|
|
122
|
+
}
|
|
123
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, readdirSync, statSync } from 'fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
export const TEMPLATES_DIR = join(__dirname, '..', 'templates');
|
|
8
|
+
|
|
9
|
+
export function applyTemplate(str, vars) {
|
|
10
|
+
return str.replace(/\{\{(\w+)\}\}/g, (match, key) =>
|
|
11
|
+
Object.prototype.hasOwnProperty.call(vars, key) ? vars[key] : match
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ensureDir(dir) {
|
|
16
|
+
mkdirSync(dir, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function writeFile(path, content) {
|
|
20
|
+
ensureDir(dirname(path));
|
|
21
|
+
writeFileSync(path, content, 'utf8');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function readTemplate(relativePath) {
|
|
25
|
+
return readFileSync(join(TEMPLATES_DIR, relativePath), 'utf8');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function copyTemplateTree(srcRel, destAbs, vars, opts = {}) {
|
|
29
|
+
const { makeExecutable = false, stripTemplateExt = true } = opts;
|
|
30
|
+
const srcAbs = join(TEMPLATES_DIR, srcRel);
|
|
31
|
+
if (!existsSync(srcAbs)) return;
|
|
32
|
+
walk(srcAbs, srcAbs, destAbs, vars, { makeExecutable, stripTemplateExt });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function walk(rootSrc, src, dest, vars, opts) {
|
|
36
|
+
for (const entry of readdirSync(src)) {
|
|
37
|
+
const srcEntry = join(src, entry);
|
|
38
|
+
const stats = statSync(srcEntry);
|
|
39
|
+
if (stats.isDirectory()) {
|
|
40
|
+
walk(rootSrc, srcEntry, join(dest, entry), vars, opts);
|
|
41
|
+
} else {
|
|
42
|
+
const targetName = opts.stripTemplateExt && entry.endsWith('.template')
|
|
43
|
+
? entry.slice(0, -'.template'.length)
|
|
44
|
+
: entry;
|
|
45
|
+
const destFile = join(dest, targetName);
|
|
46
|
+
const raw = readFileSync(srcEntry, 'utf8');
|
|
47
|
+
const rendered = applyTemplate(raw, vars);
|
|
48
|
+
writeFile(destFile, rendered);
|
|
49
|
+
if (opts.makeExecutable && destFile.endsWith('.sh')) {
|
|
50
|
+
try { chmodSync(destFile, 0o755); } catch { /* Windows can't chmod, ignore */ }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Walk templates/claude/** and return one sync target per file:
|
|
57
|
+
// { templateSrc, destRel }
|
|
58
|
+
// templateSrc is relative to TEMPLATES_DIR (forward-slashed, e.g.
|
|
59
|
+
// 'claude/instructions/reference/foo.md.template'); destRel maps it into the
|
|
60
|
+
// scaffolded project ('.claude/instructions/reference/foo.md') — leading
|
|
61
|
+
// 'claude/' becomes '.claude/', a trailing '.template' is stripped, subfolders
|
|
62
|
+
// preserved. This is the same transform copyTemplateTree applies, so it
|
|
63
|
+
// reproduces the formerly-hardcoded skills/hooks/settings/spec-template entries
|
|
64
|
+
// exactly and picks up the instructions tree automatically. Claude-only: no
|
|
65
|
+
// .github mirror target is emitted. `exclude` lists templateSrc paths to skip —
|
|
66
|
+
// the escape hatch for any future scaffold-once file under claude/.
|
|
67
|
+
export function enumerateClaudeTargets(exclude = []) {
|
|
68
|
+
const root = join(TEMPLATES_DIR, 'claude');
|
|
69
|
+
if (!existsSync(root)) return [];
|
|
70
|
+
const skip = new Set(exclude);
|
|
71
|
+
const targets = [];
|
|
72
|
+
walkClaude(root, 'claude', skip, targets);
|
|
73
|
+
return targets;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function walkClaude(absDir, relDir, skip, targets) {
|
|
77
|
+
for (const entry of readdirSync(absDir).sort()) {
|
|
78
|
+
const abs = join(absDir, entry);
|
|
79
|
+
const rel = `${relDir}/${entry}`;
|
|
80
|
+
if (statSync(abs).isDirectory()) {
|
|
81
|
+
walkClaude(abs, rel, skip, targets);
|
|
82
|
+
} else if (!skip.has(rel)) {
|
|
83
|
+
const destRel = '.claude/' + rel.slice('claude/'.length).replace(/\.template$/, '');
|
|
84
|
+
targets.push({ templateSrc: rel, destRel });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function exists(path) {
|
|
90
|
+
return existsSync(path);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function sha256(str) {
|
|
94
|
+
return createHash('sha256').update(str, 'utf8').digest('hex');
|
|
95
|
+
}
|