start-vibing-stacks 2.23.0 → 2.25.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 +15 -4
- package/dist/index.js +5 -1
- package/dist/migrate.d.ts +33 -11
- package/dist/migrate.js +312 -42
- package/package.json +1 -1
- package/stacks/_shared/hooks/_state.ts +1 -0
- package/stacks/_shared/hooks/final-check.ts +1 -0
- package/stacks/_shared/hooks/peers.ts +1 -0
- package/stacks/_shared/hooks/post-tool-use.ts +1 -0
- package/stacks/_shared/hooks/pre-tool-use.ts +1 -0
- package/stacks/_shared/hooks/run-hook.ts +1 -0
- package/stacks/_shared/hooks/session-start.ts +1 -0
- package/stacks/_shared/hooks/stop-validator.ts +1 -0
- package/stacks/_shared/hooks/user-prompt-submit.ts +1 -0
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Multi-stack AI workflow for **Claude Code** & **Cursor**. One command installs a
|
|
|
6
6
|
npx start-vibing-stacks
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
> Latest: **v2.
|
|
9
|
+
> Latest: **v2.25.0** — `--force-hooks` (alias `--force-legacy`) now also covers legacy commands (`commands/*.md` without `version:` frontmatter), not just hooks. Single command rebuilds installs older than v2.23.0. v2.24.0 → smart migrate (versioned hooks/commands, idempotent `settings.json`, runtime dirs, `.gitignore`). v2.23.0 → multi-instance coordination layer.
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
@@ -130,13 +130,24 @@ Single-host coordination only. It does **not** replace `git` for cross-machine c
|
|
|
130
130
|
## CLI
|
|
131
131
|
|
|
132
132
|
```bash
|
|
133
|
-
npx start-vibing-stacks
|
|
134
|
-
npx start-vibing-stacks migrate
|
|
135
|
-
npx start-vibing-stacks migrate --apply
|
|
133
|
+
npx start-vibing-stacks # setup or resume current project
|
|
134
|
+
npx start-vibing-stacks migrate # dry run: skills + agents + hooks + commands + settings.json + runtime dirs
|
|
135
|
+
npx start-vibing-stacks migrate --apply # apply (idempotent; preserves customizations)
|
|
136
|
+
npx start-vibing-stacks migrate --apply --force-legacy # also overwrite legacy hooks AND commands (no version) — creates .bak
|
|
137
|
+
npx start-vibing-stacks migrate --apply --force-hooks # alias for --force-legacy (back-compat)
|
|
136
138
|
|
|
137
139
|
# flags: --force --no-claude --no-mcp --no-install --help --version
|
|
138
140
|
```
|
|
139
141
|
|
|
142
|
+
`migrate` is the safe upgrade path between releases. Each release gets:
|
|
143
|
+
- **Hooks** versioned via `// @sv-version: x.y.z` header — semver-compared against installed.
|
|
144
|
+
- **Commands** versioned via frontmatter `version:` — same as skills/agents.
|
|
145
|
+
- **`settings.json#hooks`** — idempotent deep-merge: missing event blocks (`SessionStart`, `PreToolUse`, `PostToolUse`, `SessionEnd`) are appended; existing entries (including yours) are preserved.
|
|
146
|
+
- **Runtime artefacts** — `.claude/state/{sessions,inbox,file-touches}/_archive` auto-created; `_state.README.md` staged.
|
|
147
|
+
- **`.gitignore`** — `.claude/state/` appended if not already covered.
|
|
148
|
+
|
|
149
|
+
Legacy files without versioning — hooks without `// @sv-version` AND commands without `version:` frontmatter (anything installed before the versioning landed) — are reported as `needs-update-legacy` and skipped by default. Pass `--force-legacy` (or its alias `--force-hooks`) to overwrite them — each gets a timestamped `.bak`. Skills and agents are intentionally **not** covered because users customize them; they stay in `modified-no-version` (advisory only).
|
|
150
|
+
|
|
140
151
|
Global install: `npm i -g start-vibing-stacks` → `svs` (alias).
|
|
141
152
|
|
|
142
153
|
---
|
package/dist/index.js
CHANGED
|
@@ -33,6 +33,7 @@ const FLAGS = {
|
|
|
33
33
|
help: args.includes('--help') || args.includes('-h'),
|
|
34
34
|
version: args.includes('--version') || args.includes('-v'),
|
|
35
35
|
apply: args.includes('--apply'),
|
|
36
|
+
forceHooks: args.includes('--force-hooks') || args.includes('--force-legacy'),
|
|
36
37
|
};
|
|
37
38
|
if (FLAGS.version) {
|
|
38
39
|
console.log(PKG_VERSION);
|
|
@@ -52,6 +53,9 @@ if (FLAGS.help) {
|
|
|
52
53
|
${chalk.bold('Options:')}
|
|
53
54
|
--force Overwrite existing configuration (default command)
|
|
54
55
|
--apply Apply updates (migrate command)
|
|
56
|
+
--force-legacy Overwrite legacy hooks (no @sv-version) AND legacy commands
|
|
57
|
+
(no version: frontmatter) — each gets a timestamped .bak
|
|
58
|
+
--force-hooks Alias for --force-legacy (kept for back-compat)
|
|
55
59
|
--no-claude Skip Claude Code installation
|
|
56
60
|
--no-mcp Skip MCP server selection
|
|
57
61
|
--no-install Skip dependency installation
|
|
@@ -68,7 +72,7 @@ if (FLAGS.help) {
|
|
|
68
72
|
// Subcommand: migrate
|
|
69
73
|
if (SUBCOMMAND === 'migrate') {
|
|
70
74
|
const { runMigrate } = await import('./migrate.js');
|
|
71
|
-
await runMigrate(process.cwd(), { apply: FLAGS.apply });
|
|
75
|
+
await runMigrate(process.cwd(), { apply: FLAGS.apply, forceHooks: FLAGS.forceHooks });
|
|
72
76
|
process.exit(0);
|
|
73
77
|
}
|
|
74
78
|
const AVAILABLE_STACKS = [
|
package/dist/migrate.d.ts
CHANGED
|
@@ -1,27 +1,49 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Start Vibing Stacks — Migrate
|
|
2
|
+
* Start Vibing Stacks — Migrate (v2 — smart, idempotent, settings-aware)
|
|
3
3
|
*
|
|
4
|
-
* Compares
|
|
5
|
-
*
|
|
4
|
+
* Compares installed vs bundled versions for skills, agents, hooks AND commands.
|
|
5
|
+
* Also reconciles runtime artefacts that earlier migrate versions ignored:
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* - hooks: parsed via `// @sv-version: x.y.z` header. Legacy installs (no tag)
|
|
8
|
+
* are skipped with a "needs-update-legacy" warning unless `--force-hooks` is
|
|
9
|
+
* passed (which makes a `.bak` copy before overwriting).
|
|
10
|
+
* - commands: same `version:` frontmatter contract as skills/agents.
|
|
11
|
+
* - .claude/settings.json — patched idempotently to ensure the bundled hook
|
|
12
|
+
* chain (SessionStart / UserPromptSubmit / PreToolUse / PostToolUse / Stop /
|
|
13
|
+
* SessionEnd) is wired. Existing entries are preserved; only missing entries
|
|
14
|
+
* are appended. Atomic write (.tmp + rename).
|
|
15
|
+
* - .claude/state/ — runtime layout for multi-instance coordination is
|
|
16
|
+
* auto-created and the `_state.README.md` is staged as `.claude/state/README.md`.
|
|
17
|
+
* - .gitignore — `.claude/state/` is appended if not already covered.
|
|
12
18
|
*/
|
|
19
|
+
type Status = 'missing' | 'outdated' | 'current' | 'ahead' | 'modified-no-version' | 'needs-update-legacy';
|
|
13
20
|
export interface MigrateItem {
|
|
14
|
-
kind: 'skill' | 'agent' | 'hook';
|
|
21
|
+
kind: 'skill' | 'agent' | 'hook' | 'command';
|
|
15
22
|
name: string;
|
|
16
23
|
source: string;
|
|
17
24
|
target: string;
|
|
18
25
|
bundledVersion: string;
|
|
19
26
|
installedVersion: string | null;
|
|
20
|
-
status:
|
|
27
|
+
status: Status;
|
|
21
28
|
}
|
|
22
29
|
export interface MigrateOptions {
|
|
23
30
|
apply: boolean;
|
|
24
|
-
scope?: 'skills' | 'agents' | 'hooks' | 'all';
|
|
31
|
+
scope?: 'skills' | 'agents' | 'hooks' | 'commands' | 'all';
|
|
32
|
+
forceHooks?: boolean;
|
|
25
33
|
}
|
|
26
34
|
export declare function planMigration(projectDir: string, opts: MigrateOptions): MigrateItem[];
|
|
35
|
+
interface SettingsPatchReport {
|
|
36
|
+
added: string[];
|
|
37
|
+
alreadyPresent: string[];
|
|
38
|
+
fileExisted: boolean;
|
|
39
|
+
error?: string;
|
|
40
|
+
}
|
|
41
|
+
export declare function patchSettings(projectDir: string, dryRun: boolean): SettingsPatchReport;
|
|
42
|
+
interface RuntimeReport {
|
|
43
|
+
created: string[];
|
|
44
|
+
copied: string[];
|
|
45
|
+
gitignoreUpdated: boolean;
|
|
46
|
+
}
|
|
47
|
+
export declare function ensureRuntimeArtefacts(projectDir: string, dryRun: boolean): RuntimeReport;
|
|
27
48
|
export declare function runMigrate(projectDir: string, opts: MigrateOptions): Promise<void>;
|
|
49
|
+
export {};
|
package/dist/migrate.js
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Start Vibing Stacks — Migrate
|
|
2
|
+
* Start Vibing Stacks — Migrate (v2 — smart, idempotent, settings-aware)
|
|
3
3
|
*
|
|
4
|
-
* Compares
|
|
5
|
-
*
|
|
4
|
+
* Compares installed vs bundled versions for skills, agents, hooks AND commands.
|
|
5
|
+
* Also reconciles runtime artefacts that earlier migrate versions ignored:
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* - hooks: parsed via `// @sv-version: x.y.z` header. Legacy installs (no tag)
|
|
8
|
+
* are skipped with a "needs-update-legacy" warning unless `--force-hooks` is
|
|
9
|
+
* passed (which makes a `.bak` copy before overwriting).
|
|
10
|
+
* - commands: same `version:` frontmatter contract as skills/agents.
|
|
11
|
+
* - .claude/settings.json — patched idempotently to ensure the bundled hook
|
|
12
|
+
* chain (SessionStart / UserPromptSubmit / PreToolUse / PostToolUse / Stop /
|
|
13
|
+
* SessionEnd) is wired. Existing entries are preserved; only missing entries
|
|
14
|
+
* are appended. Atomic write (.tmp + rename).
|
|
15
|
+
* - .claude/state/ — runtime layout for multi-instance coordination is
|
|
16
|
+
* auto-created and the `_state.README.md` is staged as `.claude/state/README.md`.
|
|
17
|
+
* - .gitignore — `.claude/state/` is appended if not already covered.
|
|
12
18
|
*/
|
|
13
|
-
import { existsSync, readFileSync, copyFileSync, mkdirSync, statSync, readdirSync } from 'fs';
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync, statSync, readdirSync, renameSync, } from 'fs';
|
|
14
20
|
import { join, relative, dirname, resolve } from 'path';
|
|
15
21
|
import { fileURLToPath } from 'url';
|
|
22
|
+
import { randomBytes } from 'crypto';
|
|
16
23
|
import * as semver from 'semver';
|
|
17
24
|
import * as ui from './ui.js';
|
|
18
25
|
const __m_filename = fileURLToPath(import.meta.url);
|
|
@@ -20,7 +27,8 @@ const __m_dirname = dirname(__m_filename);
|
|
|
20
27
|
const CLI_ROOT = resolve(__m_dirname, '..');
|
|
21
28
|
const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---/;
|
|
22
29
|
const VERSION_RE = /^version:\s*["']?([0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?)["']?\s*$/m;
|
|
23
|
-
|
|
30
|
+
const SV_VERSION_RE = /\/\/\s*@sv-version:\s*([0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?)/;
|
|
31
|
+
function parseFrontmatterVersion(file) {
|
|
24
32
|
if (!existsSync(file))
|
|
25
33
|
return null;
|
|
26
34
|
try {
|
|
@@ -35,7 +43,19 @@ function parseVersion(file) {
|
|
|
35
43
|
return null;
|
|
36
44
|
}
|
|
37
45
|
}
|
|
38
|
-
function
|
|
46
|
+
function parseHookVersion(file) {
|
|
47
|
+
if (!existsSync(file))
|
|
48
|
+
return null;
|
|
49
|
+
try {
|
|
50
|
+
const content = readFileSync(file, 'utf8').slice(0, 1024);
|
|
51
|
+
const m = SV_VERSION_RE.exec(content);
|
|
52
|
+
return m?.[1] ?? null;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function statusFromSemver(bundled, installed) {
|
|
39
59
|
if (installed === null)
|
|
40
60
|
return 'modified-no-version';
|
|
41
61
|
const cmp = semver.compare(installed, bundled);
|
|
@@ -62,11 +82,11 @@ function listSubdirs(dir) {
|
|
|
62
82
|
return [];
|
|
63
83
|
}
|
|
64
84
|
}
|
|
65
|
-
function listFiles(dir,
|
|
85
|
+
function listFiles(dir, suffixes) {
|
|
66
86
|
if (!existsSync(dir))
|
|
67
87
|
return [];
|
|
68
88
|
try {
|
|
69
|
-
return readdirSync(dir).filter(n => n.endsWith(
|
|
89
|
+
return readdirSync(dir).filter(n => suffixes.some(s => n.endsWith(s)) && !n.startsWith('_state.README'));
|
|
70
90
|
}
|
|
71
91
|
catch {
|
|
72
92
|
return [];
|
|
@@ -90,11 +110,16 @@ function listSkillSources(stack, frontendSkillsDir) {
|
|
|
90
110
|
}
|
|
91
111
|
function listAgentSources() {
|
|
92
112
|
const dir = join(CLI_ROOT, 'stacks', '_shared', 'agents');
|
|
93
|
-
return listFiles(dir, '.md').map(n => ({ source: join(dir, n), name: n }));
|
|
113
|
+
return listFiles(dir, ['.md']).map(n => ({ source: join(dir, n), name: n }));
|
|
94
114
|
}
|
|
95
115
|
function listHookSources() {
|
|
96
116
|
const dir = join(CLI_ROOT, 'stacks', '_shared', 'hooks');
|
|
97
|
-
|
|
117
|
+
// Track .ts and .sh files (runnable hooks + shared lib). _state.README.md is handled separately.
|
|
118
|
+
return listFiles(dir, ['.ts', '.sh']).map(n => ({ source: join(dir, n), name: n }));
|
|
119
|
+
}
|
|
120
|
+
function listCommandSources() {
|
|
121
|
+
const dir = join(CLI_ROOT, 'stacks', '_shared', 'commands');
|
|
122
|
+
return listFiles(dir, ['.md']).map(n => ({ source: join(dir, n), name: n }));
|
|
98
123
|
}
|
|
99
124
|
function loadProjectConfig(projectDir) {
|
|
100
125
|
const path = join(projectDir, '.claude', 'config', 'active-project.json');
|
|
@@ -118,53 +143,223 @@ export function planMigration(projectDir, opts) {
|
|
|
118
143
|
if (scope === 'all' || scope === 'skills') {
|
|
119
144
|
for (const { source, name } of listSkillSources(config.stack, config.frontendSkillsDir)) {
|
|
120
145
|
const target = join(projectDir, '.claude', 'skills', name, 'SKILL.md');
|
|
121
|
-
const bundledVersion =
|
|
146
|
+
const bundledVersion = parseFrontmatterVersion(source);
|
|
122
147
|
if (!bundledVersion)
|
|
123
148
|
continue;
|
|
124
|
-
const installedVersion =
|
|
125
|
-
const status = !existsSync(target)
|
|
126
|
-
? 'missing'
|
|
127
|
-
: statusOf(bundledVersion, installedVersion);
|
|
149
|
+
const installedVersion = parseFrontmatterVersion(target);
|
|
150
|
+
const status = !existsSync(target) ? 'missing' : statusFromSemver(bundledVersion, installedVersion);
|
|
128
151
|
items.push({ kind: 'skill', name, source, target, bundledVersion, installedVersion, status });
|
|
129
152
|
}
|
|
130
153
|
}
|
|
131
154
|
if (scope === 'all' || scope === 'agents') {
|
|
132
155
|
for (const { source, name } of listAgentSources()) {
|
|
133
156
|
const target = join(projectDir, '.claude', 'agents', name);
|
|
134
|
-
const bundledVersion =
|
|
157
|
+
const bundledVersion = parseFrontmatterVersion(source);
|
|
135
158
|
if (!bundledVersion)
|
|
136
159
|
continue;
|
|
137
|
-
const installedVersion =
|
|
138
|
-
const status = !existsSync(target)
|
|
139
|
-
? 'missing'
|
|
140
|
-
: statusOf(bundledVersion, installedVersion);
|
|
160
|
+
const installedVersion = parseFrontmatterVersion(target);
|
|
161
|
+
const status = !existsSync(target) ? 'missing' : statusFromSemver(bundledVersion, installedVersion);
|
|
141
162
|
items.push({ kind: 'agent', name, source, target, bundledVersion, installedVersion, status });
|
|
142
163
|
}
|
|
143
164
|
}
|
|
144
165
|
if (scope === 'all' || scope === 'hooks') {
|
|
145
166
|
for (const { source, name } of listHookSources()) {
|
|
146
167
|
const target = join(projectDir, '.claude', 'hooks', name);
|
|
147
|
-
const bundledVersion = '0.0.0';
|
|
148
|
-
const installedVersion =
|
|
149
|
-
|
|
168
|
+
const bundledVersion = parseHookVersion(source) ?? '0.0.0';
|
|
169
|
+
const installedVersion = parseHookVersion(target);
|
|
170
|
+
let status;
|
|
171
|
+
if (!existsSync(target)) {
|
|
172
|
+
status = 'missing';
|
|
173
|
+
}
|
|
174
|
+
else if (installedVersion === null) {
|
|
175
|
+
status = bundledVersion === '0.0.0' ? 'current' : 'needs-update-legacy';
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
status = statusFromSemver(bundledVersion, installedVersion);
|
|
179
|
+
}
|
|
150
180
|
items.push({ kind: 'hook', name, source, target, bundledVersion, installedVersion, status });
|
|
151
181
|
}
|
|
152
182
|
}
|
|
183
|
+
if (scope === 'all' || scope === 'commands') {
|
|
184
|
+
for (const { source, name } of listCommandSources()) {
|
|
185
|
+
const target = join(projectDir, '.claude', 'commands', name);
|
|
186
|
+
const bundledVersion = parseFrontmatterVersion(source);
|
|
187
|
+
if (!bundledVersion)
|
|
188
|
+
continue;
|
|
189
|
+
const installedVersion = parseFrontmatterVersion(target);
|
|
190
|
+
let status;
|
|
191
|
+
if (!existsSync(target)) {
|
|
192
|
+
status = 'missing';
|
|
193
|
+
}
|
|
194
|
+
else if (installedVersion === null) {
|
|
195
|
+
// Commands are canonical infra files (slash-command routes). When the
|
|
196
|
+
// installed copy lacks `version:` frontmatter it predates versioned
|
|
197
|
+
// commands — treat as legacy so `--force-hooks` can heal it (same
|
|
198
|
+
// .bak machinery as legacy hooks). Skills/agents stay manual-review.
|
|
199
|
+
status = 'needs-update-legacy';
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
status = statusFromSemver(bundledVersion, installedVersion);
|
|
203
|
+
}
|
|
204
|
+
items.push({ kind: 'command', name, source, target, bundledVersion, installedVersion, status });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
153
207
|
return items;
|
|
154
208
|
}
|
|
155
|
-
function
|
|
209
|
+
function backup(target) {
|
|
210
|
+
if (!existsSync(target))
|
|
211
|
+
return null;
|
|
212
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
213
|
+
const dest = `${target}.${stamp}.bak`;
|
|
214
|
+
try {
|
|
215
|
+
copyFileSync(target, dest);
|
|
216
|
+
return dest;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function applyOne(item, opts) {
|
|
156
223
|
mkdirSync(dirname(item.target), { recursive: true });
|
|
224
|
+
if (item.status === 'needs-update-legacy') {
|
|
225
|
+
if (!opts.forceHooks)
|
|
226
|
+
return { applied: false };
|
|
227
|
+
const backupPath = backup(item.target);
|
|
228
|
+
copyFileSync(item.source, item.target);
|
|
229
|
+
return { applied: true, backupPath };
|
|
230
|
+
}
|
|
157
231
|
copyFileSync(item.source, item.target);
|
|
232
|
+
return { applied: true };
|
|
158
233
|
}
|
|
234
|
+
const REQUIRED_HOOK_BLOCKS = {
|
|
235
|
+
SessionStart: {
|
|
236
|
+
hooks: [
|
|
237
|
+
{ type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.ts"', timeout: 10 },
|
|
238
|
+
],
|
|
239
|
+
},
|
|
240
|
+
UserPromptSubmit: {
|
|
241
|
+
matcher: '',
|
|
242
|
+
hooks: [
|
|
243
|
+
{ type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/user-prompt-submit.ts"', timeout: 10 },
|
|
244
|
+
],
|
|
245
|
+
},
|
|
246
|
+
PreToolUse: {
|
|
247
|
+
matcher: 'Edit|Write|MultiEdit|NotebookEdit',
|
|
248
|
+
hooks: [
|
|
249
|
+
{ type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-tool-use.ts"', timeout: 5 },
|
|
250
|
+
],
|
|
251
|
+
},
|
|
252
|
+
PostToolUse: {
|
|
253
|
+
matcher: 'Edit|Write|MultiEdit|NotebookEdit',
|
|
254
|
+
hooks: [
|
|
255
|
+
{ type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/post-tool-use.ts"', timeout: 5 },
|
|
256
|
+
],
|
|
257
|
+
},
|
|
258
|
+
Stop: {
|
|
259
|
+
hooks: [
|
|
260
|
+
{ type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-validator.ts"', timeout: 30 },
|
|
261
|
+
],
|
|
262
|
+
},
|
|
263
|
+
SessionEnd: {
|
|
264
|
+
hooks: [
|
|
265
|
+
{ type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-validator.ts"', timeout: 10 },
|
|
266
|
+
],
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
function commandSubstring(cmd) {
|
|
270
|
+
const m = cmd.match(/\.claude\/hooks\/([A-Za-z0-9._-]+\.(?:ts|sh|js|mjs))/);
|
|
271
|
+
return m?.[1] ?? cmd;
|
|
272
|
+
}
|
|
273
|
+
function writeFileAtomic(target, content) {
|
|
274
|
+
const tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
275
|
+
writeFileSync(tmp, content);
|
|
276
|
+
renameSync(tmp, target);
|
|
277
|
+
}
|
|
278
|
+
export function patchSettings(projectDir, dryRun) {
|
|
279
|
+
const path = join(projectDir, '.claude', 'settings.json');
|
|
280
|
+
const report = { added: [], alreadyPresent: [], fileExisted: existsSync(path) };
|
|
281
|
+
let settings = {};
|
|
282
|
+
if (existsSync(path)) {
|
|
283
|
+
try {
|
|
284
|
+
settings = JSON.parse(readFileSync(path, 'utf8'));
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
report.error = `settings.json is not valid JSON (${err.message}) — skipping patch.`;
|
|
288
|
+
return report;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
settings.hooks = settings.hooks || {};
|
|
292
|
+
for (const [event, requiredBlock] of Object.entries(REQUIRED_HOOK_BLOCKS)) {
|
|
293
|
+
const existingBlocks = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
|
|
294
|
+
const requiredScript = commandSubstring(requiredBlock.hooks[0].command);
|
|
295
|
+
const alreadyHas = existingBlocks.some(b => (b.hooks || []).some(h => commandSubstring(h.command).includes(requiredScript)));
|
|
296
|
+
if (alreadyHas) {
|
|
297
|
+
report.alreadyPresent.push(event);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (!dryRun) {
|
|
301
|
+
settings.hooks[event] = [...existingBlocks, requiredBlock];
|
|
302
|
+
}
|
|
303
|
+
report.added.push(event);
|
|
304
|
+
}
|
|
305
|
+
if (!dryRun && report.added.length > 0 && !report.error) {
|
|
306
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
307
|
+
writeFileAtomic(path, JSON.stringify(settings, null, '\t'));
|
|
308
|
+
}
|
|
309
|
+
return report;
|
|
310
|
+
}
|
|
311
|
+
export function ensureRuntimeArtefacts(projectDir, dryRun) {
|
|
312
|
+
const report = { created: [], copied: [], gitignoreUpdated: false };
|
|
313
|
+
const stateRoot = join(projectDir, '.claude', 'state');
|
|
314
|
+
const dirs = [
|
|
315
|
+
join(stateRoot, 'sessions', '_archive'),
|
|
316
|
+
join(stateRoot, 'inbox'),
|
|
317
|
+
join(stateRoot, 'file-touches', '_archive'),
|
|
318
|
+
];
|
|
319
|
+
for (const d of dirs) {
|
|
320
|
+
if (!existsSync(d)) {
|
|
321
|
+
if (!dryRun)
|
|
322
|
+
mkdirSync(d, { recursive: true });
|
|
323
|
+
report.created.push(relative(projectDir, d));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const readmeSrc = join(CLI_ROOT, 'stacks', '_shared', 'hooks', '_state.README.md');
|
|
327
|
+
const readmeDest = join(stateRoot, 'README.md');
|
|
328
|
+
if (existsSync(readmeSrc) && !existsSync(readmeDest)) {
|
|
329
|
+
if (!dryRun) {
|
|
330
|
+
mkdirSync(dirname(readmeDest), { recursive: true });
|
|
331
|
+
copyFileSync(readmeSrc, readmeDest);
|
|
332
|
+
}
|
|
333
|
+
report.copied.push(relative(projectDir, readmeDest));
|
|
334
|
+
}
|
|
335
|
+
const giPath = join(projectDir, '.gitignore');
|
|
336
|
+
if (existsSync(giPath)) {
|
|
337
|
+
const gi = readFileSync(giPath, 'utf8');
|
|
338
|
+
const covered = /^\.claude\/state\/?$/m.test(gi) || /^\.claude\/?$/m.test(gi);
|
|
339
|
+
if (!covered) {
|
|
340
|
+
if (!dryRun) {
|
|
341
|
+
const next = gi.trimEnd() +
|
|
342
|
+
'\n\n# Claude Code multi-instance coordination state (runtime, per-host)\n.claude/state/\n';
|
|
343
|
+
writeFileSync(giPath, next);
|
|
344
|
+
}
|
|
345
|
+
report.gitignoreUpdated = true;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return report;
|
|
349
|
+
}
|
|
350
|
+
/* ---------------------------------------------------------------------------
|
|
351
|
+
* CLI runner
|
|
352
|
+
* -------------------------------------------------------------------------*/
|
|
159
353
|
export async function runMigrate(projectDir, opts) {
|
|
160
354
|
ui.header('🔄 Start Vibing — Migrate');
|
|
161
355
|
const items = planMigration(projectDir, opts);
|
|
162
|
-
if (items.length === 0) {
|
|
163
|
-
ui.info('Nothing to migrate.');
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
356
|
const grouped = {
|
|
167
|
-
missing: [],
|
|
357
|
+
missing: [],
|
|
358
|
+
outdated: [],
|
|
359
|
+
current: [],
|
|
360
|
+
ahead: [],
|
|
361
|
+
'modified-no-version': [],
|
|
362
|
+
'needs-update-legacy': [],
|
|
168
363
|
};
|
|
169
364
|
for (const it of items)
|
|
170
365
|
grouped[it.status].push(it);
|
|
@@ -174,44 +369,119 @@ export async function runMigrate(projectDir, opts) {
|
|
|
174
369
|
console.log(`\n ${label} (${list.length}):`);
|
|
175
370
|
for (const it of list.slice(0, 30)) {
|
|
176
371
|
const v = it.installedVersion ?? '–';
|
|
177
|
-
console.log(` ${it.kind.padEnd(
|
|
372
|
+
console.log(` ${it.kind.padEnd(7)} ${it.name.padEnd(32)} installed=${v.padEnd(8)} bundled=${it.bundledVersion}`);
|
|
178
373
|
}
|
|
179
374
|
if (list.length > 30)
|
|
180
375
|
console.log(` ... and ${list.length - 30} more`);
|
|
181
376
|
};
|
|
182
377
|
summary('MISSING', grouped.missing);
|
|
183
378
|
summary('OUTDATED', grouped.outdated);
|
|
379
|
+
summary('NEEDS UPDATE — legacy (hooks without @sv-version, commands without version: frontmatter)', grouped['needs-update-legacy']);
|
|
184
380
|
summary('AHEAD (installed newer than bundled — kept)', grouped.ahead);
|
|
185
381
|
summary('UNVERSIONED (manual review)', grouped['modified-no-version']);
|
|
186
382
|
summary('CURRENT', grouped.current);
|
|
187
|
-
|
|
188
|
-
|
|
383
|
+
// Settings.json + runtime — preview always, regardless of items.
|
|
384
|
+
const settingsPreview = patchSettings(projectDir, true);
|
|
385
|
+
const runtimePreview = ensureRuntimeArtefacts(projectDir, true);
|
|
386
|
+
if (settingsPreview.added.length > 0) {
|
|
387
|
+
console.log('\n SETTINGS.JSON — will add hook entries:');
|
|
388
|
+
for (const ev of settingsPreview.added)
|
|
389
|
+
console.log(` + ${ev}`);
|
|
390
|
+
}
|
|
391
|
+
if (settingsPreview.error) {
|
|
392
|
+
console.log(`\n SETTINGS.JSON — ${settingsPreview.error}`);
|
|
393
|
+
}
|
|
394
|
+
if (runtimePreview.created.length > 0) {
|
|
395
|
+
console.log('\n RUNTIME — will create dirs:');
|
|
396
|
+
for (const d of runtimePreview.created)
|
|
397
|
+
console.log(` + ${d}`);
|
|
398
|
+
}
|
|
399
|
+
if (runtimePreview.copied.length > 0) {
|
|
400
|
+
console.log('\n RUNTIME — will copy:');
|
|
401
|
+
for (const f of runtimePreview.copied)
|
|
402
|
+
console.log(` + ${f}`);
|
|
403
|
+
}
|
|
404
|
+
if (runtimePreview.gitignoreUpdated) {
|
|
405
|
+
console.log('\n GITIGNORE — will append `.claude/state/`');
|
|
406
|
+
}
|
|
407
|
+
const upgradable = [
|
|
408
|
+
...grouped.missing,
|
|
409
|
+
...grouped.outdated,
|
|
410
|
+
...(opts.forceHooks ? grouped['needs-update-legacy'] : []),
|
|
411
|
+
];
|
|
412
|
+
const nothingFile = upgradable.length === 0;
|
|
413
|
+
const nothingSettings = settingsPreview.added.length === 0;
|
|
414
|
+
const nothingRuntime = runtimePreview.created.length === 0 && runtimePreview.copied.length === 0 && !runtimePreview.gitignoreUpdated;
|
|
415
|
+
if (nothingFile && nothingSettings && nothingRuntime) {
|
|
189
416
|
console.log('');
|
|
190
417
|
ui.success('Everything up to date.');
|
|
191
418
|
return;
|
|
192
419
|
}
|
|
193
420
|
if (!opts.apply) {
|
|
194
421
|
console.log('');
|
|
195
|
-
|
|
422
|
+
const todo = [];
|
|
423
|
+
if (upgradable.length > 0)
|
|
424
|
+
todo.push(`${upgradable.length} file(s)`);
|
|
425
|
+
if (settingsPreview.added.length > 0)
|
|
426
|
+
todo.push(`${settingsPreview.added.length} settings entry(ies)`);
|
|
427
|
+
if (!nothingRuntime)
|
|
428
|
+
todo.push('runtime artefacts');
|
|
429
|
+
ui.info(`Run with --apply to update: ${todo.join(' + ')}.`);
|
|
430
|
+
if (grouped['needs-update-legacy'].length > 0 && !opts.forceHooks) {
|
|
431
|
+
ui.info(`${grouped['needs-update-legacy'].length} legacy file(s) detected (hooks without @sv-version, ` +
|
|
432
|
+
`commands without \`version:\` frontmatter). Use --force-legacy (alias --force-hooks) to ` +
|
|
433
|
+
`update them with a .bak backup.`);
|
|
434
|
+
}
|
|
196
435
|
return;
|
|
197
436
|
}
|
|
198
437
|
console.log('');
|
|
438
|
+
let updated = 0;
|
|
439
|
+
let legacyUpdated = 0;
|
|
199
440
|
for (const it of upgradable) {
|
|
200
441
|
try {
|
|
201
|
-
applyOne(it);
|
|
202
|
-
|
|
442
|
+
const r = applyOne(it, opts);
|
|
443
|
+
if (!r.applied)
|
|
444
|
+
continue;
|
|
445
|
+
updated++;
|
|
446
|
+
if (it.status === 'needs-update-legacy')
|
|
447
|
+
legacyUpdated++;
|
|
448
|
+
const bak = r.backupPath ? ` (backup: ${relative(projectDir, r.backupPath)})` : '';
|
|
449
|
+
ui.success(`updated ${it.kind} ${it.name} (${it.installedVersion ?? '–'} → ${it.bundledVersion})${bak}`);
|
|
203
450
|
}
|
|
204
451
|
catch (err) {
|
|
205
452
|
ui.warn(`failed ${it.kind} ${it.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
206
453
|
}
|
|
207
454
|
}
|
|
455
|
+
// Apply settings + runtime.
|
|
456
|
+
const settingsApply = patchSettings(projectDir, false);
|
|
457
|
+
const runtimeApply = ensureRuntimeArtefacts(projectDir, false);
|
|
458
|
+
for (const ev of settingsApply.added)
|
|
459
|
+
ui.success(`settings.json: wired ${ev}`);
|
|
460
|
+
if (settingsApply.error)
|
|
461
|
+
ui.warn(settingsApply.error);
|
|
462
|
+
for (const d of runtimeApply.created)
|
|
463
|
+
ui.success(`runtime: created ${d}`);
|
|
464
|
+
for (const f of runtimeApply.copied)
|
|
465
|
+
ui.success(`runtime: copied ${f}`);
|
|
466
|
+
if (runtimeApply.gitignoreUpdated)
|
|
467
|
+
ui.success('gitignore: appended .claude/state/');
|
|
208
468
|
console.log('');
|
|
209
|
-
ui.success(`Migration complete: ${
|
|
469
|
+
ui.success(`Migration complete: ${updated} file(s), ${settingsApply.added.length} settings entry(ies)` +
|
|
470
|
+
(legacyUpdated > 0 ? `, ${legacyUpdated} legacy file(s) overwritten with backup` : ''));
|
|
210
471
|
if (grouped['modified-no-version'].length > 0) {
|
|
211
472
|
console.log('');
|
|
212
|
-
ui.warn(`${grouped['modified-no-version'].length} unversioned local
|
|
473
|
+
ui.warn(`${grouped['modified-no-version'].length} unversioned local skill/agent/command(s) skipped — review manually.`);
|
|
213
474
|
for (const it of grouped['modified-no-version'].slice(0, 10)) {
|
|
214
475
|
console.log(` ${relative(projectDir, it.target)}`);
|
|
215
476
|
}
|
|
216
477
|
}
|
|
478
|
+
if (grouped['needs-update-legacy'].length > 0 && !opts.forceHooks) {
|
|
479
|
+
console.log('');
|
|
480
|
+
ui.warn(`${grouped['needs-update-legacy'].length} legacy file(s) skipped (hooks without @sv-version, ` +
|
|
481
|
+
`commands without \`version:\` frontmatter). Re-run with --force-legacy ` +
|
|
482
|
+
`(alias --force-hooks) to update them (each gets a .bak backup).`);
|
|
483
|
+
for (const it of grouped['needs-update-legacy'].slice(0, 10)) {
|
|
484
|
+
console.log(` ${relative(projectDir, it.target)}`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
217
487
|
}
|
package/package.json
CHANGED