cc-plan-viewer 0.2.1 → 0.2.3

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 CHANGED
@@ -1,4 +1,4 @@
1
- # cc-plan-viewer
1
+ # Claude Code Plan Viewer
2
2
 
3
3
  A browser-based review UI for Claude Code plans. When Claude Code enters plan mode, a browser tab opens automatically showing the plan with proper markdown rendering, syntax highlighting, and inline commenting.
4
4
 
@@ -27498,7 +27498,8 @@ var init_lifecycle = __esm({
27498
27498
  import { createServer } from "node:http";
27499
27499
  import fs3 from "node:fs";
27500
27500
  import path3 from "node:path";
27501
- function createApp(plansDirs2) {
27501
+ function createApp(initialPlansDirs) {
27502
+ const plansDirs2 = [...initialPlansDirs];
27502
27503
  const plansDir = plansDirs2[0] || "";
27503
27504
  const app = (0, import_express.default)();
27504
27505
  const server2 = createServer(app);
@@ -27569,6 +27570,10 @@ function createApp(plansDirs2) {
27569
27570
  app.post("/api/plan-updated", (req, res) => {
27570
27571
  const { filePath, planOptions } = req.body;
27571
27572
  const filename = path3.basename(filePath || "");
27573
+ const dir = path3.dirname(filePath || "");
27574
+ if (dir && !plansDirs2.includes(dir) && fs3.existsSync(dir)) {
27575
+ plansDirs2.push(dir);
27576
+ }
27572
27577
  const message = JSON.stringify({
27573
27578
  type: "plan-updated",
27574
27579
  filename,
@@ -27755,6 +27760,7 @@ var init_server = __esm({
27755
27760
  import fs6 from "node:fs";
27756
27761
  import path6 from "node:path";
27757
27762
  import os3 from "node:os";
27763
+ import readline from "node:readline";
27758
27764
  import { execSync } from "node:child_process";
27759
27765
  var INSTALL_DIR = path6.join(os3.homedir(), ".cc-plan-viewer");
27760
27766
  var INSTALLED_HOOK = path6.join(INSTALL_DIR, "plan-viewer-hook.cjs");
@@ -27769,18 +27775,6 @@ function getPkgRoot() {
27769
27775
  return PKG_ROOT_DEV;
27770
27776
  }
27771
27777
  var DEFAULT_SETTINGS = path6.join(os3.homedir(), ".claude", "settings.json");
27772
- function resolveSettingsPath() {
27773
- const configIdx = process.argv.indexOf("--config");
27774
- if (configIdx !== -1 && process.argv[configIdx + 1]) {
27775
- const custom = path6.resolve(process.argv[configIdx + 1]);
27776
- if (!fs6.existsSync(path6.dirname(custom))) {
27777
- console.error(`[cc-plan-viewer] Directory does not exist: ${path6.dirname(custom)}`);
27778
- process.exit(1);
27779
- }
27780
- return custom;
27781
- }
27782
- return DEFAULT_SETTINGS;
27783
- }
27784
27778
  function readSettings(settingsPath) {
27785
27779
  try {
27786
27780
  return JSON.parse(fs6.readFileSync(settingsPath, "utf8"));
@@ -27794,6 +27788,131 @@ function writeSettings(settingsPath, settings) {
27794
27788
  fs6.mkdirSync(dir, { recursive: true });
27795
27789
  fs6.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
27796
27790
  }
27791
+ function findAllClaudeSettings() {
27792
+ const home = os3.homedir();
27793
+ const found = /* @__PURE__ */ new Set();
27794
+ try {
27795
+ for (const name of fs6.readdirSync(home)) {
27796
+ if (!name.startsWith(".claude"))
27797
+ continue;
27798
+ const candidate = path6.join(home, name, "settings.json");
27799
+ if (fs6.existsSync(candidate))
27800
+ found.add(candidate);
27801
+ }
27802
+ } catch {
27803
+ }
27804
+ if (process.env.CLAUDE_CONFIG_DIR) {
27805
+ const candidate = path6.join(process.env.CLAUDE_CONFIG_DIR, "settings.json");
27806
+ if (fs6.existsSync(candidate))
27807
+ found.add(candidate);
27808
+ }
27809
+ return [...found].sort();
27810
+ }
27811
+ function tildePath(p) {
27812
+ return p.replace(os3.homedir(), "~");
27813
+ }
27814
+ async function promptMultiSelect(options, question, activeConfigDir) {
27815
+ if (!process.stdin.isTTY) {
27816
+ if (activeConfigDir) {
27817
+ const match = options.findIndex((p) => p.startsWith(activeConfigDir));
27818
+ return match >= 0 ? [match] : [0];
27819
+ }
27820
+ return options.map((_, i) => i);
27821
+ }
27822
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
27823
+ console.log("");
27824
+ console.log(`[cc-plan-viewer] Found ${options.length} Claude config directories:`);
27825
+ options.forEach((opt, i) => {
27826
+ const active = activeConfigDir && opt.startsWith(activeConfigDir) ? " (active)" : "";
27827
+ console.log(` ${i + 1}. ${tildePath(opt)}${active}`);
27828
+ });
27829
+ console.log("");
27830
+ return new Promise((resolve) => {
27831
+ rl.question(`${question} (comma-separated numbers, or "all"): `, (answer) => {
27832
+ rl.close();
27833
+ const trimmed = answer.trim().toLowerCase();
27834
+ if (trimmed === "all" || trimmed === "") {
27835
+ resolve(options.map((_, i) => i));
27836
+ return;
27837
+ }
27838
+ const indices = trimmed.split(",").map((s) => parseInt(s.trim(), 10) - 1).filter((i) => i >= 0 && i < options.length);
27839
+ resolve(indices);
27840
+ });
27841
+ });
27842
+ }
27843
+ function addHookToSettings(settingsPath) {
27844
+ const settings = readSettings(settingsPath);
27845
+ if (!settings.hooks || typeof settings.hooks !== "object") {
27846
+ settings.hooks = {};
27847
+ }
27848
+ const hooks = settings.hooks;
27849
+ if (!Array.isArray(hooks.PostToolUse)) {
27850
+ hooks.PostToolUse = [];
27851
+ }
27852
+ const hookCommand = getHookCommand();
27853
+ hooks.PostToolUse = hooks.PostToolUse.filter((entry) => {
27854
+ if (typeof entry !== "object" || entry === null)
27855
+ return true;
27856
+ const e = entry;
27857
+ if (!Array.isArray(e.hooks))
27858
+ return true;
27859
+ return !e.hooks.some((h) => {
27860
+ if (typeof h !== "object" || h === null)
27861
+ return false;
27862
+ const cmd = h.command;
27863
+ return typeof cmd === "string" && cmd.includes("plan-viewer-hook");
27864
+ });
27865
+ });
27866
+ hooks.PostToolUse.push({
27867
+ matcher: "Write|Edit",
27868
+ hooks: [{ type: "command", command: hookCommand }]
27869
+ });
27870
+ writeSettings(settingsPath, settings);
27871
+ }
27872
+ function removeHookFromSettings(settingsPath) {
27873
+ const settings = readSettings(settingsPath);
27874
+ const hooks = settings.hooks;
27875
+ if (!hooks?.PostToolUse || !Array.isArray(hooks.PostToolUse))
27876
+ return false;
27877
+ const before = hooks.PostToolUse.length;
27878
+ hooks.PostToolUse = hooks.PostToolUse.filter((entry) => {
27879
+ if (typeof entry !== "object" || entry === null)
27880
+ return true;
27881
+ const e = entry;
27882
+ if (!Array.isArray(e.hooks))
27883
+ return true;
27884
+ return !e.hooks.some((h) => {
27885
+ if (typeof h !== "object" || h === null)
27886
+ return false;
27887
+ const cmd = h.command;
27888
+ return typeof cmd === "string" && cmd.includes("plan-viewer-hook");
27889
+ });
27890
+ });
27891
+ if (hooks.PostToolUse.length < before) {
27892
+ writeSettings(settingsPath, settings);
27893
+ return true;
27894
+ }
27895
+ return false;
27896
+ }
27897
+ function settingsHasHook(settingsPath) {
27898
+ const settings = readSettings(settingsPath);
27899
+ const hooks = settings.hooks;
27900
+ if (!hooks?.PostToolUse || !Array.isArray(hooks.PostToolUse))
27901
+ return false;
27902
+ return hooks.PostToolUse.some((entry) => {
27903
+ if (typeof entry !== "object" || entry === null)
27904
+ return false;
27905
+ const e = entry;
27906
+ if (!Array.isArray(e.hooks))
27907
+ return false;
27908
+ return e.hooks.some((h) => {
27909
+ if (typeof h !== "object" || h === null)
27910
+ return false;
27911
+ const cmd = h.command;
27912
+ return typeof cmd === "string" && cmd.includes("plan-viewer-hook");
27913
+ });
27914
+ });
27915
+ }
27797
27916
  function copyDir(src, dest) {
27798
27917
  if (!fs6.existsSync(src))
27799
27918
  return;
@@ -27836,50 +27955,48 @@ function installFiles() {
27836
27955
  function getHookCommand() {
27837
27956
  return `node "${INSTALLED_HOOK}"`;
27838
27957
  }
27839
- function install() {
27840
- const settingsPath = resolveSettingsPath();
27958
+ async function install() {
27841
27959
  console.log("[cc-plan-viewer] Installing...");
27842
27960
  console.log(`[cc-plan-viewer] Install dir: ${INSTALL_DIR}`);
27843
- console.log(`[cc-plan-viewer] Settings: ${settingsPath}`);
27844
27961
  installFiles();
27845
27962
  console.log("[cc-plan-viewer] Files copied to ~/.cc-plan-viewer/");
27846
27963
  patchHookPaths();
27847
- const settings = readSettings(settingsPath);
27848
- if (!settings.hooks || typeof settings.hooks !== "object") {
27849
- settings.hooks = {};
27850
- }
27851
- const hooks = settings.hooks;
27852
- if (!Array.isArray(hooks.PostToolUse)) {
27853
- hooks.PostToolUse = [];
27964
+ const configIdx = process.argv.indexOf("--config");
27965
+ if (configIdx !== -1 && process.argv[configIdx + 1]) {
27966
+ const settingsPath = path6.resolve(process.argv[configIdx + 1]);
27967
+ if (!fs6.existsSync(path6.dirname(settingsPath))) {
27968
+ console.error(`[cc-plan-viewer] Directory does not exist: ${path6.dirname(settingsPath)}`);
27969
+ process.exit(1);
27970
+ }
27971
+ addHookToSettings(settingsPath);
27972
+ console.log(`[cc-plan-viewer] Hook added to ${tildePath(settingsPath)}`);
27973
+ } else {
27974
+ const allSettings = findAllClaudeSettings();
27975
+ if (allSettings.length === 0) {
27976
+ addHookToSettings(DEFAULT_SETTINGS);
27977
+ console.log(`[cc-plan-viewer] Hook added to ${tildePath(DEFAULT_SETTINGS)}`);
27978
+ } else if (allSettings.length === 1) {
27979
+ addHookToSettings(allSettings[0]);
27980
+ console.log(`[cc-plan-viewer] Hook added to ${tildePath(allSettings[0])}`);
27981
+ } else {
27982
+ const selected = await promptMultiSelect(allSettings, "Install hook in which configs?", process.env.CLAUDE_CONFIG_DIR);
27983
+ if (selected.length === 0) {
27984
+ console.log("[cc-plan-viewer] No configs selected. Hook not installed.");
27985
+ return;
27986
+ }
27987
+ for (const idx of selected) {
27988
+ addHookToSettings(allSettings[idx]);
27989
+ console.log(`[cc-plan-viewer] Hook added to ${tildePath(allSettings[idx])}`);
27990
+ }
27991
+ }
27854
27992
  }
27855
- const hookCommand = getHookCommand();
27856
- hooks.PostToolUse = hooks.PostToolUse.filter((entry) => {
27857
- if (typeof entry !== "object" || entry === null)
27858
- return true;
27859
- const e = entry;
27860
- if (!Array.isArray(e.hooks))
27861
- return true;
27862
- return !e.hooks.some((h) => {
27863
- if (typeof h !== "object" || h === null)
27864
- return false;
27865
- const cmd = h.command;
27866
- return typeof cmd === "string" && cmd.includes("plan-viewer-hook");
27867
- });
27868
- });
27869
- hooks.PostToolUse.push({
27870
- matcher: "Write|Edit",
27871
- hooks: [{ type: "command", command: hookCommand }]
27872
- });
27873
- writeSettings(settingsPath, settings);
27993
+ console.log("");
27874
27994
  console.log("[cc-plan-viewer] Hook installed successfully.");
27875
27995
  console.log("");
27876
27996
  console.log(" Next time Claude Code writes a plan, the viewer will open in your browser.");
27877
27997
  console.log("");
27878
27998
  console.log(" Update anytime: npx cc-plan-viewer@latest update");
27879
27999
  console.log(" Uninstall: npx cc-plan-viewer uninstall");
27880
- if (settingsPath !== DEFAULT_SETTINGS) {
27881
- console.log(` Custom config: ${settingsPath}`);
27882
- }
27883
28000
  }
27884
28001
  function patchHookPaths() {
27885
28002
  if (!fs6.existsSync(INSTALLED_HOOK))
@@ -27915,29 +28032,28 @@ function update() {
27915
28032
  }
27916
28033
  console.log("[cc-plan-viewer] Files updated in ~/.cc-plan-viewer/");
27917
28034
  }
27918
- function uninstall() {
27919
- const settingsPath = resolveSettingsPath();
28035
+ async function uninstall() {
27920
28036
  console.log("[cc-plan-viewer] Uninstalling...");
27921
- const settings = readSettings(settingsPath);
27922
- const hooks = settings.hooks;
27923
- if (hooks?.PostToolUse && Array.isArray(hooks.PostToolUse)) {
27924
- const before = hooks.PostToolUse.length;
27925
- hooks.PostToolUse = hooks.PostToolUse.filter((entry) => {
27926
- if (typeof entry !== "object" || entry === null)
27927
- return true;
27928
- const e = entry;
27929
- if (!Array.isArray(e.hooks))
27930
- return true;
27931
- return !e.hooks.some((h) => {
27932
- if (typeof h !== "object" || h === null)
27933
- return false;
27934
- const cmd = h.command;
27935
- return typeof cmd === "string" && cmd.includes("plan-viewer-hook");
27936
- });
27937
- });
27938
- if (hooks.PostToolUse.length < before) {
27939
- writeSettings(settingsPath, settings);
27940
- console.log(`[cc-plan-viewer] Hook removed from ${settingsPath}`);
28037
+ const configIdx = process.argv.indexOf("--config");
28038
+ if (configIdx !== -1 && process.argv[configIdx + 1]) {
28039
+ const settingsPath = path6.resolve(process.argv[configIdx + 1]);
28040
+ if (removeHookFromSettings(settingsPath)) {
28041
+ console.log(`[cc-plan-viewer] Hook removed from ${tildePath(settingsPath)}`);
28042
+ }
28043
+ } else {
28044
+ const allSettings = findAllClaudeSettings();
28045
+ const withHook = allSettings.filter(settingsHasHook);
28046
+ if (withHook.length === 0) {
28047
+ console.log("[cc-plan-viewer] No hooks found in any Claude config.");
28048
+ } else if (withHook.length === 1) {
28049
+ removeHookFromSettings(withHook[0]);
28050
+ console.log(`[cc-plan-viewer] Hook removed from ${tildePath(withHook[0])}`);
28051
+ } else {
28052
+ const selected = await promptMultiSelect(withHook, "Remove hook from which configs?");
28053
+ for (const idx of selected) {
28054
+ removeHookFromSettings(withHook[idx]);
28055
+ console.log(`[cc-plan-viewer] Hook removed from ${tildePath(withHook[idx])}`);
28056
+ }
27941
28057
  }
27942
28058
  }
27943
28059
  if (fs6.existsSync(INSTALL_DIR)) {
@@ -27977,13 +28093,13 @@ function version() {
27977
28093
  var command = process.argv[2];
27978
28094
  switch (command) {
27979
28095
  case "install":
27980
- install();
28096
+ await install();
27981
28097
  break;
27982
28098
  case "update":
27983
28099
  update();
27984
28100
  break;
27985
28101
  case "uninstall":
27986
- uninstall();
28102
+ await uninstall();
27987
28103
  break;
27988
28104
  case "start":
27989
28105
  start();
@@ -27999,11 +28115,14 @@ switch (command) {
27999
28115
  cc-plan-viewer \u2014 Browser-based review UI for Claude Code plans
28000
28116
 
28001
28117
  Usage:
28002
- npx cc-plan-viewer install Install hook + viewer files
28003
- npx cc-plan-viewer install --config <path> Use custom settings.json path
28004
- npx cc-plan-viewer@latest update Update to latest version
28005
- npx cc-plan-viewer uninstall Remove hook + viewer files
28006
- npx cc-plan-viewer version Show installed version
28118
+ npx cc-plan-viewer install Install hook + viewer files
28119
+ npx cc-plan-viewer install --config <path> Use specific settings.json path
28120
+ npx cc-plan-viewer@latest update Update to latest version
28121
+ npx cc-plan-viewer uninstall Remove hook + viewer files
28122
+ npx cc-plan-viewer version Show installed version
28123
+
28124
+ When multiple Claude configs are detected (~/.claude*/settings.json),
28125
+ you'll be prompted to choose which ones to install the hook in.
28007
28126
 
28008
28127
  Files are installed to ~/.cc-plan-viewer/ so they persist across npm cache clears.
28009
28128
  `);
@@ -2,6 +2,7 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
5
+ import readline from 'node:readline';
5
6
  import { execSync } from 'node:child_process';
6
7
  // ─── Stable install location ───
7
8
  const INSTALL_DIR = path.join(os.homedir(), '.cc-plan-viewer');
@@ -23,19 +24,6 @@ function getPkgRoot() {
23
24
  }
24
25
  // ─── Settings file resolution ───
25
26
  const DEFAULT_SETTINGS = path.join(os.homedir(), '.claude', 'settings.json');
26
- function resolveSettingsPath() {
27
- // Check --config flag
28
- const configIdx = process.argv.indexOf('--config');
29
- if (configIdx !== -1 && process.argv[configIdx + 1]) {
30
- const custom = path.resolve(process.argv[configIdx + 1]);
31
- if (!fs.existsSync(path.dirname(custom))) {
32
- console.error(`[cc-plan-viewer] Directory does not exist: ${path.dirname(custom)}`);
33
- process.exit(1);
34
- }
35
- return custom;
36
- }
37
- return DEFAULT_SETTINGS;
38
- }
39
27
  function readSettings(settingsPath) {
40
28
  try {
41
29
  return JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
@@ -50,6 +38,139 @@ function writeSettings(settingsPath, settings) {
50
38
  fs.mkdirSync(dir, { recursive: true });
51
39
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
52
40
  }
41
+ // ─── Multi-config detection ───
42
+ function findAllClaudeSettings() {
43
+ const home = os.homedir();
44
+ const found = new Set();
45
+ // Scan ~/.claude*/settings.json
46
+ try {
47
+ for (const name of fs.readdirSync(home)) {
48
+ if (!name.startsWith('.claude'))
49
+ continue;
50
+ const candidate = path.join(home, name, 'settings.json');
51
+ if (fs.existsSync(candidate))
52
+ found.add(candidate);
53
+ }
54
+ }
55
+ catch { }
56
+ // Include CLAUDE_CONFIG_DIR if set (may point outside ~/.claude*)
57
+ if (process.env.CLAUDE_CONFIG_DIR) {
58
+ const candidate = path.join(process.env.CLAUDE_CONFIG_DIR, 'settings.json');
59
+ if (fs.existsSync(candidate))
60
+ found.add(candidate);
61
+ }
62
+ return [...found].sort();
63
+ }
64
+ function tildePath(p) {
65
+ return p.replace(os.homedir(), '~');
66
+ }
67
+ async function promptMultiSelect(options, question, activeConfigDir) {
68
+ // Non-interactive: fall back to CLAUDE_CONFIG_DIR or all
69
+ if (!process.stdin.isTTY) {
70
+ if (activeConfigDir) {
71
+ const match = options.findIndex(p => p.startsWith(activeConfigDir));
72
+ return match >= 0 ? [match] : [0];
73
+ }
74
+ return options.map((_, i) => i);
75
+ }
76
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
77
+ console.log('');
78
+ console.log(`[cc-plan-viewer] Found ${options.length} Claude config directories:`);
79
+ options.forEach((opt, i) => {
80
+ const active = activeConfigDir && opt.startsWith(activeConfigDir) ? ' (active)' : '';
81
+ console.log(` ${i + 1}. ${tildePath(opt)}${active}`);
82
+ });
83
+ console.log('');
84
+ return new Promise((resolve) => {
85
+ rl.question(`${question} (comma-separated numbers, or "all"): `, (answer) => {
86
+ rl.close();
87
+ const trimmed = answer.trim().toLowerCase();
88
+ if (trimmed === 'all' || trimmed === '') {
89
+ resolve(options.map((_, i) => i));
90
+ return;
91
+ }
92
+ const indices = trimmed.split(',')
93
+ .map(s => parseInt(s.trim(), 10) - 1)
94
+ .filter(i => i >= 0 && i < options.length);
95
+ resolve(indices);
96
+ });
97
+ });
98
+ }
99
+ function addHookToSettings(settingsPath) {
100
+ const settings = readSettings(settingsPath);
101
+ if (!settings.hooks || typeof settings.hooks !== 'object') {
102
+ settings.hooks = {};
103
+ }
104
+ const hooks = settings.hooks;
105
+ if (!Array.isArray(hooks.PostToolUse)) {
106
+ hooks.PostToolUse = [];
107
+ }
108
+ const hookCommand = getHookCommand();
109
+ // Remove any existing cc-plan-viewer hooks first (handles upgrades)
110
+ hooks.PostToolUse = hooks.PostToolUse.filter((entry) => {
111
+ if (typeof entry !== 'object' || entry === null)
112
+ return true;
113
+ const e = entry;
114
+ if (!Array.isArray(e.hooks))
115
+ return true;
116
+ return !e.hooks.some((h) => {
117
+ if (typeof h !== 'object' || h === null)
118
+ return false;
119
+ const cmd = h.command;
120
+ return typeof cmd === 'string' && cmd.includes('plan-viewer-hook');
121
+ });
122
+ });
123
+ // Add fresh hook entry
124
+ hooks.PostToolUse.push({
125
+ matcher: 'Write|Edit',
126
+ hooks: [{ type: 'command', command: hookCommand }],
127
+ });
128
+ writeSettings(settingsPath, settings);
129
+ }
130
+ function removeHookFromSettings(settingsPath) {
131
+ const settings = readSettings(settingsPath);
132
+ const hooks = settings.hooks;
133
+ if (!hooks?.PostToolUse || !Array.isArray(hooks.PostToolUse))
134
+ return false;
135
+ const before = hooks.PostToolUse.length;
136
+ hooks.PostToolUse = hooks.PostToolUse.filter((entry) => {
137
+ if (typeof entry !== 'object' || entry === null)
138
+ return true;
139
+ const e = entry;
140
+ if (!Array.isArray(e.hooks))
141
+ return true;
142
+ return !e.hooks.some((h) => {
143
+ if (typeof h !== 'object' || h === null)
144
+ return false;
145
+ const cmd = h.command;
146
+ return typeof cmd === 'string' && cmd.includes('plan-viewer-hook');
147
+ });
148
+ });
149
+ if (hooks.PostToolUse.length < before) {
150
+ writeSettings(settingsPath, settings);
151
+ return true;
152
+ }
153
+ return false;
154
+ }
155
+ function settingsHasHook(settingsPath) {
156
+ const settings = readSettings(settingsPath);
157
+ const hooks = settings.hooks;
158
+ if (!hooks?.PostToolUse || !Array.isArray(hooks.PostToolUse))
159
+ return false;
160
+ return hooks.PostToolUse.some((entry) => {
161
+ if (typeof entry !== 'object' || entry === null)
162
+ return false;
163
+ const e = entry;
164
+ if (!Array.isArray(e.hooks))
165
+ return false;
166
+ return e.hooks.some((h) => {
167
+ if (typeof h !== 'object' || h === null)
168
+ return false;
169
+ const cmd = h.command;
170
+ return typeof cmd === 'string' && cmd.includes('plan-viewer-hook');
171
+ });
172
+ });
173
+ }
53
174
  // ─── Copy files to stable location ───
54
175
  function copyDir(src, dest) {
55
176
  if (!fs.existsSync(src))
@@ -100,60 +221,57 @@ function installFiles() {
100
221
  function getHookCommand() {
101
222
  return `node "${INSTALLED_HOOK}"`;
102
223
  }
103
- function install() {
104
- const settingsPath = resolveSettingsPath();
224
+ async function install() {
105
225
  console.log('[cc-plan-viewer] Installing...');
106
226
  console.log(`[cc-plan-viewer] Install dir: ${INSTALL_DIR}`);
107
- console.log(`[cc-plan-viewer] Settings: ${settingsPath}`);
108
227
  // Copy files to stable location
109
228
  installFiles();
110
229
  console.log('[cc-plan-viewer] Files copied to ~/.cc-plan-viewer/');
111
- // Patch the installed hook to know where the server is
112
- // The hook uses __dirname to find the server — now it's in ~/.cc-plan-viewer/
113
- // and the server is at ~/.cc-plan-viewer/server/server/index.js
114
- // Hook already resolves path.join(__dirname, '..', 'dist', 'server', 'server', 'index.js')
115
- // but now it should look at path.join(__dirname, '..', 'server', 'server', 'index.js')
116
- // Let's patch the hook to check both locations
117
230
  patchHookPaths();
118
- // Add hook to settings
119
- const settings = readSettings(settingsPath);
120
- if (!settings.hooks || typeof settings.hooks !== 'object') {
121
- settings.hooks = {};
231
+ // Determine which settings files to update
232
+ const configIdx = process.argv.indexOf('--config');
233
+ if (configIdx !== -1 && process.argv[configIdx + 1]) {
234
+ // Explicit --config flag: use exactly that path
235
+ const settingsPath = path.resolve(process.argv[configIdx + 1]);
236
+ if (!fs.existsSync(path.dirname(settingsPath))) {
237
+ console.error(`[cc-plan-viewer] Directory does not exist: ${path.dirname(settingsPath)}`);
238
+ process.exit(1);
239
+ }
240
+ addHookToSettings(settingsPath);
241
+ console.log(`[cc-plan-viewer] Hook added to ${tildePath(settingsPath)}`);
122
242
  }
123
- const hooks = settings.hooks;
124
- if (!Array.isArray(hooks.PostToolUse)) {
125
- hooks.PostToolUse = [];
243
+ else {
244
+ const allSettings = findAllClaudeSettings();
245
+ if (allSettings.length === 0) {
246
+ // No existing configs — create default
247
+ addHookToSettings(DEFAULT_SETTINGS);
248
+ console.log(`[cc-plan-viewer] Hook added to ${tildePath(DEFAULT_SETTINGS)}`);
249
+ }
250
+ else if (allSettings.length === 1) {
251
+ // Single config — use it directly
252
+ addHookToSettings(allSettings[0]);
253
+ console.log(`[cc-plan-viewer] Hook added to ${tildePath(allSettings[0])}`);
254
+ }
255
+ else {
256
+ // Multiple configs — prompt user
257
+ const selected = await promptMultiSelect(allSettings, 'Install hook in which configs?', process.env.CLAUDE_CONFIG_DIR);
258
+ if (selected.length === 0) {
259
+ console.log('[cc-plan-viewer] No configs selected. Hook not installed.');
260
+ return;
261
+ }
262
+ for (const idx of selected) {
263
+ addHookToSettings(allSettings[idx]);
264
+ console.log(`[cc-plan-viewer] Hook added to ${tildePath(allSettings[idx])}`);
265
+ }
266
+ }
126
267
  }
127
- const hookCommand = getHookCommand();
128
- // Remove any existing cc-plan-viewer hooks first (handles upgrades)
129
- hooks.PostToolUse = hooks.PostToolUse.filter((entry) => {
130
- if (typeof entry !== 'object' || entry === null)
131
- return true;
132
- const e = entry;
133
- if (!Array.isArray(e.hooks))
134
- return true;
135
- return !e.hooks.some((h) => {
136
- if (typeof h !== 'object' || h === null)
137
- return false;
138
- const cmd = h.command;
139
- return typeof cmd === 'string' && cmd.includes('plan-viewer-hook');
140
- });
141
- });
142
- // Add fresh hook entry
143
- hooks.PostToolUse.push({
144
- matcher: 'Write|Edit',
145
- hooks: [{ type: 'command', command: hookCommand }],
146
- });
147
- writeSettings(settingsPath, settings);
268
+ console.log('');
148
269
  console.log('[cc-plan-viewer] Hook installed successfully.');
149
270
  console.log('');
150
271
  console.log(' Next time Claude Code writes a plan, the viewer will open in your browser.');
151
272
  console.log('');
152
273
  console.log(' Update anytime: npx cc-plan-viewer@latest update');
153
274
  console.log(' Uninstall: npx cc-plan-viewer uninstall');
154
- if (settingsPath !== DEFAULT_SETTINGS) {
155
- console.log(` Custom config: ${settingsPath}`);
156
- }
157
275
  }
158
276
  function patchHookPaths() {
159
277
  // The installed hook is at ~/.cc-plan-viewer/plan-viewer-hook.cjs
@@ -197,30 +315,32 @@ function update() {
197
315
  }
198
316
  console.log('[cc-plan-viewer] Files updated in ~/.cc-plan-viewer/');
199
317
  }
200
- function uninstall() {
201
- const settingsPath = resolveSettingsPath();
318
+ async function uninstall() {
202
319
  console.log('[cc-plan-viewer] Uninstalling...');
203
- // Remove hook from settings
204
- const settings = readSettings(settingsPath);
205
- const hooks = settings.hooks;
206
- if (hooks?.PostToolUse && Array.isArray(hooks.PostToolUse)) {
207
- const before = hooks.PostToolUse.length;
208
- hooks.PostToolUse = hooks.PostToolUse.filter((entry) => {
209
- if (typeof entry !== 'object' || entry === null)
210
- return true;
211
- const e = entry;
212
- if (!Array.isArray(e.hooks))
213
- return true;
214
- return !e.hooks.some((h) => {
215
- if (typeof h !== 'object' || h === null)
216
- return false;
217
- const cmd = h.command;
218
- return typeof cmd === 'string' && cmd.includes('plan-viewer-hook');
219
- });
220
- });
221
- if (hooks.PostToolUse.length < before) {
222
- writeSettings(settingsPath, settings);
223
- console.log(`[cc-plan-viewer] Hook removed from ${settingsPath}`);
320
+ // Determine which settings files to clean up
321
+ const configIdx = process.argv.indexOf('--config');
322
+ if (configIdx !== -1 && process.argv[configIdx + 1]) {
323
+ const settingsPath = path.resolve(process.argv[configIdx + 1]);
324
+ if (removeHookFromSettings(settingsPath)) {
325
+ console.log(`[cc-plan-viewer] Hook removed from ${tildePath(settingsPath)}`);
326
+ }
327
+ }
328
+ else {
329
+ const allSettings = findAllClaudeSettings();
330
+ const withHook = allSettings.filter(settingsHasHook);
331
+ if (withHook.length === 0) {
332
+ console.log('[cc-plan-viewer] No hooks found in any Claude config.');
333
+ }
334
+ else if (withHook.length === 1) {
335
+ removeHookFromSettings(withHook[0]);
336
+ console.log(`[cc-plan-viewer] Hook removed from ${tildePath(withHook[0])}`);
337
+ }
338
+ else {
339
+ const selected = await promptMultiSelect(withHook, 'Remove hook from which configs?');
340
+ for (const idx of selected) {
341
+ removeHookFromSettings(withHook[idx]);
342
+ console.log(`[cc-plan-viewer] Hook removed from ${tildePath(withHook[idx])}`);
343
+ }
224
344
  }
225
345
  }
226
346
  // Remove installed files
@@ -266,13 +386,13 @@ function version() {
266
386
  const command = process.argv[2];
267
387
  switch (command) {
268
388
  case 'install':
269
- install();
389
+ await install();
270
390
  break;
271
391
  case 'update':
272
392
  update();
273
393
  break;
274
394
  case 'uninstall':
275
- uninstall();
395
+ await uninstall();
276
396
  break;
277
397
  case 'start':
278
398
  start();
@@ -288,11 +408,14 @@ switch (command) {
288
408
  cc-plan-viewer — Browser-based review UI for Claude Code plans
289
409
 
290
410
  Usage:
291
- npx cc-plan-viewer install Install hook + viewer files
292
- npx cc-plan-viewer install --config <path> Use custom settings.json path
293
- npx cc-plan-viewer@latest update Update to latest version
294
- npx cc-plan-viewer uninstall Remove hook + viewer files
295
- npx cc-plan-viewer version Show installed version
411
+ npx cc-plan-viewer install Install hook + viewer files
412
+ npx cc-plan-viewer install --config <path> Use specific settings.json path
413
+ npx cc-plan-viewer@latest update Update to latest version
414
+ npx cc-plan-viewer uninstall Remove hook + viewer files
415
+ npx cc-plan-viewer version Show installed version
416
+
417
+ When multiple Claude configs are detected (~/.claude*/settings.json),
418
+ you'll be prompted to choose which ones to install the hook in.
296
419
 
297
420
  Files are installed to ~/.cc-plan-viewer/ so they persist across npm cache clears.
298
421
  `);
@@ -6,7 +6,8 @@ import path from 'node:path';
6
6
  import { parsePlan } from './planParser.js';
7
7
  import { saveReview, getReview } from './reviewStore.js';
8
8
  import { resetIdleTimer } from './lifecycle.js';
9
- export function createApp(plansDirs) {
9
+ export function createApp(initialPlansDirs) {
10
+ const plansDirs = [...initialPlansDirs];
10
11
  const plansDir = plansDirs[0] || '';
11
12
  const app = express();
12
13
  const server = createServer(app);
@@ -86,6 +87,11 @@ export function createApp(plansDirs) {
86
87
  app.post('/api/plan-updated', (req, res) => {
87
88
  const { filePath, planOptions } = req.body;
88
89
  const filename = path.basename(filePath || '');
90
+ // Dynamically register new plan directories (e.g., project-level .claude/plans/)
91
+ const dir = path.dirname(filePath || '');
92
+ if (dir && !plansDirs.includes(dir) && fs.existsSync(dir)) {
93
+ plansDirs.push(dir);
94
+ }
89
95
  // Broadcast to all WebSocket clients
90
96
  const message = JSON.stringify({
91
97
  type: 'plan-updated',
@@ -27481,7 +27481,8 @@ function resetIdleTimer() {
27481
27481
  }
27482
27482
 
27483
27483
  // dist/server/server/app.js
27484
- function createApp(plansDirs2) {
27484
+ function createApp(initialPlansDirs) {
27485
+ const plansDirs2 = [...initialPlansDirs];
27485
27486
  const plansDir = plansDirs2[0] || "";
27486
27487
  const app = (0, import_express.default)();
27487
27488
  const server2 = createServer(app);
@@ -27552,6 +27553,10 @@ function createApp(plansDirs2) {
27552
27553
  app.post("/api/plan-updated", (req, res) => {
27553
27554
  const { filePath, planOptions } = req.body;
27554
27555
  const filename = path3.basename(filePath || "");
27556
+ const dir = path3.dirname(filePath || "");
27557
+ if (dir && !plansDirs2.includes(dir) && fs3.existsSync(dir)) {
27558
+ plansDirs2.push(dir);
27559
+ }
27555
27560
  const message = JSON.stringify({
27556
27561
  type: "plan-updated",
27557
27562
  filename,
@@ -14,17 +14,21 @@ const PORT_FILE = path.join(os.tmpdir(), 'cc-plan-viewer-port');
14
14
  const DEBOUNCE_FILE = path.join(os.tmpdir(), 'cc-plan-viewer-opened.json');
15
15
  const DEBOUNCE_MS = 5000; // 5 seconds (prevents rapid re-opens during multi-chunk writes)
16
16
 
17
- // Dynamically find all ~/.claude*/plans/ directories
17
+ // Dynamically find all plans directories (home-level + project-level)
18
18
  function getAllPlansDirs() {
19
+ const dirs = [];
19
20
  const home = os.homedir();
20
21
  try {
21
- return fs.readdirSync(home)
22
+ fs.readdirSync(home)
22
23
  .filter(name => name.startsWith('.claude'))
23
24
  .map(name => path.join(home, name, 'plans'))
24
- .filter(dir => fs.existsSync(dir));
25
- } catch {
26
- return [];
27
- }
25
+ .filter(dir => fs.existsSync(dir))
26
+ .forEach(dir => dirs.push(dir));
27
+ } catch {}
28
+ // Project-level: {cwd}/.claude/plans/
29
+ const projectDir = path.join(process.cwd(), '.claude', 'plans');
30
+ if (fs.existsSync(projectDir)) dirs.push(projectDir);
31
+ return dirs;
28
32
  }
29
33
 
30
34
  function getServerPort() {
@@ -37,10 +41,12 @@ function getServerPort() {
37
41
 
38
42
  function isPlanFile(filePath) {
39
43
  if (!filePath || !filePath.endsWith('.md')) return false;
40
- // Match any ~/.claude*/plans/*.md
44
+ // Home-level: ~/.claude*/plans/*.md
41
45
  const home = os.homedir();
42
46
  const rel = path.relative(home, filePath);
43
- return /^\.claude[^/]*\/plans\/[^/]+\.md$/.test(rel);
47
+ if (/^\.claude[^/]*\/plans\/[^/]+\.md$/.test(rel)) return true;
48
+ // Project-level: */.claude/plans/*.md
49
+ return /\/\.claude\/plans\/[^/]+\.md$/.test(filePath);
44
50
  }
45
51
 
46
52
  function shouldOpenBrowser(filename) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-plan-viewer",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Browser-based PR-style review UI for Claude Code plans",
5
5
  "type": "module",
6
6
  "bin": {