sparkle-design-cli 2.0.7-beta.5 → 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,12 +437,37 @@ 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 のときに、プロジェクトルートに既存の 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.
451
+ let agentsInstructionResult = null;
452
+ let agentsInstructionPath = null;
453
+ if (options.assistant === 'claude' && !options.instructionsPath) {
454
+ const candidate = path.resolve(cwd, 'AGENTS.md');
455
+ if (candidate !== instructionPath && fs.existsSync(candidate)) {
456
+ agentsInstructionPath = candidate;
457
+ agentsInstructionResult = updateInstructionFile(agentsInstructionPath, instructionBlock);
458
+ }
459
+ }
460
+
440
461
  if (!options.dryRun) {
441
462
  if (packageResult.changed) writeJson(packageJsonPath, packageResult.packageJson);
442
463
  if (instructionResult.changed) {
443
464
  ensureDir(instructionPath);
444
465
  fs.writeFileSync(instructionPath, instructionResult.content, 'utf8');
445
466
  }
467
+ if (agentsInstructionResult?.changed && agentsInstructionPath) {
468
+ ensureDir(agentsInstructionPath);
469
+ fs.writeFileSync(agentsInstructionPath, agentsInstructionResult.content, 'utf8');
470
+ }
446
471
  }
447
472
 
448
473
  return {
@@ -451,9 +476,222 @@ function runAssistantGuard(cwd, packageJsonPath, options, assistantConfig) {
451
476
  instructionPath,
452
477
  packageResult,
453
478
  instructionResult,
479
+ agentsInstructionPath,
480
+ agentsInstructionResult,
481
+ };
482
+ }
483
+
484
+ /**
485
+ * Agent 別の hook 設定ファイルに「lint:sparkle を強制実行する」hook を追加する。
486
+ *
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 上書きせず、ユーザーに修正を促すエラーを出す
495
+ *
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
504
+ */
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
+ );
519
+ }
520
+ }
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
+ : {};
533
+ const stopGroups = Array.isArray(hooks.Stop) ? [...hooks.Stop] : [];
534
+
535
+ const alreadyPresent = stopGroups.some(
536
+ (group) =>
537
+ Array.isArray(group?.hooks) &&
538
+ group.hooks.some((entry) => entry?.type === 'command' && entry?.command === managedCommand)
539
+ );
540
+
541
+ if (alreadyPresent) {
542
+ return {
543
+ assistant: 'claude',
544
+ changed: false,
545
+ existed,
546
+ path: settingsPath,
547
+ reason: 'already-present',
548
+ command: managedCommand,
549
+ };
550
+ }
551
+
552
+ stopGroups.push({
553
+ hooks: [{ type: 'command', command: managedCommand }],
554
+ });
555
+ hooks.Stop = stopGroups;
556
+ nextSettings.hooks = hooks;
557
+
558
+ if (!dryRun) {
559
+ ensureDir(settingsPath);
560
+ writeJson(settingsPath, nextSettings);
561
+ }
562
+
563
+ return {
564
+ assistant: 'claude',
565
+ changed: true,
566
+ existed,
567
+ path: settingsPath,
568
+ reason: existed ? 'appended' : 'created',
569
+ command: managedCommand,
570
+ };
571
+ }
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,
454
616
  };
455
617
  }
456
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
+
457
695
  function runGenerate({ skipGenerate, dryRun, strict }) {
458
696
  // --skip-generate / --dry-run と --strict が同時指定された場合、strict チェックを
459
697
  // 走らせる対象 (generate) そのものがスキップされるため strict は事実上無意味。
@@ -509,6 +747,13 @@ export function setupAssistant(options = {}) {
509
747
  { ...options, assistant, dryRun },
510
748
  assistantConfig
511
749
  );
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);
512
757
  const generate = runGenerate({
513
758
  skipGenerate: Boolean(options.skipGenerate),
514
759
  dryRun,
@@ -539,6 +784,23 @@ export function setupAssistant(options = {}) {
539
784
  changed: guard.instructionResult.changed,
540
785
  existed: guard.instructionResult.existed,
541
786
  },
787
+ agentsInstructions: guard.agentsInstructionPath
788
+ ? {
789
+ path: path.relative(cwd, guard.agentsInstructionPath),
790
+ changed: guard.agentsInstructionResult?.changed ?? false,
791
+ existed: guard.agentsInstructionResult?.existed ?? false,
792
+ }
793
+ : null,
794
+ hook: hook
795
+ ? {
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,
802
+ }
803
+ : null,
542
804
  };
543
805
 
544
806
  console.log(JSON.stringify(summary, null, 2));
@@ -546,7 +808,7 @@ export function setupAssistant(options = {}) {
546
808
  // セットアップ後のリマインダー。AI の会話履歴にも残るよう stderr に出す。
547
809
  // en: Post-setup reminder. Written to stderr so it stays in the AI transcript
548
810
  // without polluting the JSON stdout payload that tooling parses.
549
- printPostSetupReminder(guard.target, packageManager);
811
+ printPostSetupReminder(guard.target, packageManager, { hook });
550
812
 
551
813
  return summary;
552
814
  }
@@ -568,7 +830,13 @@ function buildLintCommand(packageManager) {
568
830
  }
569
831
  }
570
832
 
571
- function printPostSetupReminder(target, packageManager) {
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 } = {}) {
572
840
  const lintTarget = target || 'src';
573
841
  const lintCmd = buildLintCommand(packageManager);
574
842
  const lines = [
@@ -580,8 +848,24 @@ function printPostSetupReminder(target, packageManager) {
580
848
  ' After creating or modifying UI components, always run `lint:sparkle` to catch Sparkle Design anti-patterns.',
581
849
  ' 3. AI でレビューする場合は `lint:sparkle:json` を使い、`findings` に加えて `manualReviewReminders` の各項目まで必ず確認してください。',
582
850
  ' For AI review, use `lint:sparkle:json` and inspect every entry of both `findings` and `manualReviewReminders`.',
583
- '',
584
851
  ];
852
+ if (hook && (hook.changed || hook.reason === 'already-present')) {
853
+ const meta = HOOK_LABELS[hook.assistant] ?? HOOK_LABELS.claude;
854
+ const label =
855
+ hook.reason === 'already-present'
856
+ ? '(既存のまま)'
857
+ : hook.reason === 'created'
858
+ ? '(新規作成)'
859
+ : '(既存設定に追記)';
860
+ lines.push(
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.`
863
+ );
864
+ if (hook.featureFlagNote) {
865
+ lines.push(` ⚠️ ${hook.featureFlagNote}`);
866
+ }
867
+ }
868
+ lines.push('');
585
869
  for (const line of lines) {
586
870
  console.error(line);
587
871
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparkle-design-cli",
3
- "version": "2.0.7-beta.5",
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",