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

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.
@@ -98,11 +98,20 @@ function createCustomCssImportBlock(customCssPath, globalsPath) {
98
98
  function createSparkleImportBlock(sourcePackages = [], globalsPath = null, customCssPath = null) {
99
99
  const parts = [''];
100
100
 
101
- if (sourcePackages !== null && sourcePackages !== undefined) {
102
- const nodeModulesRel = globalsPath ? resolveNodeModulesRelPath(globalsPath) : '../node_modules';
103
- parts.push(createSourceBlock(sourcePackages, nodeModulesRel));
104
- }
105
-
101
+ // CSS 仕様上 `@import` は `@charset` / `@layer` 以外の at-rule より前に
102
+ // 書く必要がある。`@source` を `@import` より前に置くと、PostCSS 処理系に
103
+ // よっては後続の `@import "./sparkle-design.css"` が silent に drop され、
104
+ // sparkle tokens が最終 CSS に出ない回帰になる(beta.6 以前で再現)。
105
+ // そのため順序は:
106
+ // 1. @import "./sparkle-design.css" (Sparkle 本体のトークン)
107
+ // 2. @import "./custom-tokens.css" (ユーザーのカスタムトークン、任意)
108
+ // 3. @source "../node_modules/..." (Tailwind スキャン対象、任意)
109
+ // の順で出力する。
110
+ // en: Per CSS spec, all `@import` rules must precede any other at-rule
111
+ // (except `@charset` / `@layer`). Placing `@source` between imports makes
112
+ // downstream `@import "./sparkle-design.css"` silently drop in some
113
+ // PostCSS toolchains, which removes Sparkle tokens from the output.
114
+ // We now emit @import blocks first, then any @source directives.
106
115
  parts.push(COMMENTS.SPARKLE_IMPORT, IMPORTS.SPARKLE_DESIGN);
107
116
 
108
117
  // カスタムCSS import を sparkle-design.css の後に配置
@@ -111,6 +120,11 @@ function createSparkleImportBlock(sourcePackages = [], globalsPath = null, custo
111
120
  parts.push(customBlock);
112
121
  }
113
122
 
123
+ if (sourcePackages !== null && sourcePackages !== undefined) {
124
+ const nodeModulesRel = globalsPath ? resolveNodeModulesRelPath(globalsPath) : '../node_modules';
125
+ parts.push(createSourceBlock(sourcePackages, nodeModulesRel));
126
+ }
127
+
114
128
  parts.push('');
115
129
  return parts.join('\n');
116
130
  }
package/lib/setup.js CHANGED
@@ -437,19 +437,23 @@ function runAssistantGuard(cwd, packageJsonPath, options, assistantConfig) {
437
437
  const instructionBlock = buildInstructionBlock(target, options.assistant);
438
438
  const instructionResult = updateInstructionFile(instructionPath, instructionBlock);
439
439
 
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.
440
+ // --assistant claude のときに、プロジェクトルートに既存の AGENTS.md が
441
+ // 「すでにある」場合だけ Guard を追記する。create-next-app などが先行生成
442
+ // した AGENTS.md や、ユーザーが Codex/Gemini 併用のために置いているファイル
443
+ // には courtesy として Guard を載せる一方、存在しない AGENTS.md を
444
+ // Sparkle 側が勝手に作ることはしない(「指定していない AI 指示書が
445
+ // あったら書く」という緩い方針)。
446
+ // en: When --assistant=claude, also upsert Guard into AGENTS.md **only if
447
+ // the file already exists**. This covers projects where create-next-app or
448
+ // another tool shipped an AGENTS.md (or where the user keeps one for
449
+ // Codex/Gemini), without the CLI unilaterally creating a file the user
450
+ // didn't opt into.
448
451
  let agentsInstructionResult = null;
449
452
  let agentsInstructionPath = null;
450
453
  if (options.assistant === 'claude' && !options.instructionsPath) {
451
- agentsInstructionPath = path.resolve(cwd, 'AGENTS.md');
452
- if (agentsInstructionPath !== instructionPath) {
454
+ const candidate = path.resolve(cwd, 'AGENTS.md');
455
+ if (candidate !== instructionPath && fs.existsSync(candidate)) {
456
+ agentsInstructionPath = candidate;
453
457
  agentsInstructionResult = updateInstructionFile(agentsInstructionPath, instructionBlock);
454
458
  }
455
459
  }
@@ -478,51 +482,56 @@ function runAssistantGuard(cwd, packageJsonPath, options, assistantConfig) {
478
482
  }
479
483
 
480
484
  /**
481
- * .claude/settings.json に lint:sparkle を強制実行する Stop hook を追加する。
485
+ * Agent 別の hook 設定ファイルに「lint:sparkle を強制実行する」hook を追加する。
482
486
  *
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
- * 全員に共通のガードレールであることを意図しているため。
487
+ * 各 agent が持つ hook システムの schema は異なるため、agent ごとに以下の
488
+ * ヘルパーを持つ。共通ポイントは:
489
+ * - 実行コマンドは `npx --yes sparkle-design-cli check <target> --strict || exit 2`
490
+ * - exit 2 にエスカレーションすることで、ブロック対応する agent では
491
+ * 応答終了を止めて findings 修正に向かわせる
492
+ * - 既存の hook 設定ファイルは非破壊マージし、同一 command が含まれていれば skip
493
+ * (冪等)
494
+ * - 壊れた JSON は silent 上書きせず、ユーザーに修正を促すエラーを出す
492
495
  *
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.
496
+ * en: Install an agent-specific hook that runs `lint:sparkle --strict || exit 2`.
497
+ * Each agent ships its own hook config format; these helpers emit the right
498
+ * shape while preserving existing user content and staying idempotent on rerun.
499
+ *
500
+ * References (2026-04 時点):
501
+ * - Claude Code : https://docs.claude.com/en/docs/claude-code/hooks
502
+ * - Cursor : https://cursor.com/docs/hooks
503
+ * - Codex : https://developers.openai.com/codex/hooks
498
504
  */
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
- }
505
+ function buildManagedHookCommand(target) {
506
+ return `npx --yes sparkle-design-cli check ${target} --strict || exit 2`;
507
+ }
508
+
509
+ function loadHookJson(filePath, label) {
510
+ if (!fs.existsSync(filePath)) {
511
+ return { existed: false, config: null };
512
+ }
513
+ try {
514
+ return { existed: true, config: readJson(filePath) };
515
+ } catch (error) {
516
+ throw new Error(
517
+ `${label} が不正な JSON です (${error.message})。修正してから再実行してください。`
518
+ );
516
519
  }
520
+ }
517
521
 
518
- const nextSettings = typeof settings === 'object' && settings ? { ...settings } : {};
519
- const hooks = typeof nextSettings.hooks === 'object' && nextSettings.hooks
520
- ? { ...nextSettings.hooks }
521
- : {};
522
+ function runClaudeHook(cwd, target, dryRun) {
523
+ const settingsPath = path.resolve(cwd, '.claude/settings.json');
524
+ const managedCommand = buildManagedHookCommand(target);
525
+ const { existed, config } = loadHookJson(settingsPath, '.claude/settings.json');
526
+
527
+ const nextSettings =
528
+ typeof config === 'object' && config ? { ...config } : {};
529
+ const hooks =
530
+ typeof nextSettings.hooks === 'object' && nextSettings.hooks
531
+ ? { ...nextSettings.hooks }
532
+ : {};
522
533
  const stopGroups = Array.isArray(hooks.Stop) ? [...hooks.Stop] : [];
523
534
 
524
- // 既存の Stop hook 配列に同一 command が含まれていれば冪等にスキップ。
525
- // en: Skip if the managed command already appears in any Stop entry.
526
535
  const alreadyPresent = stopGroups.some(
527
536
  (group) =>
528
537
  Array.isArray(group?.hooks) &&
@@ -531,21 +540,17 @@ function runClaudeHook(cwd, target, dryRun) {
531
540
 
532
541
  if (alreadyPresent) {
533
542
  return {
543
+ assistant: 'claude',
534
544
  changed: false,
535
545
  existed,
536
- settingsPath,
546
+ path: settingsPath,
537
547
  reason: 'already-present',
538
548
  command: managedCommand,
539
549
  };
540
550
  }
541
551
 
542
552
  stopGroups.push({
543
- hooks: [
544
- {
545
- type: 'command',
546
- command: managedCommand,
547
- },
548
- ],
553
+ hooks: [{ type: 'command', command: managedCommand }],
549
554
  });
550
555
  hooks.Stop = stopGroups;
551
556
  nextSettings.hooks = hooks;
@@ -556,14 +561,137 @@ function runClaudeHook(cwd, target, dryRun) {
556
561
  }
557
562
 
558
563
  return {
564
+ assistant: 'claude',
559
565
  changed: true,
560
566
  existed,
561
- settingsPath,
567
+ path: settingsPath,
562
568
  reason: existed ? 'appended' : 'created',
563
569
  command: managedCommand,
564
570
  };
565
571
  }
566
572
 
573
+ /**
574
+ * Cursor 1.7+ の hook 設定(`.cursor/hooks.json`)に stop hook を追加する。
575
+ * schema: `{ version: 1, hooks: { stop: [{ command: "..." }] } }`
576
+ */
577
+ function runCursorHook(cwd, target, dryRun) {
578
+ const hooksPath = path.resolve(cwd, '.cursor/hooks.json');
579
+ const managedCommand = buildManagedHookCommand(target);
580
+ const { existed, config } = loadHookJson(hooksPath, '.cursor/hooks.json');
581
+
582
+ const nextConfig = typeof config === 'object' && config ? { ...config } : {};
583
+ if (!nextConfig.version) nextConfig.version = 1;
584
+ const hooks =
585
+ typeof nextConfig.hooks === 'object' && nextConfig.hooks ? { ...nextConfig.hooks } : {};
586
+ const stopEntries = Array.isArray(hooks.stop) ? [...hooks.stop] : [];
587
+
588
+ const alreadyPresent = stopEntries.some((entry) => entry?.command === managedCommand);
589
+ if (alreadyPresent) {
590
+ return {
591
+ assistant: 'cursor',
592
+ changed: false,
593
+ existed,
594
+ path: hooksPath,
595
+ reason: 'already-present',
596
+ command: managedCommand,
597
+ };
598
+ }
599
+
600
+ stopEntries.push({ command: managedCommand });
601
+ hooks.stop = stopEntries;
602
+ nextConfig.hooks = hooks;
603
+
604
+ if (!dryRun) {
605
+ ensureDir(hooksPath);
606
+ writeJson(hooksPath, nextConfig);
607
+ }
608
+
609
+ return {
610
+ assistant: 'cursor',
611
+ changed: true,
612
+ existed,
613
+ path: hooksPath,
614
+ reason: existed ? 'appended' : 'created',
615
+ command: managedCommand,
616
+ };
617
+ }
618
+
619
+ /**
620
+ * Codex の hook 設定(`.codex/hooks.json`)に Stop hook を追加する。
621
+ * schema は Claude とほぼ同じネスト構造。
622
+ * 有効化には `~/.codex/config.toml` に `[features] codex_hooks = true` が必要。
623
+ */
624
+ function runCodexHook(cwd, target, dryRun) {
625
+ const hooksPath = path.resolve(cwd, '.codex/hooks.json');
626
+ const managedCommand = buildManagedHookCommand(target);
627
+ const { existed, config } = loadHookJson(hooksPath, '.codex/hooks.json');
628
+
629
+ const nextConfig = typeof config === 'object' && config ? { ...config } : {};
630
+ const hooks =
631
+ typeof nextConfig.hooks === 'object' && nextConfig.hooks ? { ...nextConfig.hooks } : {};
632
+ const stopGroups = Array.isArray(hooks.Stop) ? [...hooks.Stop] : [];
633
+
634
+ const alreadyPresent = stopGroups.some(
635
+ (group) =>
636
+ Array.isArray(group?.hooks) &&
637
+ group.hooks.some((entry) => entry?.type === 'command' && entry?.command === managedCommand)
638
+ );
639
+
640
+ if (alreadyPresent) {
641
+ return {
642
+ assistant: 'codex',
643
+ changed: false,
644
+ existed,
645
+ path: hooksPath,
646
+ reason: 'already-present',
647
+ command: managedCommand,
648
+ // 有効化にはユーザー側の opt-in が必要な旨を summary に載せる。
649
+ // en: Codex hooks are behind an opt-in feature flag; surface that in summary.
650
+ featureFlagNote:
651
+ 'Codex で有効化するには `~/.codex/config.toml` に `[features]` セクションを作り `codex_hooks = true` を設定してください。',
652
+ };
653
+ }
654
+
655
+ stopGroups.push({
656
+ hooks: [{ type: 'command', command: managedCommand }],
657
+ });
658
+ hooks.Stop = stopGroups;
659
+ nextConfig.hooks = hooks;
660
+
661
+ if (!dryRun) {
662
+ ensureDir(hooksPath);
663
+ writeJson(hooksPath, nextConfig);
664
+ }
665
+
666
+ return {
667
+ assistant: 'codex',
668
+ changed: true,
669
+ existed,
670
+ path: hooksPath,
671
+ reason: existed ? 'appended' : 'created',
672
+ command: managedCommand,
673
+ featureFlagNote:
674
+ 'Codex で有効化するには `~/.codex/config.toml` に `[features]` セクションを作り `codex_hooks = true` を設定してください。',
675
+ };
676
+ }
677
+
678
+ /**
679
+ * --assistant 値から該当 hook writer に dispatch する。generic は hook なし。
680
+ * en: Dispatch to the appropriate hook writer for the selected assistant.
681
+ */
682
+ function runAssistantHook(assistant, cwd, target, dryRun) {
683
+ switch (assistant) {
684
+ case 'claude':
685
+ return runClaudeHook(cwd, target, dryRun);
686
+ case 'cursor':
687
+ return runCursorHook(cwd, target, dryRun);
688
+ case 'codex':
689
+ return runCodexHook(cwd, target, dryRun);
690
+ default:
691
+ return null;
692
+ }
693
+ }
694
+
567
695
  function runGenerate({ skipGenerate, dryRun, strict }) {
568
696
  // --skip-generate / --dry-run と --strict が同時指定された場合、strict チェックを
569
697
  // 走らせる対象 (generate) そのものがスキップされるため strict は事実上無意味。
@@ -619,12 +747,13 @@ export function setupAssistant(options = {}) {
619
747
  { ...options, assistant, dryRun },
620
748
  assistantConfig
621
749
  );
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;
750
+ // assistant 別に hook 設定ファイル(`.claude/settings.json` /
751
+ // `.cursor/hooks.json` / `.codex/hooks.json`)に `lint:sparkle --strict` を
752
+ // 走らせる stop / Stop hook を自動設定する。instruction 頼みではなく hook
753
+ // で強制することで lint:sparkle の実行漏れを防ぐ。generic は hook なし。
754
+ // en: For supported assistants, install a stop hook so `lint:sparkle --strict`
755
+ // becomes a hard gate. Generic assistant has no hook target.
756
+ const hook = runAssistantHook(assistant, cwd, guard.target, dryRun);
628
757
  const generate = runGenerate({
629
758
  skipGenerate: Boolean(options.skipGenerate),
630
759
  dryRun,
@@ -662,12 +791,14 @@ export function setupAssistant(options = {}) {
662
791
  existed: guard.agentsInstructionResult?.existed ?? false,
663
792
  }
664
793
  : null,
665
- claudeHook: claudeHook
794
+ hook: hook
666
795
  ? {
667
- path: path.relative(cwd, claudeHook.settingsPath),
668
- changed: claudeHook.changed,
669
- existed: claudeHook.existed,
670
- reason: claudeHook.reason,
796
+ assistant: hook.assistant,
797
+ path: path.relative(cwd, hook.path),
798
+ changed: hook.changed,
799
+ existed: hook.existed,
800
+ reason: hook.reason,
801
+ featureFlagNote: hook.featureFlagNote ?? null,
671
802
  }
672
803
  : null,
673
804
  };
@@ -677,7 +808,7 @@ export function setupAssistant(options = {}) {
677
808
  // セットアップ後のリマインダー。AI の会話履歴にも残るよう stderr に出す。
678
809
  // en: Post-setup reminder. Written to stderr so it stays in the AI transcript
679
810
  // without polluting the JSON stdout payload that tooling parses.
680
- printPostSetupReminder(guard.target, packageManager, { claudeHook });
811
+ printPostSetupReminder(guard.target, packageManager, { hook });
681
812
 
682
813
  return summary;
683
814
  }
@@ -699,7 +830,13 @@ function buildLintCommand(packageManager) {
699
830
  }
700
831
  }
701
832
 
702
- function printPostSetupReminder(target, packageManager, { claudeHook } = {}) {
833
+ const HOOK_LABELS = {
834
+ claude: { name: 'Claude Code', file: '.claude/settings.json', event: 'Stop' },
835
+ cursor: { name: 'Cursor', file: '.cursor/hooks.json', event: 'stop' },
836
+ codex: { name: 'Codex', file: '.codex/hooks.json', event: 'Stop' },
837
+ };
838
+
839
+ function printPostSetupReminder(target, packageManager, { hook } = {}) {
703
840
  const lintTarget = target || 'src';
704
841
  const lintCmd = buildLintCommand(packageManager);
705
842
  const lines = [
@@ -712,17 +849,21 @@ function printPostSetupReminder(target, packageManager, { claudeHook } = {}) {
712
849
  ' 3. AI でレビューする場合は `lint:sparkle:json` を使い、`findings` に加えて `manualReviewReminders` の各項目まで必ず確認してください。',
713
850
  ' For AI review, use `lint:sparkle:json` and inspect every entry of both `findings` and `manualReviewReminders`.',
714
851
  ];
715
- if (claudeHook?.changed || claudeHook?.reason === 'already-present') {
852
+ if (hook && (hook.changed || hook.reason === 'already-present')) {
853
+ const meta = HOOK_LABELS[hook.assistant] ?? HOOK_LABELS.claude;
716
854
  const label =
717
- claudeHook.reason === 'already-present'
855
+ hook.reason === 'already-present'
718
856
  ? '(既存のまま)'
719
- : claudeHook.reason === 'created'
857
+ : hook.reason === 'created'
720
858
  ? '(新規作成)'
721
859
  : '(既存設定に追記)';
722
860
  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.'
861
+ ` 4. ${meta.name} の ${meta.event} hook を ${meta.file} に設定しました ${label}。応答を終える直前に \`lint:sparkle --strict\` が走り、findings があれば exit 2 で停止がブロックされます。`,
862
+ ` 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
863
  );
864
+ if (hook.featureFlagNote) {
865
+ lines.push(` ⚠️ ${hook.featureFlagNote}`);
866
+ }
726
867
  }
727
868
  lines.push('');
728
869
  for (const line of lines) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparkle-design-cli",
3
- "version": "2.0.7-beta.6",
3
+ "version": "2.0.7-beta.7",
4
4
  "description": "Sparkle Design CSS Generator - デザインシステムCSSを設定ファイルから生成するツール",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",