sparkle-design-cli 2.0.7-rc.4 → 2.0.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.
@@ -3,8 +3,9 @@
3
3
  import { generateCSS } from '../lib/generate-css.js';
4
4
  import { checkProject } from '../lib/check.js';
5
5
  import { setupAssistant } from '../lib/setup.js';
6
+ import { runStopHook } from '../lib/stop-hook.js';
6
7
 
7
- const SUBCOMMANDS = new Set(['generate', 'check', 'setup']);
8
+ const SUBCOMMANDS = new Set(['generate', 'check', 'setup', 'stop-hook']);
8
9
 
9
10
  function requireOptionValue(args, index, flags) {
10
11
  const value = args[index + 1];
@@ -138,6 +139,7 @@ Commands:
138
139
  generate sparkle.config.json から CSS を生成
139
140
  check Sparkle Design のアンチパターンを検査
140
141
  setup Sparkle Design プロジェクトをセットアップ(パッケージ導入 + 初期ファイル + AI ガード + generate)
142
+ stop-hook AI assistant の Stop hook 用 internal subcommand(findings 検出時に exit 2 で 1 度だけ停止をブロック / 再発火は自動回避)
141
143
 
142
144
  Generate:
143
145
  sparkle-design-cli generate
@@ -302,6 +304,16 @@ function main() {
302
304
  return;
303
305
  }
304
306
 
307
+ if (command === 'stop-hook') {
308
+ // setup で各 agent の hook 設定ファイルから呼ばれる internal subcommand。
309
+ // 第 2 引数は lint 対象 path(setup 時に決まったもの)。option flag は未対応。
310
+ // en: Internal subcommand invoked by agent stop hooks. The single positional
311
+ // arg is the lint target path determined at setup time.
312
+ const target = args[1];
313
+ const exitCode = runStopHook(target);
314
+ process.exit(exitCode);
315
+ }
316
+
305
317
  // 未知のコマンド: エラー終了ではなく note + help 表示にする(typo 時の迷子を防ぐ)
306
318
  // en: Unknown command: show a short note + help instead of throwing
307
319
  console.warn(
package/lib/setup.js CHANGED
@@ -594,16 +594,20 @@ function runAssistantGuard(cwd, packageJsonPath, options, assistantConfig) {
594
594
  *
595
595
  * 各 agent が持つ hook システムの schema は異なるため、agent ごとに以下の
596
596
  * ヘルパーを持つ。共通ポイントは:
597
- * - 実行コマンドは `npx --yes sparkle-design-cli check <target> --strict || exit 2`
598
- * - exit 2 にエスカレーションすることで、ブロック対応する agent では
599
- * 応答終了を止めて findings 修正に向かわせる
597
+ * - 実行コマンドは `npx --yes sparkle-design-cli stop-hook <target>`
598
+ * (内部で check --strict を回し、初回 findings 検出時のみ exit 2 で停止を
599
+ * ブロックする。Claude Code の `stop_hook_active` を読んで再発火時は
600
+ * exit 0 で抜けることでセッション内ループを防ぐ)
600
601
  * - 既存の hook 設定ファイルは非破壊マージし、同一 command が含まれていれば skip
601
- * (冪等)
602
+ * (冪等)。2.0.7 までの旧コマンド `check ... --strict || exit 2` を見つけたら
603
+ * 新 subcommand へ自動移行する
602
604
  * - 壊れた JSON は silent 上書きせず、ユーザーに修正を促すエラーを出す
603
605
  *
604
- * en: Install an agent-specific hook that runs `lint:sparkle --strict || exit 2`.
605
- * Each agent ships its own hook config format; these helpers emit the right
606
- * shape while preserving existing user content and staying idempotent on rerun.
606
+ * en: Install an agent-specific hook that runs the `stop-hook` subcommand,
607
+ * which surfaces findings once and avoids the infinite re-fire loop the
608
+ * pre-2.0.8 form had. Each agent ships its own hook config format; these
609
+ * helpers emit the right shape, stay idempotent on rerun, and migrate
610
+ * legacy 2.0.7 commands when found.
607
611
  *
608
612
  * References (2026-04 時点):
609
613
  * - Claude Code : https://docs.claude.com/en/docs/claude-code/hooks
@@ -611,7 +615,26 @@ function runAssistantGuard(cwd, packageJsonPath, options, assistantConfig) {
611
615
  * - Codex : https://developers.openai.com/codex/hooks
612
616
  */
613
617
  function buildManagedHookCommand(target) {
614
- return `npx --yes sparkle-design-cli check ${target} --strict || exit 2`;
618
+ // Stop / stop hook は専用 subcommand に集約する。subcommand 側で
619
+ // `stop_hook_active` を見て初回だけ exit 2、再発火時は exit 0 で抜ける
620
+ // ことでセッション内ループを防ぐ。
621
+ // en: Route through the dedicated `stop-hook` subcommand so we can detect
622
+ // re-fires (Claude Code's `stop_hook_active`) and avoid infinite loops.
623
+ return `npx --yes sparkle-design-cli stop-hook ${target}`;
624
+ }
625
+
626
+ /**
627
+ * 2.0.7 までは `npx --yes sparkle-design-cli check <target> --strict || exit 2` を
628
+ * 直接 hook に書いていたが、findings が残っていると Stop hook が再発火し続けて
629
+ * セッションが無限ループする問題があった。`stop-hook` subcommand に切り替える
630
+ * ため、既存 hook 設定にこの旧コマンドが残っている場合は新コマンドへ置換する。
631
+ *
632
+ * en: Detect legacy hook commands installed by 2.0.7 and earlier so setup
633
+ * reruns can heal them by swapping in the loop-safe `stop-hook` subcommand.
634
+ */
635
+ function isLegacyManagedHookCommand(command) {
636
+ if (typeof command !== 'string') return false;
637
+ return /sparkle-design-cli\s+check\s+\S+\s+--strict\s*\|\|\s*exit\s+2/.test(command);
615
638
  }
616
639
 
617
640
  function loadHookJson(filePath, label) {
@@ -685,14 +708,40 @@ function runClaudeHook(cwd, target, dryRun) {
685
708
  typeof nextSettings.hooks === 'object' && nextSettings.hooks ? { ...nextSettings.hooks } : {};
686
709
  const stopGroups = Array.isArray(hooks.Stop) ? [...hooks.Stop] : [];
687
710
 
688
- const alreadyPresent = stopGroups.some(
689
- (group) =>
690
- Array.isArray(group?.hooks) &&
691
- group.hooks.some((entry) => entry?.type === 'command' && entry?.command === managedCommand)
692
- );
711
+ // 1st pass: legacy エントリを発見。alreadyPresent / migrated を判定する。
712
+ // 2 回目以降の setup や、ユーザーが手動で新コマンドを足した状態でも
713
+ // 重複が出ないよう、変換は 2nd pass で行う。
714
+ // en: First pass detects state; second pass mutates without creating dupes.
715
+ let alreadyPresent = false;
716
+ let hasLegacy = false;
717
+ for (const group of stopGroups) {
718
+ if (!Array.isArray(group?.hooks)) continue;
719
+ for (const entry of group.hooks) {
720
+ if (entry?.type !== 'command') continue;
721
+ if (entry.command === managedCommand) alreadyPresent = true;
722
+ else if (isLegacyManagedHookCommand(entry.command)) hasLegacy = true;
723
+ }
724
+ }
725
+ const migrated = hasLegacy;
726
+
727
+ const updatedGroups = stopGroups.map((group) => {
728
+ if (!Array.isArray(group?.hooks)) return group;
729
+ const updatedHooks = group.hooks
730
+ .map((entry) => {
731
+ if (entry?.type === 'command' && isLegacyManagedHookCommand(entry.command)) {
732
+ // legacy + 新コマンドが両方あったら legacy を削除(dedupe)。
733
+ // legacy のみなら新コマンドへ in-place 置換。
734
+ // en: Drop legacy when the new command coexists; otherwise rewrite.
735
+ return alreadyPresent ? null : { ...entry, command: managedCommand };
736
+ }
737
+ return entry;
738
+ })
739
+ .filter((entry) => entry !== null);
740
+ return { ...group, hooks: updatedHooks };
741
+ });
693
742
 
694
743
  const mismatch = detectClaudeSessionRootMismatch(cwd);
695
- if (alreadyPresent) {
744
+ if (alreadyPresent && !migrated) {
696
745
  return {
697
746
  assistant: 'claude',
698
747
  changed: false,
@@ -704,10 +753,12 @@ function runClaudeHook(cwd, target, dryRun) {
704
753
  };
705
754
  }
706
755
 
707
- stopGroups.push({
708
- hooks: [{ type: 'command', command: managedCommand }],
709
- });
710
- hooks.Stop = stopGroups;
756
+ let nextStopGroups = updatedGroups;
757
+ if (!migrated && !alreadyPresent) {
758
+ nextStopGroups = [...updatedGroups, { hooks: [{ type: 'command', command: managedCommand }] }];
759
+ }
760
+
761
+ hooks.Stop = nextStopGroups;
711
762
  nextSettings.hooks = hooks;
712
763
 
713
764
  if (!dryRun) {
@@ -719,7 +770,7 @@ function runClaudeHook(cwd, target, dryRun) {
719
770
  changed: true,
720
771
  existed,
721
772
  path: settingsPath,
722
- reason: existed ? 'appended' : 'created',
773
+ reason: migrated ? 'migrated' : existed ? 'appended' : 'created',
723
774
  command: managedCommand,
724
775
  sessionRootMismatch: mismatch,
725
776
  };
@@ -740,8 +791,24 @@ function runCursorHook(cwd, target, dryRun) {
740
791
  typeof nextConfig.hooks === 'object' && nextConfig.hooks ? { ...nextConfig.hooks } : {};
741
792
  const stopEntries = Array.isArray(hooks.stop) ? [...hooks.stop] : [];
742
793
 
743
- const alreadyPresent = stopEntries.some((entry) => entry?.command === managedCommand);
744
- if (alreadyPresent) {
794
+ let alreadyPresent = false;
795
+ let hasLegacy = false;
796
+ for (const entry of stopEntries) {
797
+ if (entry?.command === managedCommand) alreadyPresent = true;
798
+ else if (isLegacyManagedHookCommand(entry?.command)) hasLegacy = true;
799
+ }
800
+ const migrated = hasLegacy;
801
+
802
+ const updatedEntries = stopEntries
803
+ .map((entry) => {
804
+ if (isLegacyManagedHookCommand(entry?.command)) {
805
+ return alreadyPresent ? null : { ...entry, command: managedCommand };
806
+ }
807
+ return entry;
808
+ })
809
+ .filter((entry) => entry !== null);
810
+
811
+ if (alreadyPresent && !migrated) {
745
812
  return {
746
813
  assistant: 'cursor',
747
814
  changed: false,
@@ -752,8 +819,12 @@ function runCursorHook(cwd, target, dryRun) {
752
819
  };
753
820
  }
754
821
 
755
- stopEntries.push({ command: managedCommand });
756
- hooks.stop = stopEntries;
822
+ let nextStopEntries = updatedEntries;
823
+ if (!migrated && !alreadyPresent) {
824
+ nextStopEntries = [...updatedEntries, { command: managedCommand }];
825
+ }
826
+
827
+ hooks.stop = nextStopEntries;
757
828
  nextConfig.hooks = hooks;
758
829
 
759
830
  if (!dryRun) {
@@ -766,7 +837,7 @@ function runCursorHook(cwd, target, dryRun) {
766
837
  changed: true,
767
838
  existed,
768
839
  path: hooksPath,
769
- reason: existed ? 'appended' : 'created',
840
+ reason: migrated ? 'migrated' : existed ? 'appended' : 'created',
770
841
  command: managedCommand,
771
842
  };
772
843
  }
@@ -786,13 +857,37 @@ function runCodexHook(cwd, target, dryRun) {
786
857
  typeof nextConfig.hooks === 'object' && nextConfig.hooks ? { ...nextConfig.hooks } : {};
787
858
  const stopGroups = Array.isArray(hooks.Stop) ? [...hooks.Stop] : [];
788
859
 
789
- const alreadyPresent = stopGroups.some(
790
- (group) =>
791
- Array.isArray(group?.hooks) &&
792
- group.hooks.some((entry) => entry?.type === 'command' && entry?.command === managedCommand)
793
- );
860
+ let alreadyPresent = false;
861
+ let hasLegacy = false;
862
+ for (const group of stopGroups) {
863
+ if (!Array.isArray(group?.hooks)) continue;
864
+ for (const entry of group.hooks) {
865
+ if (entry?.type !== 'command') continue;
866
+ if (entry.command === managedCommand) alreadyPresent = true;
867
+ else if (isLegacyManagedHookCommand(entry.command)) hasLegacy = true;
868
+ }
869
+ }
870
+ const migrated = hasLegacy;
871
+
872
+ const updatedGroups = stopGroups.map((group) => {
873
+ if (!Array.isArray(group?.hooks)) return group;
874
+ const updatedHooks = group.hooks
875
+ .map((entry) => {
876
+ if (entry?.type === 'command' && isLegacyManagedHookCommand(entry.command)) {
877
+ return alreadyPresent ? null : { ...entry, command: managedCommand };
878
+ }
879
+ return entry;
880
+ })
881
+ .filter((entry) => entry !== null);
882
+ return { ...group, hooks: updatedHooks };
883
+ });
884
+
885
+ // 有効化にはユーザー側の opt-in が必要な旨を summary に載せる。
886
+ // en: Codex hooks are behind an opt-in feature flag; surface that in summary.
887
+ const featureFlagNote =
888
+ 'Codex で有効化するには `~/.codex/config.toml` に `[features]` セクションを作り `codex_hooks = true` を設定してください。';
794
889
 
795
- if (alreadyPresent) {
890
+ if (alreadyPresent && !migrated) {
796
891
  return {
797
892
  assistant: 'codex',
798
893
  changed: false,
@@ -800,17 +895,16 @@ function runCodexHook(cwd, target, dryRun) {
800
895
  path: hooksPath,
801
896
  reason: 'already-present',
802
897
  command: managedCommand,
803
- // 有効化にはユーザー側の opt-in が必要な旨を summary に載せる。
804
- // en: Codex hooks are behind an opt-in feature flag; surface that in summary.
805
- featureFlagNote:
806
- 'Codex で有効化するには `~/.codex/config.toml` に `[features]` セクションを作り `codex_hooks = true` を設定してください。',
898
+ featureFlagNote,
807
899
  };
808
900
  }
809
901
 
810
- stopGroups.push({
811
- hooks: [{ type: 'command', command: managedCommand }],
812
- });
813
- hooks.Stop = stopGroups;
902
+ let nextStopGroups = updatedGroups;
903
+ if (!migrated && !alreadyPresent) {
904
+ nextStopGroups = [...updatedGroups, { hooks: [{ type: 'command', command: managedCommand }] }];
905
+ }
906
+
907
+ hooks.Stop = nextStopGroups;
814
908
  nextConfig.hooks = hooks;
815
909
 
816
910
  if (!dryRun) {
@@ -823,10 +917,9 @@ function runCodexHook(cwd, target, dryRun) {
823
917
  changed: true,
824
918
  existed,
825
919
  path: hooksPath,
826
- reason: existed ? 'appended' : 'created',
920
+ reason: migrated ? 'migrated' : existed ? 'appended' : 'created',
827
921
  command: managedCommand,
828
- featureFlagNote:
829
- 'Codex で有効化するには `~/.codex/config.toml` に `[features]` セクションを作り `codex_hooks = true` を設定してください。',
922
+ featureFlagNote,
830
923
  };
831
924
  }
832
925
 
@@ -918,11 +1011,12 @@ export function setupAssistant(options = {}) {
918
1011
  assistantConfig
919
1012
  );
920
1013
  // assistant 別に hook 設定ファイル(`.claude/settings.json` /
921
- // `.cursor/hooks.json` / `.codex/hooks.json`)に `lint:sparkle --strict` を
922
- // 走らせる stop / Stop hook を自動設定する。instruction 頼みではなく hook
923
- // で強制することで lint:sparkle の実行漏れを防ぐ。generic は hook なし。
924
- // en: For supported assistants, install a stop hook so `lint:sparkle --strict`
925
- // becomes a hard gate. Generic assistant has no hook target.
1014
+ // `.cursor/hooks.json` / `.codex/hooks.json`)に `stop-hook` subcommand を
1015
+ // 設定する。instruction 頼みではなく hook で強制することで lint:sparkle の
1016
+ // 実行漏れを防ぐ。subcommand 側で `stop_hook_active` を見て再発火を抑止し、
1017
+ // findings があるときの 1 回だけ exit 2 でブロックする。generic は hook なし。
1018
+ // en: Install the loop-safe `stop-hook` subcommand so lint:sparkle becomes a
1019
+ // hard gate. Generic assistant has no hook target.
926
1020
  const hook = runAssistantHook(assistant, cwd, guard.target, dryRun);
927
1021
  const generate = runGenerate({
928
1022
  skipGenerate: Boolean(options.skipGenerate),
@@ -1045,10 +1139,12 @@ function printPostSetupReminder(target, packageManager, { hook, legacyCursorGuar
1045
1139
  ? '(既存のまま)'
1046
1140
  : hook.reason === 'created'
1047
1141
  ? '(新規作成)'
1048
- : '(既存設定に追記)';
1142
+ : hook.reason === 'migrated'
1143
+ ? '(旧 || exit 2 形式から自動移行)'
1144
+ : '(既存設定に追記)';
1049
1145
  lines.push(
1050
- ` 4. ${meta.name} の ${meta.event} hook を ${meta.file} に設定しました ${label}。応答を終える直前に \`lint:sparkle --strict\` が走り、findings があれば exit 2 で停止がブロックされます。`,
1051
- ` 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.`
1146
+ ` 4. ${meta.name} の ${meta.event} hook を ${meta.file} に設定しました ${label}。応答を終える直前に \`sparkle-design-cli stop-hook\` が走り、findings があれば 1 度だけ exit 2 で停止をブロックします(再発火は \`stop_hook_active\` を見て自動回避)。`,
1147
+ ` Installed a ${meta.name} ${meta.event} hook in ${meta.file}: it runs \`sparkle-design-cli stop-hook\` before the turn ends, blocks once with exit 2 when findings exist, and skips re-blocking on stop_hook_active.`
1052
1148
  );
1053
1149
  if (hook.featureFlagNote) {
1054
1150
  lines.push(` ⚠️ ${hook.featureFlagNote}`);
@@ -0,0 +1,60 @@
1
+ import fs from 'fs';
2
+ import { checkProject } from './check.js';
3
+
4
+ /**
5
+ * AI assistant の Stop / stop hook 用エントリポイント。
6
+ *
7
+ * 単に `lint:sparkle --strict || exit 2` を毎回走らせると、Claude Code
8
+ * のように exit 2 を「もう一度ターンを継続させる」シグナルとして解釈する
9
+ * agent では、findings が残っている限り Stop hook が再発火し続けて
10
+ * セッションが無限ループに陥る。
11
+ *
12
+ * Claude Code は再発火時に stdin JSON へ `stop_hook_active: true` を
13
+ * 渡してくるので、そのフラグが立っていたらここで block せず exit 0 で
14
+ * 抜ける。これで「最初の 1 回だけ exit 2 で停止をブロックして findings
15
+ * を通知し、以降はユーザーの判断に委ねる」フローになる。
16
+ *
17
+ * en: Run `check --strict` once per session to surface findings, but stop
18
+ * blocking on subsequent invocations within the same Stop hook chain.
19
+ * Claude Code sets `stop_hook_active: true` on re-fires so the hook can
20
+ * exit cleanly instead of looping forever (see Claude Code hooks docs).
21
+ */
22
+ export function runStopHook(target) {
23
+ const payload = readStdinJsonSafely();
24
+
25
+ if (payload && payload.stop_hook_active === true) {
26
+ process.stderr.write(
27
+ 'sparkle-design-cli stop-hook: stop_hook_active=true を検知したため再ブロックしません。' +
28
+ '前回の exit 2 で findings は通知済みです。' +
29
+ ' / Detected stop_hook_active=true; not re-blocking. Findings were already surfaced on the first call.\n'
30
+ );
31
+ return 0;
32
+ }
33
+
34
+ const targets = target ? [target] : [];
35
+ const hasFindings = checkProject(targets, { strict: true, format: 'text' });
36
+
37
+ if (hasFindings) {
38
+ process.stderr.write(
39
+ '\nsparkle-design-cli stop-hook: findings を検出したため応答終了を 1 度だけブロックしました。' +
40
+ '上記の findings を修正するか、対応しない判断であればユーザーに確認してから再度応答を完了してください。' +
41
+ ' / Blocked once because findings were detected. Fix them or confirm with the user before completing the response again.\n'
42
+ );
43
+ return 2;
44
+ }
45
+
46
+ return 0;
47
+ }
48
+
49
+ function readStdinJsonSafely() {
50
+ // stdin が tty / 空 / 非 JSON の場合は「初回呼び出し」として扱う。
51
+ // en: Treat missing or non-JSON stdin as a first invocation.
52
+ try {
53
+ if (process.stdin.isTTY) return null;
54
+ const raw = fs.readFileSync(0, 'utf8');
55
+ if (!raw || !raw.trim()) return null;
56
+ return JSON.parse(raw);
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparkle-design-cli",
3
- "version": "2.0.7-rc.4",
3
+ "version": "2.0.8",
4
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",