start-vibing-stacks 2.23.0 → 2.24.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 +14 -4
- package/dist/index.js +3 -1
- package/dist/migrate.d.ts +33 -11
- package/dist/migrate.js +297 -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.24.0** — `migrate` is now smart and complete. Versioned hooks (`// @sv-version`), versioned commands, idempotent `settings.json` patcher, runtime dirs auto-creation, `.gitignore` update — all reachable via `npx start-vibing-stacks migrate --apply`. New `--force-hooks` flag overwrites legacy hooks (no `@sv-version`) with timestamped `.bak` backup. v2.23.0 → multi-instance coordination layer.
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
@@ -130,13 +130,23 @@ 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-hooks # also overwrite legacy hooks (no @sv-version) — creates .bak
|
|
136
137
|
|
|
137
138
|
# flags: --force --no-claude --no-mcp --no-install --help --version
|
|
138
139
|
```
|
|
139
140
|
|
|
141
|
+
`migrate` is the safe upgrade path between releases. Each release gets:
|
|
142
|
+
- **Hooks** versioned via `// @sv-version: x.y.z` header — semver-compared against installed.
|
|
143
|
+
- **Commands** versioned via frontmatter `version:` — same as skills/agents.
|
|
144
|
+
- **`settings.json#hooks`** — idempotent deep-merge: missing event blocks (`SessionStart`, `PreToolUse`, `PostToolUse`, `SessionEnd`) are appended; existing entries (including yours) are preserved.
|
|
145
|
+
- **Runtime artefacts** — `.claude/state/{sessions,inbox,file-touches}/_archive` auto-created; `_state.README.md` staged.
|
|
146
|
+
- **`.gitignore`** — `.claude/state/` appended if not already covered.
|
|
147
|
+
|
|
148
|
+
Legacy hooks without `@sv-version` (anything older than v2.24.0) are reported as `needs-update-legacy` and skipped by default. Pass `--force-hooks` to overwrite them — each gets a timestamped `.bak`.
|
|
149
|
+
|
|
140
150
|
Global install: `npm i -g start-vibing-stacks` → `svs` (alias).
|
|
141
151
|
|
|
142
152
|
---
|
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'),
|
|
36
37
|
};
|
|
37
38
|
if (FLAGS.version) {
|
|
38
39
|
console.log(PKG_VERSION);
|
|
@@ -52,6 +53,7 @@ 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-hooks Overwrite legacy hooks without @sv-version (migrate; creates .bak)
|
|
55
57
|
--no-claude Skip Claude Code installation
|
|
56
58
|
--no-mcp Skip MCP server selection
|
|
57
59
|
--no-install Skip dependency installation
|
|
@@ -68,7 +70,7 @@ if (FLAGS.help) {
|
|
|
68
70
|
// Subcommand: migrate
|
|
69
71
|
if (SUBCOMMAND === 'migrate') {
|
|
70
72
|
const { runMigrate } = await import('./migrate.js');
|
|
71
|
-
await runMigrate(process.cwd(), { apply: FLAGS.apply });
|
|
73
|
+
await runMigrate(process.cwd(), { apply: FLAGS.apply, forceHooks: FLAGS.forceHooks });
|
|
72
74
|
process.exit(0);
|
|
73
75
|
}
|
|
74
76
|
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,210 @@ 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
|
+
const status = !existsSync(target) ? 'missing' : statusFromSemver(bundledVersion, installedVersion);
|
|
191
|
+
items.push({ kind: 'command', name, source, target, bundledVersion, installedVersion, status });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
153
194
|
return items;
|
|
154
195
|
}
|
|
155
|
-
function
|
|
196
|
+
function backup(target) {
|
|
197
|
+
if (!existsSync(target))
|
|
198
|
+
return null;
|
|
199
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
200
|
+
const dest = `${target}.${stamp}.bak`;
|
|
201
|
+
try {
|
|
202
|
+
copyFileSync(target, dest);
|
|
203
|
+
return dest;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function applyOne(item, opts) {
|
|
156
210
|
mkdirSync(dirname(item.target), { recursive: true });
|
|
211
|
+
if (item.status === 'needs-update-legacy') {
|
|
212
|
+
if (!opts.forceHooks)
|
|
213
|
+
return { applied: false };
|
|
214
|
+
const backupPath = backup(item.target);
|
|
215
|
+
copyFileSync(item.source, item.target);
|
|
216
|
+
return { applied: true, backupPath };
|
|
217
|
+
}
|
|
157
218
|
copyFileSync(item.source, item.target);
|
|
219
|
+
return { applied: true };
|
|
220
|
+
}
|
|
221
|
+
const REQUIRED_HOOK_BLOCKS = {
|
|
222
|
+
SessionStart: {
|
|
223
|
+
hooks: [
|
|
224
|
+
{ type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.ts"', timeout: 10 },
|
|
225
|
+
],
|
|
226
|
+
},
|
|
227
|
+
UserPromptSubmit: {
|
|
228
|
+
matcher: '',
|
|
229
|
+
hooks: [
|
|
230
|
+
{ type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/user-prompt-submit.ts"', timeout: 10 },
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
PreToolUse: {
|
|
234
|
+
matcher: 'Edit|Write|MultiEdit|NotebookEdit',
|
|
235
|
+
hooks: [
|
|
236
|
+
{ type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-tool-use.ts"', timeout: 5 },
|
|
237
|
+
],
|
|
238
|
+
},
|
|
239
|
+
PostToolUse: {
|
|
240
|
+
matcher: 'Edit|Write|MultiEdit|NotebookEdit',
|
|
241
|
+
hooks: [
|
|
242
|
+
{ type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/post-tool-use.ts"', timeout: 5 },
|
|
243
|
+
],
|
|
244
|
+
},
|
|
245
|
+
Stop: {
|
|
246
|
+
hooks: [
|
|
247
|
+
{ type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-validator.ts"', timeout: 30 },
|
|
248
|
+
],
|
|
249
|
+
},
|
|
250
|
+
SessionEnd: {
|
|
251
|
+
hooks: [
|
|
252
|
+
{ type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-validator.ts"', timeout: 10 },
|
|
253
|
+
],
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
function commandSubstring(cmd) {
|
|
257
|
+
const m = cmd.match(/\.claude\/hooks\/([A-Za-z0-9._-]+\.(?:ts|sh|js|mjs))/);
|
|
258
|
+
return m?.[1] ?? cmd;
|
|
259
|
+
}
|
|
260
|
+
function writeFileAtomic(target, content) {
|
|
261
|
+
const tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
262
|
+
writeFileSync(tmp, content);
|
|
263
|
+
renameSync(tmp, target);
|
|
264
|
+
}
|
|
265
|
+
export function patchSettings(projectDir, dryRun) {
|
|
266
|
+
const path = join(projectDir, '.claude', 'settings.json');
|
|
267
|
+
const report = { added: [], alreadyPresent: [], fileExisted: existsSync(path) };
|
|
268
|
+
let settings = {};
|
|
269
|
+
if (existsSync(path)) {
|
|
270
|
+
try {
|
|
271
|
+
settings = JSON.parse(readFileSync(path, 'utf8'));
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
report.error = `settings.json is not valid JSON (${err.message}) — skipping patch.`;
|
|
275
|
+
return report;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
settings.hooks = settings.hooks || {};
|
|
279
|
+
for (const [event, requiredBlock] of Object.entries(REQUIRED_HOOK_BLOCKS)) {
|
|
280
|
+
const existingBlocks = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
|
|
281
|
+
const requiredScript = commandSubstring(requiredBlock.hooks[0].command);
|
|
282
|
+
const alreadyHas = existingBlocks.some(b => (b.hooks || []).some(h => commandSubstring(h.command).includes(requiredScript)));
|
|
283
|
+
if (alreadyHas) {
|
|
284
|
+
report.alreadyPresent.push(event);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (!dryRun) {
|
|
288
|
+
settings.hooks[event] = [...existingBlocks, requiredBlock];
|
|
289
|
+
}
|
|
290
|
+
report.added.push(event);
|
|
291
|
+
}
|
|
292
|
+
if (!dryRun && report.added.length > 0 && !report.error) {
|
|
293
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
294
|
+
writeFileAtomic(path, JSON.stringify(settings, null, '\t'));
|
|
295
|
+
}
|
|
296
|
+
return report;
|
|
158
297
|
}
|
|
298
|
+
export function ensureRuntimeArtefacts(projectDir, dryRun) {
|
|
299
|
+
const report = { created: [], copied: [], gitignoreUpdated: false };
|
|
300
|
+
const stateRoot = join(projectDir, '.claude', 'state');
|
|
301
|
+
const dirs = [
|
|
302
|
+
join(stateRoot, 'sessions', '_archive'),
|
|
303
|
+
join(stateRoot, 'inbox'),
|
|
304
|
+
join(stateRoot, 'file-touches', '_archive'),
|
|
305
|
+
];
|
|
306
|
+
for (const d of dirs) {
|
|
307
|
+
if (!existsSync(d)) {
|
|
308
|
+
if (!dryRun)
|
|
309
|
+
mkdirSync(d, { recursive: true });
|
|
310
|
+
report.created.push(relative(projectDir, d));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const readmeSrc = join(CLI_ROOT, 'stacks', '_shared', 'hooks', '_state.README.md');
|
|
314
|
+
const readmeDest = join(stateRoot, 'README.md');
|
|
315
|
+
if (existsSync(readmeSrc) && !existsSync(readmeDest)) {
|
|
316
|
+
if (!dryRun) {
|
|
317
|
+
mkdirSync(dirname(readmeDest), { recursive: true });
|
|
318
|
+
copyFileSync(readmeSrc, readmeDest);
|
|
319
|
+
}
|
|
320
|
+
report.copied.push(relative(projectDir, readmeDest));
|
|
321
|
+
}
|
|
322
|
+
const giPath = join(projectDir, '.gitignore');
|
|
323
|
+
if (existsSync(giPath)) {
|
|
324
|
+
const gi = readFileSync(giPath, 'utf8');
|
|
325
|
+
const covered = /^\.claude\/state\/?$/m.test(gi) || /^\.claude\/?$/m.test(gi);
|
|
326
|
+
if (!covered) {
|
|
327
|
+
if (!dryRun) {
|
|
328
|
+
const next = gi.trimEnd() +
|
|
329
|
+
'\n\n# Claude Code multi-instance coordination state (runtime, per-host)\n.claude/state/\n';
|
|
330
|
+
writeFileSync(giPath, next);
|
|
331
|
+
}
|
|
332
|
+
report.gitignoreUpdated = true;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return report;
|
|
336
|
+
}
|
|
337
|
+
/* ---------------------------------------------------------------------------
|
|
338
|
+
* CLI runner
|
|
339
|
+
* -------------------------------------------------------------------------*/
|
|
159
340
|
export async function runMigrate(projectDir, opts) {
|
|
160
341
|
ui.header('🔄 Start Vibing — Migrate');
|
|
161
342
|
const items = planMigration(projectDir, opts);
|
|
162
|
-
if (items.length === 0) {
|
|
163
|
-
ui.info('Nothing to migrate.');
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
343
|
const grouped = {
|
|
167
|
-
missing: [],
|
|
344
|
+
missing: [],
|
|
345
|
+
outdated: [],
|
|
346
|
+
current: [],
|
|
347
|
+
ahead: [],
|
|
348
|
+
'modified-no-version': [],
|
|
349
|
+
'needs-update-legacy': [],
|
|
168
350
|
};
|
|
169
351
|
for (const it of items)
|
|
170
352
|
grouped[it.status].push(it);
|
|
@@ -174,44 +356,117 @@ export async function runMigrate(projectDir, opts) {
|
|
|
174
356
|
console.log(`\n ${label} (${list.length}):`);
|
|
175
357
|
for (const it of list.slice(0, 30)) {
|
|
176
358
|
const v = it.installedVersion ?? '–';
|
|
177
|
-
console.log(` ${it.kind.padEnd(
|
|
359
|
+
console.log(` ${it.kind.padEnd(7)} ${it.name.padEnd(32)} installed=${v.padEnd(8)} bundled=${it.bundledVersion}`);
|
|
178
360
|
}
|
|
179
361
|
if (list.length > 30)
|
|
180
362
|
console.log(` ... and ${list.length - 30} more`);
|
|
181
363
|
};
|
|
182
364
|
summary('MISSING', grouped.missing);
|
|
183
365
|
summary('OUTDATED', grouped.outdated);
|
|
366
|
+
summary('NEEDS UPDATE — legacy hooks (no @sv-version tag)', grouped['needs-update-legacy']);
|
|
184
367
|
summary('AHEAD (installed newer than bundled — kept)', grouped.ahead);
|
|
185
368
|
summary('UNVERSIONED (manual review)', grouped['modified-no-version']);
|
|
186
369
|
summary('CURRENT', grouped.current);
|
|
187
|
-
|
|
188
|
-
|
|
370
|
+
// Settings.json + runtime — preview always, regardless of items.
|
|
371
|
+
const settingsPreview = patchSettings(projectDir, true);
|
|
372
|
+
const runtimePreview = ensureRuntimeArtefacts(projectDir, true);
|
|
373
|
+
if (settingsPreview.added.length > 0) {
|
|
374
|
+
console.log('\n SETTINGS.JSON — will add hook entries:');
|
|
375
|
+
for (const ev of settingsPreview.added)
|
|
376
|
+
console.log(` + ${ev}`);
|
|
377
|
+
}
|
|
378
|
+
if (settingsPreview.error) {
|
|
379
|
+
console.log(`\n SETTINGS.JSON — ${settingsPreview.error}`);
|
|
380
|
+
}
|
|
381
|
+
if (runtimePreview.created.length > 0) {
|
|
382
|
+
console.log('\n RUNTIME — will create dirs:');
|
|
383
|
+
for (const d of runtimePreview.created)
|
|
384
|
+
console.log(` + ${d}`);
|
|
385
|
+
}
|
|
386
|
+
if (runtimePreview.copied.length > 0) {
|
|
387
|
+
console.log('\n RUNTIME — will copy:');
|
|
388
|
+
for (const f of runtimePreview.copied)
|
|
389
|
+
console.log(` + ${f}`);
|
|
390
|
+
}
|
|
391
|
+
if (runtimePreview.gitignoreUpdated) {
|
|
392
|
+
console.log('\n GITIGNORE — will append `.claude/state/`');
|
|
393
|
+
}
|
|
394
|
+
const upgradable = [
|
|
395
|
+
...grouped.missing,
|
|
396
|
+
...grouped.outdated,
|
|
397
|
+
...(opts.forceHooks ? grouped['needs-update-legacy'] : []),
|
|
398
|
+
];
|
|
399
|
+
const nothingFile = upgradable.length === 0;
|
|
400
|
+
const nothingSettings = settingsPreview.added.length === 0;
|
|
401
|
+
const nothingRuntime = runtimePreview.created.length === 0 && runtimePreview.copied.length === 0 && !runtimePreview.gitignoreUpdated;
|
|
402
|
+
if (nothingFile && nothingSettings && nothingRuntime) {
|
|
189
403
|
console.log('');
|
|
190
404
|
ui.success('Everything up to date.');
|
|
191
405
|
return;
|
|
192
406
|
}
|
|
193
407
|
if (!opts.apply) {
|
|
194
408
|
console.log('');
|
|
195
|
-
|
|
409
|
+
const todo = [];
|
|
410
|
+
if (upgradable.length > 0)
|
|
411
|
+
todo.push(`${upgradable.length} file(s)`);
|
|
412
|
+
if (settingsPreview.added.length > 0)
|
|
413
|
+
todo.push(`${settingsPreview.added.length} settings entry(ies)`);
|
|
414
|
+
if (!nothingRuntime)
|
|
415
|
+
todo.push('runtime artefacts');
|
|
416
|
+
ui.info(`Run with --apply to update: ${todo.join(' + ')}.`);
|
|
417
|
+
if (grouped['needs-update-legacy'].length > 0 && !opts.forceHooks) {
|
|
418
|
+
ui.info(`${grouped['needs-update-legacy'].length} legacy hook(s) without @sv-version detected. ` +
|
|
419
|
+
`Use --force-hooks to update them with a .bak backup.`);
|
|
420
|
+
}
|
|
196
421
|
return;
|
|
197
422
|
}
|
|
198
423
|
console.log('');
|
|
424
|
+
let updated = 0;
|
|
425
|
+
let legacyUpdated = 0;
|
|
199
426
|
for (const it of upgradable) {
|
|
200
427
|
try {
|
|
201
|
-
applyOne(it);
|
|
202
|
-
|
|
428
|
+
const r = applyOne(it, opts);
|
|
429
|
+
if (!r.applied)
|
|
430
|
+
continue;
|
|
431
|
+
updated++;
|
|
432
|
+
if (it.status === 'needs-update-legacy')
|
|
433
|
+
legacyUpdated++;
|
|
434
|
+
const bak = r.backupPath ? ` (backup: ${relative(projectDir, r.backupPath)})` : '';
|
|
435
|
+
ui.success(`updated ${it.kind} ${it.name} (${it.installedVersion ?? '–'} → ${it.bundledVersion})${bak}`);
|
|
203
436
|
}
|
|
204
437
|
catch (err) {
|
|
205
438
|
ui.warn(`failed ${it.kind} ${it.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
206
439
|
}
|
|
207
440
|
}
|
|
441
|
+
// Apply settings + runtime.
|
|
442
|
+
const settingsApply = patchSettings(projectDir, false);
|
|
443
|
+
const runtimeApply = ensureRuntimeArtefacts(projectDir, false);
|
|
444
|
+
for (const ev of settingsApply.added)
|
|
445
|
+
ui.success(`settings.json: wired ${ev}`);
|
|
446
|
+
if (settingsApply.error)
|
|
447
|
+
ui.warn(settingsApply.error);
|
|
448
|
+
for (const d of runtimeApply.created)
|
|
449
|
+
ui.success(`runtime: created ${d}`);
|
|
450
|
+
for (const f of runtimeApply.copied)
|
|
451
|
+
ui.success(`runtime: copied ${f}`);
|
|
452
|
+
if (runtimeApply.gitignoreUpdated)
|
|
453
|
+
ui.success('gitignore: appended .claude/state/');
|
|
208
454
|
console.log('');
|
|
209
|
-
ui.success(`Migration complete: ${
|
|
455
|
+
ui.success(`Migration complete: ${updated} file(s), ${settingsApply.added.length} settings entry(ies)` +
|
|
456
|
+
(legacyUpdated > 0 ? `, ${legacyUpdated} legacy hook(s) overwritten with backup` : ''));
|
|
210
457
|
if (grouped['modified-no-version'].length > 0) {
|
|
211
458
|
console.log('');
|
|
212
|
-
ui.warn(`${grouped['modified-no-version'].length} unversioned local
|
|
459
|
+
ui.warn(`${grouped['modified-no-version'].length} unversioned local skill/agent/command(s) skipped — review manually.`);
|
|
213
460
|
for (const it of grouped['modified-no-version'].slice(0, 10)) {
|
|
214
461
|
console.log(` ${relative(projectDir, it.target)}`);
|
|
215
462
|
}
|
|
216
463
|
}
|
|
464
|
+
if (grouped['needs-update-legacy'].length > 0 && !opts.forceHooks) {
|
|
465
|
+
console.log('');
|
|
466
|
+
ui.warn(`${grouped['needs-update-legacy'].length} legacy hook(s) without @sv-version were skipped. ` +
|
|
467
|
+
`Re-run with --force-hooks to update them (each gets a .bak backup).`);
|
|
468
|
+
for (const it of grouped['needs-update-legacy'].slice(0, 10)) {
|
|
469
|
+
console.log(` ${relative(projectDir, it.target)}`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
217
472
|
}
|
package/package.json
CHANGED