sparkle-design-cli 2.0.7-beta.6 → 2.0.7-beta.8

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/lib/constants.js CHANGED
@@ -44,8 +44,16 @@ export const REGEX = {
44
44
  /\/\*\s*フォントのインポート[^*]*\*\/\s*\n?(?:@import\s+(?:url\([^)]+fonts\.googleapis\.com[^)]+\)|['"][^"']*fonts\.googleapis\.com[^"']*['"]);?\s*\n?)*\n?/g,
45
45
 
46
46
  // Sparkle Design関連
47
+ // `@import "sparkle-design.css"` / `@import "./sparkle-design.css"` だけでなく
48
+ // `@import "./app/sparkle-design.css"` 等、任意の相対パスを含む variant も
49
+ // 除去対象にする。Vite のように entry CSS (src/index.css) と sparkle-design.css
50
+ // (src/app/sparkle-design.css) が別ディレクトリに置かれるレイアウトでは
51
+ // 後者のような path になるので、同一 dir 前提の regex だと AI が手で直したもの
52
+ // を除去できず二重 import が残ってしまう。
53
+ // en: Match any relative path ending in `sparkle-design.css`, including layouts
54
+ // like Vite where the entry CSS and sparkle-design.css live in different dirs.
47
55
  SPARKLE_IMPORT:
48
- /\/\*\s*Sparkle Design[^*]*\*\/\s*\n?|@import\s+['"](\.\/)?sparkle-design\.css['"];?\s*\n?/g,
56
+ /\/\*\s*Sparkle Design[^*]*\*\/\s*\n?|@import\s+['"][^"']*sparkle-design\.css['"];?\s*\n?/g,
49
57
  TAILWIND_IMPORT: /@import\s+['"]tailwindcss['"];?/,
50
58
 
51
59
  // @source ディレクティブ関連
@@ -137,6 +145,11 @@ export const COMMENTS = {
137
145
  };
138
146
 
139
147
  // インポート文のテンプレート
148
+ // SPARKLE_DESIGN は entry CSS と sparkle-design.css が同じディレクトリにある
149
+ // 場合のデフォルト。異なる場合は buildSparkleImportStatement() で相対 path を
150
+ // 計算して差し替える(Vite の src/index.css → src/app/sparkle-design.css 等)。
151
+ // en: Default when entry CSS and sparkle-design.css share a directory. Use
152
+ // buildSparkleImportStatement() for layouts that place them in different dirs.
140
153
  export const IMPORTS = {
141
154
  SPARKLE_DESIGN: '@import "./sparkle-design.css";',
142
155
  TAILWIND: '@import "tailwindcss";',
@@ -88,22 +88,64 @@ function createCustomCssImportBlock(customCssPath, globalsPath) {
88
88
  return [COMMENTS.CUSTOM_CSS, `@import "${relativePath}";`].join('\n');
89
89
  }
90
90
 
91
+ /**
92
+ * entry CSS (globals.css / index.css) から sparkle-design.css への相対 path を
93
+ * 計算し、`@import` 文を組み立てる。同一ディレクトリなら `./sparkle-design.css`、
94
+ * 異なるなら `./app/sparkle-design.css` のような相対 path になる。Vite の
95
+ * `src/index.css` → `src/app/sparkle-design.css` レイアウトで正しい path が
96
+ * 出るようにするために追加された関数(beta.7 まではハードコード)。
97
+ * en: Compute the relative path from the entry CSS to sparkle-design.css.
98
+ * Handles layouts like Vite's `src/index.css` → `src/app/sparkle-design.css`,
99
+ * where the two files live in different directories.
100
+ */
101
+ function buildSparkleImportStatement(globalsPath, sparkleDesignPath) {
102
+ if (!globalsPath || !sparkleDesignPath) {
103
+ return IMPORTS.SPARKLE_DESIGN;
104
+ }
105
+ const globalsDir = path.dirname(globalsPath);
106
+ const rel = path.relative(globalsDir, sparkleDesignPath).split(path.sep).join('/');
107
+ // path.relative が空文字を返すのは globalsPath === sparkleDesignPath のときのみ
108
+ // (想定外)。念のため fallback として同一 dir 前提の default import を返す。
109
+ // en: path.relative returns '' only when paths match; use the same-dir default.
110
+ if (!rel) return IMPORTS.SPARKLE_DESIGN;
111
+ const importPath = rel.startsWith('.') ? rel : `./${rel}`;
112
+ return `@import "${importPath}";`;
113
+ }
114
+
91
115
  /**
92
116
  * sparkle-design.css importブロックを生成する
93
117
  * @param {Array<string>} sourcePackages 追加パッケージ名の配列
94
118
  * @param {string} globalsPath globals.css の絶対パス(@source パス計算用)
95
119
  * @param {string|null} customCssPath custom-css ファイルの相対パス
120
+ * @param {string|null} sparkleDesignPath sparkle-design.css の絶対パス(相対 import 計算用)
96
121
  * @returns {string} sparkle-design.css importブロック
97
122
  */
98
- function createSparkleImportBlock(sourcePackages = [], globalsPath = null, customCssPath = null) {
123
+ function createSparkleImportBlock(
124
+ sourcePackages = [],
125
+ globalsPath = null,
126
+ customCssPath = null,
127
+ sparkleDesignPath = null
128
+ ) {
99
129
  const parts = [''];
100
130
 
101
- if (sourcePackages !== null && sourcePackages !== undefined) {
102
- const nodeModulesRel = globalsPath ? resolveNodeModulesRelPath(globalsPath) : '../node_modules';
103
- parts.push(createSourceBlock(sourcePackages, nodeModulesRel));
104
- }
105
-
106
- parts.push(COMMENTS.SPARKLE_IMPORT, IMPORTS.SPARKLE_DESIGN);
131
+ // CSS 仕様上 `@import` は `@charset` / `@layer` 以外の at-rule より前に
132
+ // 書く必要がある。`@source` を `@import` より前に置くと、PostCSS 処理系に
133
+ // よっては後続の `@import "./sparkle-design.css"` が silent に drop され、
134
+ // sparkle tokens が最終 CSS に出ない回帰になる(beta.6 以前で再現)。
135
+ // そのため順序は:
136
+ // 1. @import "./sparkle-design.css" (Sparkle 本体のトークン)
137
+ // 2. @import "./custom-tokens.css" (ユーザーのカスタムトークン、任意)
138
+ // 3. @source "../node_modules/..." (Tailwind スキャン対象、任意)
139
+ // の順で出力する。
140
+ // en: Per CSS spec, all `@import` rules must precede any other at-rule
141
+ // (except `@charset` / `@layer`). Placing `@source` between imports makes
142
+ // downstream `@import "./sparkle-design.css"` silently drop in some
143
+ // PostCSS toolchains, which removes Sparkle tokens from the output.
144
+ // We now emit @import blocks first, then any @source directives.
145
+ parts.push(
146
+ COMMENTS.SPARKLE_IMPORT,
147
+ buildSparkleImportStatement(globalsPath, sparkleDesignPath)
148
+ );
107
149
 
108
150
  // カスタムCSS import を sparkle-design.css の後に配置
109
151
  const customBlock = createCustomCssImportBlock(customCssPath, globalsPath);
@@ -111,6 +153,11 @@ function createSparkleImportBlock(sourcePackages = [], globalsPath = null, custo
111
153
  parts.push(customBlock);
112
154
  }
113
155
 
156
+ if (sourcePackages !== null && sourcePackages !== undefined) {
157
+ const nodeModulesRel = globalsPath ? resolveNodeModulesRelPath(globalsPath) : '../node_modules';
158
+ parts.push(createSourceBlock(sourcePackages, nodeModulesRel));
159
+ }
160
+
114
161
  parts.push('');
115
162
  return parts.join('\n');
116
163
  }
@@ -209,7 +256,8 @@ export function updateGlobalsWithFonts(
209
256
  fontImports,
210
257
  globalsPath,
211
258
  sourcePackages = null,
212
- customCssPath = null
259
+ customCssPath = null,
260
+ sparkleDesignPath = null
213
261
  ) {
214
262
  const hasFonts = fontImports.length > 0;
215
263
  const hasSourcePackages = sourcePackages !== null && sourcePackages !== undefined;
@@ -241,7 +289,12 @@ export function updateGlobalsWithFonts(
241
289
  }
242
290
 
243
291
  // 4. importブロックを生成(フォント @import は SparkleHead に移行したため生成しない)
244
- const sparkleImportBlock = createSparkleImportBlock(sourcePackages, globalsPath, customCssPath);
292
+ const sparkleImportBlock = createSparkleImportBlock(
293
+ sourcePackages,
294
+ globalsPath,
295
+ customCssPath,
296
+ sparkleDesignPath
297
+ );
245
298
 
246
299
  // 5. globals.css を再構築(フォント @import なし)
247
300
  const reconstructedContent = reconstructGlobalsCss(
@@ -504,7 +557,19 @@ export function manageFontImports(
504
557
  }
505
558
 
506
559
  // 4. globals.css に import / @source を追加
507
- const result = updateGlobalsWithFonts(fontImports, globalsPath, sourcePackages, customCssPath);
560
+ // sparkle-design.css の絶対パスを渡すことで、globalsPath と異なる
561
+ // ディレクトリに置かれている場合でも相対 import が正しく計算される
562
+ // (Vite の src/index.css → src/app/sparkle-design.css 等)。
563
+ // en: Pass the absolute sparkle-design.css path so the import can be
564
+ // computed relative to the entry CSS, even in Vite-style layouts where
565
+ // the two files live in different directories.
566
+ const result = updateGlobalsWithFonts(
567
+ fontImports,
568
+ globalsPath,
569
+ sourcePackages,
570
+ customCssPath,
571
+ sparkleDesignPath
572
+ );
508
573
 
509
574
  if (result.status === 'skipped') {
510
575
  return result;
@@ -256,6 +256,92 @@ function writeSparkleHead(content, sparkleDesignCssPath) {
256
256
  console.log(' → ルートレイアウトの <head> 内に <SparkleHead /> を追加してください');
257
257
  }
258
258
 
259
+ const VITE_INDEX_HTML_BLOCK_START = '<!-- sparkle-design-cli:fonts:start -->';
260
+ const VITE_INDEX_HTML_BLOCK_END = '<!-- sparkle-design-cli:fonts:end -->';
261
+
262
+ /**
263
+ * SparkleHead.tsx と同じフォント <link> タグ群を HTML 文字列として生成する。
264
+ * Vite のように `<head>` が `index.html` にある環境では、React コンポーネントを
265
+ * 挟めないため HTML に直接注入する必要がある。
266
+ * en: Return the font/link tags as HTML strings so we can inject them into
267
+ * Vite's `index.html`, where a React SparkleHead component can't reach the head.
268
+ */
269
+ function buildSparkleFontLinksHtml(resolvedFonts) {
270
+ const materialSymbolsUrl = extractUrlFromImport(FONT_DEFAULTS.MATERIAL_SYMBOLS_IMPORT);
271
+ const fontImportLines = generateMergedFontImports([...resolvedFonts.pro, ...resolvedFonts.mono]);
272
+ const fontUrls = fontImportLines.map(extractUrlFromImport).filter(Boolean);
273
+ return [
274
+ `<link rel="preconnect" href="${FONT_DOMAINS.GOOGLEAPIS}" />`,
275
+ `<link rel="preconnect" href="${FONT_DOMAINS.GSTATIC}" crossorigin />`,
276
+ `<link rel="stylesheet" href="${materialSymbolsUrl}" />`,
277
+ ...fontUrls.map((url) => `<link rel="stylesheet" href="${url}" />`),
278
+ ];
279
+ }
280
+
281
+ /**
282
+ * Vite プロジェクトの `index.html` に managed block で Sparkle のフォント
283
+ * `<link>` タグを upsert する。存在しない場合は `</head>` 直前に挿入、
284
+ * 既にマーカー付きブロックがあれば内容を置換する。
285
+ *
286
+ * - Vite 以外(Next.js の src/app など)は index.html を持たないので呼ばれない
287
+ * - 既存ユーザーの head 内に書いた自作の link は触らない(managed block の
288
+ * 範囲だけを管理するので non-destructive)
289
+ *
290
+ * en: Upsert a managed block of Sparkle font `<link>` tags into Vite's
291
+ * `index.html`, injecting before `</head>` on first run and replacing the
292
+ * block content on re-runs. Non-destructive against user-authored links
293
+ * outside the markers.
294
+ */
295
+ function upsertViteIndexHtmlFonts(cwd, resolvedFonts) {
296
+ const indexHtmlPath = path.resolve(cwd, 'index.html');
297
+ if (!fs.existsSync(indexHtmlPath)) {
298
+ return { status: 'skipped', reason: 'no-index-html' };
299
+ }
300
+ let html;
301
+ try {
302
+ html = fs.readFileSync(indexHtmlPath, 'utf8');
303
+ } catch (error) {
304
+ return { status: 'failed', reason: `read-error: ${error.code ?? error.message}` };
305
+ }
306
+
307
+ const links = buildSparkleFontLinksHtml(resolvedFonts);
308
+ // ブロック内部の各行は innerIndent で統一する。マーカー行自体も含める。
309
+ // en: Apply innerIndent to every line of the managed block, markers included.
310
+ const innerIndent = ' ';
311
+ const block = [VITE_INDEX_HTML_BLOCK_START, ...links, VITE_INDEX_HTML_BLOCK_END]
312
+ .map((line) => innerIndent + line)
313
+ .join('\n');
314
+
315
+ if (html.includes(VITE_INDEX_HTML_BLOCK_START) && html.includes(VITE_INDEX_HTML_BLOCK_END)) {
316
+ // 既存ブロックの各行インデントを尊重しつつ中身だけ置換すると複雑になるので、
317
+ // BLOCK_START の前にある空白を再利用してブロックごと丸ごと差し替える。
318
+ // en: Replace the entire managed block while preserving the leading
319
+ // whitespace that already sits in front of BLOCK_START.
320
+ const existingBlockRegex = new RegExp(
321
+ `([^\\S\\n]*)${VITE_INDEX_HTML_BLOCK_START}[\\s\\S]*?${VITE_INDEX_HTML_BLOCK_END}`
322
+ );
323
+ const replaced = html.replace(existingBlockRegex, (_match, leadingWs) => {
324
+ // 既存 leading を innerIndent に揃える。マーカー前の空白が行頭にある想定。
325
+ return [VITE_INDEX_HTML_BLOCK_START, ...links, VITE_INDEX_HTML_BLOCK_END]
326
+ .map((line, idx) => (idx === 0 ? leadingWs : innerIndent) + line)
327
+ .join('\n');
328
+ });
329
+ if (replaced === html) return { status: 'unchanged', path: indexHtmlPath };
330
+ fs.writeFileSync(indexHtmlPath, replaced, 'utf8');
331
+ return { status: 'updated', path: indexHtmlPath };
332
+ }
333
+
334
+ const headCloseRegex = /(\s*)<\/head>/i;
335
+ const match = html.match(headCloseRegex);
336
+ if (!match) {
337
+ return { status: 'failed', reason: 'no-head-close-tag' };
338
+ }
339
+ const leading = match[1]; // 通常 `\n ` (`</head>` 直前の改行とインデント)
340
+ const replaced = html.replace(headCloseRegex, `\n${block}${leading}</head>`);
341
+ fs.writeFileSync(indexHtmlPath, replaced, 'utf8');
342
+ return { status: 'created', path: indexHtmlPath };
343
+ }
344
+
259
345
  /**
260
346
  * テンプレート変数を設定値で置換する
261
347
  * @param {string} template CSSテンプレート
@@ -450,6 +536,27 @@ export function generateCSS(
450
536
  const sparkleHeadContent = generateSparkleHeadContent(resolvedFonts);
451
537
  writeSparkleHead(sparkleHeadContent, resolvedOutputPath);
452
538
 
539
+ // 7.5 Vite プロジェクトの index.html に Sparkle のフォント <link> タグを upsert
540
+ // する。Vite は <head> が index.html 側にあるため、React コンポーネントの
541
+ // SparkleHead を挟めない。managed block でマーカー間だけを管理し、既存の
542
+ // head 内容は非破壊に保つ。
543
+ // en: Vite ships its <head> in index.html, outside React's reach, so mirror
544
+ // the SparkleHead link tags into a managed block there.
545
+ const cwdForHtml = process.cwd();
546
+ if (fs.existsSync(path.resolve(cwdForHtml, 'index.html'))) {
547
+ const htmlResult = upsertViteIndexHtmlFonts(cwdForHtml, resolvedFonts);
548
+ if (htmlResult.status === 'created' || htmlResult.status === 'updated') {
549
+ const displayPath = path.relative(cwdForHtml, htmlResult.path) || htmlResult.path;
550
+ console.log(
551
+ `✅ index.html の Sparkle フォント <link> ブロックを${htmlResult.status === 'created' ? '挿入' : '更新'}しました: ${displayPath}`
552
+ );
553
+ } else if (htmlResult.status === 'failed') {
554
+ console.warn(
555
+ `⚠️ index.html への Sparkle フォント <link> 挿入に失敗しました (${htmlResult.reason})。手動で <head> 内に SparkleHead.tsx 相当の <link> タグを追加してください。`
556
+ );
557
+ }
558
+ }
559
+
453
560
  // 8. フォント管理の自動処理を実行(globals.css にはフォント @import を差し込まない)
454
561
  // source-packages は以下の合成で決まる:
455
562
  // 1. package.json から既知のデザインシステムパッケージを自動検出(baseline)
package/lib/setup.js CHANGED
@@ -20,10 +20,23 @@ const DEFAULT_SPARKLE_CONFIG = {
20
20
  };
21
21
 
22
22
  // 初期 globals.css のテンプレート
23
- // en: Initial globals.css template
24
- const INITIAL_GLOBALS_CSS = `@import "tailwindcss";
25
- @import "./sparkle-design.css";
26
- `;
23
+ // target のディレクトリと sparkle-design.css の想定出力先
24
+ // (`src/app/sparkle-design.css`) との相対関係で import path を切り替える。
25
+ // Next.js App Router (src/app/globals.css) なら同一 dir で `./sparkle-design.css`、
26
+ // Vite (src/index.css) なら 1 階層下なので `./app/sparkle-design.css` になる。
27
+ // 同一 dir 前提のハードコードで生成していた beta.7 以前は Vite レイアウトで
28
+ // path が切れ、後続 generate が相対 path を再計算しても scaffold 時点では
29
+ // 間違った path で書かれていたため、AI が手で直す副作用が出ていた。
30
+ // en: Render initial entry CSS with a path that actually resolves to
31
+ // `src/app/sparkle-design.css` given the target file location.
32
+ function buildInitialGlobalsCss(targetRelPath) {
33
+ const targetDir = path.dirname(targetRelPath);
34
+ const sparkleDesignRel = 'src/app/sparkle-design.css';
35
+ let rel = path.relative(targetDir, sparkleDesignRel).split(path.sep).join('/');
36
+ if (!rel) rel = 'sparkle-design.css';
37
+ if (!rel.startsWith('.')) rel = `./${rel}`;
38
+ return `@import "tailwindcss";\n@import "${rel}";\n`;
39
+ }
27
40
 
28
41
  // インストール対象パッケージ
29
42
  // en: Packages to install
@@ -185,6 +198,11 @@ function buildInstructionBlock(target, assistant) {
185
198
  '',
186
199
  '- **Scope**: Sparkle Design is a UI component library. For capability areas it does not cover (charts, data visualization, maps, rich text editors, animation libraries, etc.), **feel free to adopt other libraries** (e.g. Recharts, D3, Chart.js) — do not try to solve everything inside Sparkle Design. Pass Sparkle Design CSS tokens (`--color-primary-*` etc.) into those libraries to keep visuals consistent.',
187
200
  "- **Required reading**: Before using any Sparkle Design component, read the installed package's type definitions at `node_modules/<package>/dist/components/ui/<component>/index.d.ts` — prop specs, usage, and anti-patterns (with ✅ / ❌ examples) live in the JSDoc and are the source of truth. Target the installed package(s), e.g. `sparkle-design` and/or `@goodpatch/sparkle-design-internal`, and read the full JSDoc, not just the type signature.",
201
+ '- **Structural invariants — verify before hand-editing any Sparkle file**:',
202
+ ' - Entry CSS (e.g. `src/index.css`, `src/app/globals.css`) must start with `@import "tailwindcss";` and keep **all `@import` statements before any `@source` directive**. CSS-spec-compliant processors silently drop any `@import` that follows another at-rule, which removes Sparkle tokens from the output.',
203
+ ' - Sparkle fonts (Google Fonts preconnect + Material Symbols + the configured pro/mono fonts) must be present in the document `<head>`: React layouts via `<SparkleHead />` placed inside `<head>` in the root layout, Vite projects via the managed `<!-- sparkle-design-cli:fonts:start -->` … `end` block in `index.html`.',
204
+ ' - Do **not** hand-define `--color-primary-*` or other Sparkle tokens as fallbacks when they look missing. Those come from `sparkle-design.css`; missing values mean the `@import` path is wrong (e.g. `./sparkle-design.css` used when the file lives under `./app/`). Fix the import path, do not duplicate the tokens.',
205
+ ' - If you detect any of the above drifted, re-run `npx --yes sparkle-design-cli generate` before hand-editing. The CLI restores the canonical state (correct relative paths, `@source` placement, `index.html` injection).',
188
206
  '- **Required**: After creating or modifying any UI component, run `lint:sparkle` before finishing. This catches Sparkle Design anti-patterns.',
189
207
  `- For AI review, prefer \`lint:sparkle:json\` or run \`npx --yes sparkle-design-cli check ${target} --format json\` directly.`,
190
208
  '- You must inspect every entry in both `findings` and `manualReviewReminders` — the reminders flag judgment calls (Badge vs Tag, etc.) that the linter cannot detect.',
@@ -375,7 +393,8 @@ function buildScaffoldTargets(cwd) {
375
393
  target: defaultGlobalsCssTarget(cwd),
376
394
  write: (abs) => {
377
395
  ensureDir(abs);
378
- fs.writeFileSync(abs, INITIAL_GLOBALS_CSS, 'utf8');
396
+ const targetRel = path.relative(cwd, abs).split(path.sep).join('/');
397
+ fs.writeFileSync(abs, buildInitialGlobalsCss(targetRel), 'utf8');
379
398
  },
380
399
  },
381
400
  ];
@@ -437,19 +456,23 @@ function runAssistantGuard(cwd, packageJsonPath, options, assistantConfig) {
437
456
  const instructionBlock = buildInstructionBlock(target, options.assistant);
438
457
  const instructionResult = updateInstructionFile(instructionPath, instructionBlock);
439
458
 
440
- // --assistant claude の場合は CLAUDE.md に加えて AGENTS.md にも Guard を書く。
441
- // create-next-app 等が生成する AGENTS.md に Next.js 固有指示が残っていて、
442
- // CLAUDE.md から `@AGENTS.md` で import しているプロジェクトでは、Sparkle
443
- // Design Guard が AGENTS.md 側に無いと import 経由で読ませる動線が切れる。
444
- // 他の assistant は本来の AGENTS.md / .cursor/rules だけに書く。
445
- // en: When --assistant=claude, also upsert Guard into AGENTS.md so projects
446
- // that reference `@AGENTS.md` from CLAUDE.md (common Next.js setup) still
447
- // propagate the Guard via that import path.
459
+ // --assistant claude のときに、プロジェクトルートに既存の AGENTS.md が
460
+ // 「すでにある」場合だけ Guard を追記する。create-next-app などが先行生成
461
+ // した AGENTS.md や、ユーザーが Codex/Gemini 併用のために置いているファイル
462
+ // には courtesy として Guard を載せる一方、存在しない AGENTS.md を
463
+ // Sparkle 側が勝手に作ることはしない(「指定していない AI 指示書が
464
+ // あったら書く」という緩い方針)。
465
+ // en: When --assistant=claude, also upsert Guard into AGENTS.md **only if
466
+ // the file already exists**. This covers projects where create-next-app or
467
+ // another tool shipped an AGENTS.md (or where the user keeps one for
468
+ // Codex/Gemini), without the CLI unilaterally creating a file the user
469
+ // didn't opt into.
448
470
  let agentsInstructionResult = null;
449
471
  let agentsInstructionPath = null;
450
472
  if (options.assistant === 'claude' && !options.instructionsPath) {
451
- agentsInstructionPath = path.resolve(cwd, 'AGENTS.md');
452
- if (agentsInstructionPath !== instructionPath) {
473
+ const candidate = path.resolve(cwd, 'AGENTS.md');
474
+ if (candidate !== instructionPath && fs.existsSync(candidate)) {
475
+ agentsInstructionPath = candidate;
453
476
  agentsInstructionResult = updateInstructionFile(agentsInstructionPath, instructionBlock);
454
477
  }
455
478
  }
@@ -478,74 +501,107 @@ function runAssistantGuard(cwd, packageJsonPath, options, assistantConfig) {
478
501
  }
479
502
 
480
503
  /**
481
- * .claude/settings.json に lint:sparkle を強制実行する Stop hook を追加する。
504
+ * Agent 別の hook 設定ファイルに「lint:sparkle を強制実行する」hook を追加する。
505
+ *
506
+ * 各 agent が持つ hook システムの schema は異なるため、agent ごとに以下の
507
+ * ヘルパーを持つ。共通ポイントは:
508
+ * - 実行コマンドは `npx --yes sparkle-design-cli check <target> --strict || exit 2`
509
+ * - exit 2 にエスカレーションすることで、ブロック対応する agent では
510
+ * 応答終了を止めて findings 修正に向かわせる
511
+ * - 既存の hook 設定ファイルは非破壊マージし、同一 command が含まれていれば skip
512
+ * (冪等)
513
+ * - 壊れた JSON は silent 上書きせず、ユーザーに修正を促すエラーを出す
482
514
  *
483
- * 設計:
484
- * - Stop hook: Claude が応答を終えようとする直前に実行される hook。exit 2 を返すと
485
- * 停止がブロックされ、Claude が lint findings を修正してから停止する動線になる。
486
- * `sparkle-design-cli check ... --strict || exit 2` で findings があれば exit 2。
487
- * - 既存の .claude/settings.json を破壊しないよう、現行の hooks.Stop 配列を残しつつ
488
- * 同一 command が無いときだけ append する(冪等)。
489
- * - project-shared な settings.json に書くのでチームメンバーにも共有される。
490
- * user-local な settings.local.json ではなく settings.json を選ぶのは、これが
491
- * 全員に共通のガードレールであることを意図しているため。
515
+ * en: Install an agent-specific hook that runs `lint:sparkle --strict || exit 2`.
516
+ * Each agent ships its own hook config format; these helpers emit the right
517
+ * shape while preserving existing user content and staying idempotent on rerun.
492
518
  *
493
- * en: Write a Stop hook to `.claude/settings.json` that runs `lint:sparkle` and
494
- * exits with code 2 on findings, which blocks Claude Code from finishing the
495
- * turn until the linter passes. The existing file (including user-authored
496
- * hooks) is preserved by merging; we only append our entry if an identical
497
- * command is not already present.
519
+ * References (2026-04 時点):
520
+ * - Claude Code : https://docs.claude.com/en/docs/claude-code/hooks
521
+ * - Cursor : https://cursor.com/docs/hooks
522
+ * - Codex : https://developers.openai.com/codex/hooks
498
523
  */
499
- function runClaudeHook(cwd, target, dryRun) {
500
- const settingsPath = path.resolve(cwd, '.claude/settings.json');
501
- const managedCommand = `npx --yes sparkle-design-cli check ${target} --strict || exit 2`;
502
-
503
- let settings = {};
504
- let existed = false;
505
- if (fs.existsSync(settingsPath)) {
506
- existed = true;
507
- try {
508
- settings = readJson(settingsPath);
509
- } catch (error) {
510
- // 壊れた JSON を黙って上書きしないようユーザーに明示的に判断を求める。
511
- // en: Refuse to overwrite a broken settings.json silently.
512
- throw new Error(
513
- `.claude/settings.json が不正な JSON です (${error.message})。修正してから再実行してください。`
514
- );
515
- }
524
+ function buildManagedHookCommand(target) {
525
+ return `npx --yes sparkle-design-cli check ${target} --strict || exit 2`;
526
+ }
527
+
528
+ function loadHookJson(filePath, label) {
529
+ if (!fs.existsSync(filePath)) {
530
+ return { existed: false, config: null };
531
+ }
532
+ try {
533
+ return { existed: true, config: readJson(filePath) };
534
+ } catch (error) {
535
+ throw new Error(
536
+ `${label} が不正な JSON です (${error.message})。修正してから再実行してください。`
537
+ );
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Claude Code の Stop hook を読んでくれるのは「Claude を起動した作業ディレクトリ」
543
+ * 直下の `.claude/settings.json` であって、cwd とは限らない。ユーザーが親フォルダ
544
+ * で Claude を起動してからサブディレクトリに `cd` してこの setup を実行した
545
+ * 場合、`.claude/settings.json` はサブディレクトリに置かれるが Claude は親側を
546
+ * 見ているので hook が効かない、という失敗モードがある。
547
+ *
548
+ * Claude Code が Bash tool 経由で環境変数 `CLAUDE_PROJECT_DIR` を設定するので、
549
+ * それを検出して cwd と違えば「hook が効かない可能性あり」と警告する。環境変数
550
+ * が無い場合(別の agent から呼ばれている / Claude Code 以外)は警告なし。
551
+ *
552
+ * en: Claude Code loads `.claude/settings.json` from the directory where it was
553
+ * launched, not the current working directory. If the user started Claude at a
554
+ * parent folder and ran `sparkle-design-cli setup` inside a subdirectory, the
555
+ * hook lands in a place Claude won't read. Detect this mismatch via the
556
+ * `CLAUDE_PROJECT_DIR` env var that Claude Code exports into shell tools.
557
+ */
558
+ function detectClaudeSessionRootMismatch(cwd) {
559
+ const sessionDir = process.env.CLAUDE_PROJECT_DIR;
560
+ if (!sessionDir) return null;
561
+ try {
562
+ const resolvedSession = fs.realpathSync(path.resolve(sessionDir));
563
+ const resolvedCwd = fs.realpathSync(path.resolve(cwd));
564
+ if (resolvedSession === resolvedCwd) return null;
565
+ return { sessionDir: resolvedSession, cwd: resolvedCwd };
566
+ } catch {
567
+ return null;
516
568
  }
569
+ }
517
570
 
518
- const nextSettings = typeof settings === 'object' && settings ? { ...settings } : {};
519
- const hooks = typeof nextSettings.hooks === 'object' && nextSettings.hooks
520
- ? { ...nextSettings.hooks }
521
- : {};
571
+ function runClaudeHook(cwd, target, dryRun) {
572
+ const settingsPath = path.resolve(cwd, '.claude/settings.json');
573
+ const managedCommand = buildManagedHookCommand(target);
574
+ const { existed, config } = loadHookJson(settingsPath, '.claude/settings.json');
575
+
576
+ const nextSettings =
577
+ typeof config === 'object' && config ? { ...config } : {};
578
+ const hooks =
579
+ typeof nextSettings.hooks === 'object' && nextSettings.hooks
580
+ ? { ...nextSettings.hooks }
581
+ : {};
522
582
  const stopGroups = Array.isArray(hooks.Stop) ? [...hooks.Stop] : [];
523
583
 
524
- // 既存の Stop hook 配列に同一 command が含まれていれば冪等にスキップ。
525
- // en: Skip if the managed command already appears in any Stop entry.
526
584
  const alreadyPresent = stopGroups.some(
527
585
  (group) =>
528
586
  Array.isArray(group?.hooks) &&
529
587
  group.hooks.some((entry) => entry?.type === 'command' && entry?.command === managedCommand)
530
588
  );
531
589
 
590
+ const mismatch = detectClaudeSessionRootMismatch(cwd);
532
591
  if (alreadyPresent) {
533
592
  return {
593
+ assistant: 'claude',
534
594
  changed: false,
535
595
  existed,
536
- settingsPath,
596
+ path: settingsPath,
537
597
  reason: 'already-present',
538
598
  command: managedCommand,
599
+ sessionRootMismatch: mismatch,
539
600
  };
540
601
  }
541
602
 
542
603
  stopGroups.push({
543
- hooks: [
544
- {
545
- type: 'command',
546
- command: managedCommand,
547
- },
548
- ],
604
+ hooks: [{ type: 'command', command: managedCommand }],
549
605
  });
550
606
  hooks.Stop = stopGroups;
551
607
  nextSettings.hooks = hooks;
@@ -554,16 +610,139 @@ function runClaudeHook(cwd, target, dryRun) {
554
610
  ensureDir(settingsPath);
555
611
  writeJson(settingsPath, nextSettings);
556
612
  }
613
+ return {
614
+ assistant: 'claude',
615
+ changed: true,
616
+ existed,
617
+ path: settingsPath,
618
+ reason: existed ? 'appended' : 'created',
619
+ command: managedCommand,
620
+ sessionRootMismatch: mismatch,
621
+ };
622
+ }
623
+
624
+ /**
625
+ * Cursor 1.7+ の hook 設定(`.cursor/hooks.json`)に stop hook を追加する。
626
+ * schema: `{ version: 1, hooks: { stop: [{ command: "..." }] } }`
627
+ */
628
+ function runCursorHook(cwd, target, dryRun) {
629
+ const hooksPath = path.resolve(cwd, '.cursor/hooks.json');
630
+ const managedCommand = buildManagedHookCommand(target);
631
+ const { existed, config } = loadHookJson(hooksPath, '.cursor/hooks.json');
632
+
633
+ const nextConfig = typeof config === 'object' && config ? { ...config } : {};
634
+ if (!nextConfig.version) nextConfig.version = 1;
635
+ const hooks =
636
+ typeof nextConfig.hooks === 'object' && nextConfig.hooks ? { ...nextConfig.hooks } : {};
637
+ const stopEntries = Array.isArray(hooks.stop) ? [...hooks.stop] : [];
638
+
639
+ const alreadyPresent = stopEntries.some((entry) => entry?.command === managedCommand);
640
+ if (alreadyPresent) {
641
+ return {
642
+ assistant: 'cursor',
643
+ changed: false,
644
+ existed,
645
+ path: hooksPath,
646
+ reason: 'already-present',
647
+ command: managedCommand,
648
+ };
649
+ }
650
+
651
+ stopEntries.push({ command: managedCommand });
652
+ hooks.stop = stopEntries;
653
+ nextConfig.hooks = hooks;
654
+
655
+ if (!dryRun) {
656
+ ensureDir(hooksPath);
657
+ writeJson(hooksPath, nextConfig);
658
+ }
659
+
660
+ return {
661
+ assistant: 'cursor',
662
+ changed: true,
663
+ existed,
664
+ path: hooksPath,
665
+ reason: existed ? 'appended' : 'created',
666
+ command: managedCommand,
667
+ };
668
+ }
669
+
670
+ /**
671
+ * Codex の hook 設定(`.codex/hooks.json`)に Stop hook を追加する。
672
+ * schema は Claude とほぼ同じネスト構造。
673
+ * 有効化には `~/.codex/config.toml` に `[features] codex_hooks = true` が必要。
674
+ */
675
+ function runCodexHook(cwd, target, dryRun) {
676
+ const hooksPath = path.resolve(cwd, '.codex/hooks.json');
677
+ const managedCommand = buildManagedHookCommand(target);
678
+ const { existed, config } = loadHookJson(hooksPath, '.codex/hooks.json');
679
+
680
+ const nextConfig = typeof config === 'object' && config ? { ...config } : {};
681
+ const hooks =
682
+ typeof nextConfig.hooks === 'object' && nextConfig.hooks ? { ...nextConfig.hooks } : {};
683
+ const stopGroups = Array.isArray(hooks.Stop) ? [...hooks.Stop] : [];
684
+
685
+ const alreadyPresent = stopGroups.some(
686
+ (group) =>
687
+ Array.isArray(group?.hooks) &&
688
+ group.hooks.some((entry) => entry?.type === 'command' && entry?.command === managedCommand)
689
+ );
690
+
691
+ if (alreadyPresent) {
692
+ return {
693
+ assistant: 'codex',
694
+ changed: false,
695
+ existed,
696
+ path: hooksPath,
697
+ reason: 'already-present',
698
+ command: managedCommand,
699
+ // 有効化にはユーザー側の opt-in が必要な旨を summary に載せる。
700
+ // en: Codex hooks are behind an opt-in feature flag; surface that in summary.
701
+ featureFlagNote:
702
+ 'Codex で有効化するには `~/.codex/config.toml` に `[features]` セクションを作り `codex_hooks = true` を設定してください。',
703
+ };
704
+ }
705
+
706
+ stopGroups.push({
707
+ hooks: [{ type: 'command', command: managedCommand }],
708
+ });
709
+ hooks.Stop = stopGroups;
710
+ nextConfig.hooks = hooks;
711
+
712
+ if (!dryRun) {
713
+ ensureDir(hooksPath);
714
+ writeJson(hooksPath, nextConfig);
715
+ }
557
716
 
558
717
  return {
718
+ assistant: 'codex',
559
719
  changed: true,
560
720
  existed,
561
- settingsPath,
721
+ path: hooksPath,
562
722
  reason: existed ? 'appended' : 'created',
563
723
  command: managedCommand,
724
+ featureFlagNote:
725
+ 'Codex で有効化するには `~/.codex/config.toml` に `[features]` セクションを作り `codex_hooks = true` を設定してください。',
564
726
  };
565
727
  }
566
728
 
729
+ /**
730
+ * --assistant 値から該当 hook writer に dispatch する。generic は hook なし。
731
+ * en: Dispatch to the appropriate hook writer for the selected assistant.
732
+ */
733
+ function runAssistantHook(assistant, cwd, target, dryRun) {
734
+ switch (assistant) {
735
+ case 'claude':
736
+ return runClaudeHook(cwd, target, dryRun);
737
+ case 'cursor':
738
+ return runCursorHook(cwd, target, dryRun);
739
+ case 'codex':
740
+ return runCodexHook(cwd, target, dryRun);
741
+ default:
742
+ return null;
743
+ }
744
+ }
745
+
567
746
  function runGenerate({ skipGenerate, dryRun, strict }) {
568
747
  // --skip-generate / --dry-run と --strict が同時指定された場合、strict チェックを
569
748
  // 走らせる対象 (generate) そのものがスキップされるため strict は事実上無意味。
@@ -619,12 +798,13 @@ export function setupAssistant(options = {}) {
619
798
  { ...options, assistant, dryRun },
620
799
  assistantConfig
621
800
  );
622
- // --assistant claude のみ .claude/settings.json に Stop hook を自動設定する。
623
- // これで lint:sparkle の実行が instruction 頼りではなく hook で強制される。
624
- // en: For --assistant=claude, install a Stop hook in .claude/settings.json
625
- // so lint:sparkle becomes a hard gate instead of a soft instruction.
626
- const claudeHook =
627
- assistant === 'claude' ? runClaudeHook(cwd, guard.target, dryRun) : null;
801
+ // assistant 別に hook 設定ファイル(`.claude/settings.json` /
802
+ // `.cursor/hooks.json` / `.codex/hooks.json`)に `lint:sparkle --strict` を
803
+ // 走らせる stop / Stop hook を自動設定する。instruction 頼みではなく hook
804
+ // で強制することで lint:sparkle の実行漏れを防ぐ。generic は hook なし。
805
+ // en: For supported assistants, install a stop hook so `lint:sparkle --strict`
806
+ // becomes a hard gate. Generic assistant has no hook target.
807
+ const hook = runAssistantHook(assistant, cwd, guard.target, dryRun);
628
808
  const generate = runGenerate({
629
809
  skipGenerate: Boolean(options.skipGenerate),
630
810
  dryRun,
@@ -662,12 +842,15 @@ export function setupAssistant(options = {}) {
662
842
  existed: guard.agentsInstructionResult?.existed ?? false,
663
843
  }
664
844
  : null,
665
- claudeHook: claudeHook
845
+ hook: hook
666
846
  ? {
667
- path: path.relative(cwd, claudeHook.settingsPath),
668
- changed: claudeHook.changed,
669
- existed: claudeHook.existed,
670
- reason: claudeHook.reason,
847
+ assistant: hook.assistant,
848
+ path: path.relative(cwd, hook.path),
849
+ changed: hook.changed,
850
+ existed: hook.existed,
851
+ reason: hook.reason,
852
+ featureFlagNote: hook.featureFlagNote ?? null,
853
+ sessionRootMismatch: hook.sessionRootMismatch ?? null,
671
854
  }
672
855
  : null,
673
856
  };
@@ -677,7 +860,7 @@ export function setupAssistant(options = {}) {
677
860
  // セットアップ後のリマインダー。AI の会話履歴にも残るよう stderr に出す。
678
861
  // en: Post-setup reminder. Written to stderr so it stays in the AI transcript
679
862
  // without polluting the JSON stdout payload that tooling parses.
680
- printPostSetupReminder(guard.target, packageManager, { claudeHook });
863
+ printPostSetupReminder(guard.target, packageManager, { hook });
681
864
 
682
865
  return summary;
683
866
  }
@@ -699,7 +882,13 @@ function buildLintCommand(packageManager) {
699
882
  }
700
883
  }
701
884
 
702
- function printPostSetupReminder(target, packageManager, { claudeHook } = {}) {
885
+ const HOOK_LABELS = {
886
+ claude: { name: 'Claude Code', file: '.claude/settings.json', event: 'Stop' },
887
+ cursor: { name: 'Cursor', file: '.cursor/hooks.json', event: 'stop' },
888
+ codex: { name: 'Codex', file: '.codex/hooks.json', event: 'Stop' },
889
+ };
890
+
891
+ function printPostSetupReminder(target, packageManager, { hook } = {}) {
703
892
  const lintTarget = target || 'src';
704
893
  const lintCmd = buildLintCommand(packageManager);
705
894
  const lines = [
@@ -712,17 +901,28 @@ function printPostSetupReminder(target, packageManager, { claudeHook } = {}) {
712
901
  ' 3. AI でレビューする場合は `lint:sparkle:json` を使い、`findings` に加えて `manualReviewReminders` の各項目まで必ず確認してください。',
713
902
  ' For AI review, use `lint:sparkle:json` and inspect every entry of both `findings` and `manualReviewReminders`.',
714
903
  ];
715
- if (claudeHook?.changed || claudeHook?.reason === 'already-present') {
904
+ if (hook && (hook.changed || hook.reason === 'already-present')) {
905
+ const meta = HOOK_LABELS[hook.assistant] ?? HOOK_LABELS.claude;
716
906
  const label =
717
- claudeHook.reason === 'already-present'
907
+ hook.reason === 'already-present'
718
908
  ? '(既存のまま)'
719
- : claudeHook.reason === 'created'
909
+ : hook.reason === 'created'
720
910
  ? '(新規作成)'
721
911
  : '(既存設定に追記)';
722
912
  lines.push(
723
- ` 4. Claude Code の Stop hook を .claude/settings.json に設定しました ${label}。Claude が応答を終える直前に \`lint:sparkle --strict\` が走り、findings があれば exit 2 で停止がブロックされます。`,
724
- ' Installed a Claude Code Stop hook in .claude/settings.json: `lint:sparkle --strict` runs before each turn ends and exit 2 blocks the stop until findings are resolved.'
913
+ ` 4. ${meta.name} の ${meta.event} hook を ${meta.file} に設定しました ${label}。応答を終える直前に \`lint:sparkle --strict\` が走り、findings があれば exit 2 で停止がブロックされます。`,
914
+ ` Installed a ${meta.name} ${meta.event} hook in ${meta.file}: it runs \`lint:sparkle --strict\` before the turn ends and exits with code 2 when findings exist.`
725
915
  );
916
+ if (hook.featureFlagNote) {
917
+ lines.push(` ⚠️ ${hook.featureFlagNote}`);
918
+ }
919
+ if (hook.sessionRootMismatch) {
920
+ const { sessionDir, cwd: mismatchCwd } = hook.sessionRootMismatch;
921
+ lines.push(
922
+ ` ⚠️ Claude Code は ${sessionDir} を session root として ${path.join(sessionDir, '.claude/settings.json')} を読みます。今回の hook は ${path.join(mismatchCwd, '.claude/settings.json')} に書かれたため、このまま だと hook が発火しません。Claude Code をこのディレクトリから再起動するか、同じ設定を session root の .claude/settings.json にコピーしてください。`,
923
+ ` ⚠️ Claude Code reads \`.claude/settings.json\` from its session root, not cwd. The hook written to the current directory won't trigger — relaunch Claude from here, or copy the config to the session root.`
924
+ );
925
+ }
726
926
  }
727
927
  lines.push('');
728
928
  for (const line of lines) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sparkle-design-cli",
3
- "version": "2.0.7-beta.6",
4
- "description": "Sparkle Design CSS Generator - デザインシステムCSSを設定ファイルから生成するツール",
3
+ "version": "2.0.7-beta.8",
4
+ "description": "Sparkle Design CLI — プロジェクトセットアップ、CSS・フォント生成、アンチパターン検査、AI エージェント(Claude Code / Cursor / Codex)向けのガードと hook 設定まで一括で行う sparkle-design 公式 CLI。",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
7
7
  "access": "public"
@@ -21,10 +21,20 @@
21
21
  "sync:anti-pattern-docs": "node scripts/sync-anti-pattern-docs.mjs"
22
22
  },
23
23
  "keywords": [
24
- "css",
24
+ "sparkle-design",
25
25
  "design-system",
26
- "css-generator",
27
- "theming"
26
+ "cli",
27
+ "setup",
28
+ "scaffold",
29
+ "css",
30
+ "tailwindcss",
31
+ "theming",
32
+ "anti-pattern",
33
+ "linter",
34
+ "ai-agent",
35
+ "claude-code",
36
+ "cursor",
37
+ "codex"
28
38
  ],
29
39
  "author": "Goodpatch Inc. <sparkle-design@goodpatch.com>",
30
40
  "license": "MIT",