ccperm 1.13.0 → 1.14.1

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.ko.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  Claude Code는 프로젝트마다 `.claude/settings*.json`에 허용한 권한(Bash 명령, WebFetch 도메인, MCP 도구 등)을 저장합니다. 여러 프로젝트를 오가다 보면 어디서 뭘 허용했는지 파악하기 어려운데, **ccperm**은 홈 디렉토리 전체를 스캔해서 모든 설정 파일을 찾고, 인터랙티브 TUI 또는 텍스트로 보여줍니다.
8
8
 
9
- <img src="./screenshot.png" width="600" />
9
+ <img src="./demo.gif" width="600" />
10
10
 
11
11
  ## 빠른 시작
12
12
 
@@ -41,25 +41,29 @@ ccperm
41
41
 
42
42
  TTY 환경(기본)에서는 박스 프레임 TUI가 실행됩니다:
43
43
 
44
- **목록 뷰** — 프로젝트가 권한 수 기준으로 정렬됩니다. 상단에 `~/.claude` 섹션이 구분선과 함께 표시됩니다. 각 행은 카테고리별 개수(Bash, WebFetch, MCP, Tools) `shared`/`local` 라벨로 `settings.json`과 `settings.local.json`을 구분합니다.
44
+ **목록 뷰** — 프로젝트가 권한 수 기준으로 정렬됩니다. 컬럼: Bash, MCP, Tools, TOTAL, `!` (위험도), `†` (deprecated `:*`), `G` (글로벌과 중복).
45
45
 
46
46
  ```
47
47
  ┌ ccperm ──────────────────────────────── 1/8 ┐
48
- PROJECT Bash WebFetch MCP TOTAL │
49
- ├──────────────────────────────────────────────┤
50
- ~/.claude 2 2
51
- ├──────────────────────────────────────────────┤
52
- │▸ my-project local 5 3 · 8
53
- other-app shared 2 · 3 5
54
- ...
55
- [↑↓] navigate [Enter] detail [q] quit ────┘
48
+ PROJECT Bash MCP Tools TOTAL G
49
+ ├─────────────────────────────────────────────┤
50
+ ~/.claude 15 · 2 17 ·│
51
+ ├─────────────────────────────────────────────┤
52
+ my-project local 5 · 3 8 3
53
+ other-app shared 2 3 · 5 ·
54
+ ! risk † deprecated G in global
55
+ └──────── [↑↓] navigate [Enter] detail [/] search [q] quit
56
56
  ```
57
57
 
58
58
  **상세 뷰** — Enter로 프로젝트를 펼칩니다. 카테고리를 Enter로 접고 펼 수 있습니다.
59
+ - `[d]` 권한 삭제
60
+ - `[g]` 글로벌에 추가 (즉시 `(in global)` 태그 반영)
61
+ - `[i]` 위험도 정보 토글
62
+ - 글로벌에 이미 있는 권한은 노란색 `(in global)` 태그 표시
59
63
 
60
- **정보 모드**`[i]`를 누르면 권한에 대한 설명이 나타납니다.
64
+ **검색 뷰**`/`로 전체 프로젝트의 권한을 검색합니다. 권한명 기준 매칭, 프로젝트별로 그룹 표시. 방향키로 탐색 (헤더 자동 스킵), Enter로 해당 프로젝트 상세 뷰로 이동.
61
65
 
62
- 키 조작: `↑↓` 이동, `Enter` 선택/펼치기, `[i]` 정보 토글, `Esc`/`Backspace` 뒤로, `q`/`Ctrl+C` 종료.
66
+ 키 조작: `↑↓` 이동, `Enter` 선택/펼치기, `[i]` 정보, `[d]` 삭제, `[g]` +global, `/` 검색, `Esc` 뒤로, `q` 종료.
63
67
 
64
68
  ## 텍스트 출력
65
69
 
package/README.md CHANGED
@@ -6,7 +6,7 @@ Audit Claude Code permissions across all your projects.
6
6
 
7
7
  Claude Code stores allowed permissions (Bash commands, WebFetch domains, MCP tools, etc.) in `.claude/settings*.json` per project. As you work across many projects, these permissions pile up silently. **ccperm** scans your home directory, finds every settings file, and shows what you've allowed — in an interactive TUI or static text output.
8
8
 
9
- <img src="./screenshot.png" width="600" />
9
+ <img src="./demo.gif" width="600" />
10
10
 
11
11
  ## Quick Start
12
12
 
@@ -41,25 +41,29 @@ By default, ccperm scans all projects under `~` and launches an interactive TUI.
41
41
 
42
42
  When running in a TTY (the default), ccperm opens a box-frame TUI:
43
43
 
44
- **List view** — Projects sorted by permission count. `~/.claude` section at top with a separator. Each row shows category counts (Bash, WebFetch, MCP, Tools) and a `shared`/`local` label distinguishing `settings.json` vs `settings.local.json`.
44
+ **List view** — Projects sorted by permission count. Columns: Bash, MCP, Tools, TOTAL, `!` (risk), `†` (deprecated `:*`), `G` (redundant with global).
45
45
 
46
46
  ```
47
47
  ┌ ccperm ──────────────────────────────── 1/8 ┐
48
- PROJECT Bash WebFetch MCP TOTAL │
49
- ├──────────────────────────────────────────────┤
50
- ~/.claude 2 2
51
- ├──────────────────────────────────────────────┤
52
- │▸ my-project local 5 3 · 8
53
- other-app shared 2 · 3 5
54
- ...
55
- [↑↓] navigate [Enter] detail [q] quit ────┘
48
+ PROJECT Bash MCP Tools TOTAL G
49
+ ├─────────────────────────────────────────────┤
50
+ ~/.claude 15 · 2 17 ·│
51
+ ├─────────────────────────────────────────────┤
52
+ my-project local 5 · 3 8 3
53
+ other-app shared 2 3 · 5 ·
54
+ ! risk † deprecated G in global
55
+ └──────── [↑↓] navigate [Enter] detail [/] search [q] quit
56
56
  ```
57
57
 
58
58
  **Detail view** — Press Enter to expand a project. Categories are collapsible; press Enter to toggle.
59
+ - `[d]` delete a permission
60
+ - `[g]` add permission to global settings (shows `(in global)` tag immediately)
61
+ - `[i]` toggle risk info mode
62
+ - Permissions already in global are tagged `(in global)` in yellow
59
63
 
60
- **Info mode** — Press `[i]` to show descriptions for each permission.
64
+ **Search view** — Press `/` to search across all projects. Matches permission names and shows results grouped by project. Arrow keys navigate (headers are skipped), Enter jumps to the project's detail view.
61
65
 
62
- Keys: `↑↓` navigate, `Enter` select/expand, `[i]` toggle info, `Esc`/`Backspace` back, `q`/`Ctrl+C` quit.
66
+ Keys: `↑↓` navigate, `Enter` select/expand, `[i]` info, `[d]` delete, `[g]` +global, `/` search, `Esc` back, `q` quit.
63
67
 
64
68
  ## Static Output
65
69
 
package/demo.gif ADDED
Binary file
package/dist/explain.js CHANGED
@@ -58,6 +58,13 @@ const BASH_COMMANDS = {
58
58
  // networking
59
59
  'curl': ['HTTP requests', 'medium', 'networking'],
60
60
  'wget': ['Download files', 'medium', 'networking'],
61
+ 'ss': ['Socket statistics', 'low', 'networking'],
62
+ 'netstat': ['Network statistics', 'low', 'networking'],
63
+ 'ip': ['Network config', 'low', 'networking'],
64
+ 'ping': ['Network connectivity test', 'low', 'networking'],
65
+ 'dig': ['DNS lookup', 'low', 'networking'],
66
+ 'nslookup': ['DNS lookup', 'low', 'networking'],
67
+ 'traceroute': ['Trace network route', 'low', 'networking'],
61
68
  'tailscale': ['Tailscale VPN', 'medium', 'networking'],
62
69
  'cloudflared': ['Cloudflare tunnel', 'medium', 'networking'],
63
70
  // databases
@@ -42,9 +42,16 @@ function boxLine(text, width) {
42
42
  }
43
43
  function boxTop(title, info, width) {
44
44
  const inner = width - 2;
45
- const titlePart = ` ${title} `;
46
45
  const infoPart = info ? ` ${info} ` : '';
47
- const fill = Math.max(0, inner - titlePart.length - infoPart.length);
46
+ const maxTitle = inner - infoPart.length - 2; // 2 for spaces around title
47
+ let truncTitle = title;
48
+ if (visLen(truncTitle) > maxTitle) {
49
+ // strip ANSI, truncate, re-wrap won't work cleanly — truncate the raw string
50
+ const plain = truncTitle.replace(/\x1b\[[0-9;]*m/g, '');
51
+ truncTitle = plain.slice(0, maxTitle - 1) + '…';
52
+ }
53
+ const titlePart = ` ${truncTitle} `;
54
+ const fill = Math.max(0, inner - visLen(titlePart) - infoPart.length);
48
55
  return `${colors_js_1.DIM}┌${titlePart}${'─'.repeat(fill)}${infoPart}┐${colors_js_1.NC}`;
49
56
  }
50
57
  function boxBottom(hint, width) {
@@ -53,9 +60,44 @@ function boxBottom(hint, width) {
53
60
  const fill = Math.max(0, inner - visLen(hintPart));
54
61
  return `${colors_js_1.DIM}└${'─'.repeat(fill)}${hintPart}┘${colors_js_1.NC}`;
55
62
  }
63
+ function boxBottom2(line1, line2, width) {
64
+ return boxLine(line1, width) + '\n' + boxBottom(line2, width);
65
+ }
56
66
  function boxSep(width) {
57
67
  return `${colors_js_1.DIM}├${'─'.repeat(width - 2)}┤${colors_js_1.NC}`;
58
68
  }
69
+ function refreshProject(results, withPerms, idx, filePath) {
70
+ const ri = results.findIndex((r) => r.path === filePath);
71
+ if (ri >= 0) {
72
+ const updated = (0, scanner_js_1.scanFile)(filePath);
73
+ if (updated) {
74
+ results[ri] = updated;
75
+ const entry = withPerms[idx];
76
+ entry.totalCount = updated.totalCount;
77
+ entry.groups = new Map();
78
+ for (const g of updated.groups)
79
+ entry.groups.set(g.category, g.items.length);
80
+ }
81
+ }
82
+ }
83
+ function buildDupMap(results) {
84
+ const globalResult = results.find((r) => r.isGlobal);
85
+ const globalPerms = globalResult ? globalResult.permissions : [];
86
+ const map = new Map();
87
+ for (const r of results) {
88
+ if (r.isGlobal)
89
+ continue;
90
+ const dup = (0, scanner_js_1.findDuplicates)(r.path, globalPerms);
91
+ const total = dup.exact.length + dup.globalDup.length;
92
+ if (total > 0)
93
+ map.set(r.display, { exact: dup.exact.length, global: dup.globalDup.length });
94
+ }
95
+ return map;
96
+ }
97
+ function getGlobalPerms(results) {
98
+ const globalResult = results.find((r) => r.isGlobal);
99
+ return globalResult ? globalResult.permissions : [];
100
+ }
59
101
  function startInteractive(merged, results) {
60
102
  return new Promise((resolve) => {
61
103
  const globals = merged.filter((r) => r.isGlobal);
@@ -64,12 +106,13 @@ function startInteractive(merged, results) {
64
106
  const emptyCount = merged.filter((r) => r.totalCount === 0 && !r.isGlobal).length;
65
107
  const riskMap = buildRiskMap(results);
66
108
  const depMap = buildDeprecatedMap(results);
109
+ let dupMap = buildDupMap(results);
67
110
  if (withPerms.length === 0) {
68
111
  console.log(`\n ${colors_js_1.GREEN}No projects with permissions found.${colors_js_1.NC}\n`);
69
112
  resolve();
70
113
  return;
71
114
  }
72
- const state = { view: 'list', cursor: 0, scrollOffset: 0, selectedProject: 0, detailCursor: 0, detailScroll: 0, expanded: new Set(), showInfo: false };
115
+ const state = { view: 'list', cursor: 0, scrollOffset: 0, selectedProject: 0, detailCursor: 0, detailScroll: 0, expanded: new Set(), showInfo: false, searchActive: false, searchQuery: '' };
73
116
  node_readline_1.default.emitKeypressEvents(process.stdin);
74
117
  if (process.stdin.isTTY)
75
118
  process.stdin.setRawMode(true);
@@ -85,21 +128,83 @@ function startInteractive(merged, results) {
85
128
  const onSigint = () => { cleanup(); process.exit(0); };
86
129
  process.on('SIGINT', onSigint);
87
130
  const render = () => {
88
- process.stdout.write('\x1b[2J\x1b[H\x1b[?25l');
89
- if (state.view === 'list')
90
- renderList(state, withPerms, emptyCount, riskMap, depMap);
131
+ process.stdout.write('\x1b[2J\x1b[H');
132
+ process.stdout.write(state.searchActive ? '\x1b[?25h' : '\x1b[?25l');
133
+ if (state.view === 'search')
134
+ renderSearch(state, withPerms, results);
135
+ else if (state.view === 'list')
136
+ renderList(state, withPerms, emptyCount, riskMap, depMap, dupMap);
91
137
  else
92
- renderDetail(state, withPerms, results);
138
+ renderDetail(state, withPerms, results, dupMap);
93
139
  };
94
140
  const onKey = (_str, key) => {
95
141
  if (!key)
96
142
  return;
143
+ // Search typing mode (active in search view)
144
+ if (state.searchActive) {
145
+ if (key.name === 'escape') {
146
+ state.searchActive = false;
147
+ state.searchQuery = '';
148
+ state.view = 'list';
149
+ state.cursor = 0;
150
+ state.scrollOffset = 0;
151
+ }
152
+ else if (key.name === 'return') {
153
+ // Navigate to selected hit's project
154
+ const hits = buildSearchHits(state.searchQuery, withPerms, results);
155
+ const hit = hits[state.cursor];
156
+ if (hit && !hit.isHeader) {
157
+ state.searchActive = false;
158
+ state.selectedProject = hit.projectIdx;
159
+ state.detailCursor = 0;
160
+ state.detailScroll = 0;
161
+ state.expanded = new Set();
162
+ state.searchQuery = '';
163
+ state.view = 'detail';
164
+ }
165
+ }
166
+ else if (key.name === 'backspace') {
167
+ state.searchQuery = state.searchQuery.slice(0, -1);
168
+ state.cursor = 0;
169
+ state.scrollOffset = 0;
170
+ }
171
+ else if (key.name === 'up' || key.name === 'down') {
172
+ const hits = buildSearchHits(state.searchQuery, withPerms, results);
173
+ if (key.name === 'up') {
174
+ do {
175
+ state.cursor = Math.max(0, state.cursor - 1);
176
+ } while (state.cursor > 0 && hits[state.cursor]?.isHeader);
177
+ }
178
+ else {
179
+ do {
180
+ state.cursor = Math.min(hits.length - 1, state.cursor + 1);
181
+ } while (state.cursor < hits.length - 1 && hits[state.cursor]?.isHeader);
182
+ }
183
+ }
184
+ else if (_str && _str.length === 1 && !key.ctrl && !key.meta) {
185
+ state.searchQuery += _str;
186
+ state.cursor = 0;
187
+ state.scrollOffset = 0;
188
+ }
189
+ render();
190
+ return;
191
+ }
97
192
  if (key.name === 'q' || (key.name === 'c' && key.ctrl)) {
98
193
  cleanup();
99
194
  console.log('');
100
195
  resolve();
101
196
  return;
102
197
  }
198
+ // `/` activates search from list or search view
199
+ if (_str === '/' && (state.view === 'list' || state.view === 'search') && !state.confirmDelete && !state.confirmGlobal) {
200
+ state.searchActive = true;
201
+ state.searchQuery = '';
202
+ state.view = 'search';
203
+ state.cursor = 0;
204
+ state.scrollOffset = 0;
205
+ render();
206
+ return;
207
+ }
103
208
  if (state.view === 'list') {
104
209
  if (key.name === 'up')
105
210
  state.cursor = Math.max(0, state.cursor - 1);
@@ -113,23 +218,44 @@ function startInteractive(merged, results) {
113
218
  state.view = 'detail';
114
219
  }
115
220
  }
221
+ else if (state.view === 'search') {
222
+ const hits = buildSearchHits(state.searchQuery, withPerms, results);
223
+ if (key.name === 'escape') {
224
+ state.view = 'list';
225
+ state.cursor = 0;
226
+ state.scrollOffset = 0;
227
+ state.searchQuery = '';
228
+ }
229
+ else if (key.name === 'up') {
230
+ do {
231
+ state.cursor = Math.max(0, state.cursor - 1);
232
+ } while (state.cursor > 0 && hits[state.cursor]?.isHeader);
233
+ }
234
+ else if (key.name === 'down') {
235
+ do {
236
+ state.cursor = Math.min(hits.length - 1, state.cursor + 1);
237
+ } while (state.cursor < hits.length - 1 && hits[state.cursor]?.isHeader);
238
+ }
239
+ else if (key.name === 'return') {
240
+ const hit = hits[state.cursor];
241
+ if (hit && !hit.isHeader) {
242
+ state.selectedProject = hit.projectIdx;
243
+ state.detailCursor = 0;
244
+ state.detailScroll = 0;
245
+ state.expanded = new Set();
246
+ state.searchQuery = '';
247
+ state.view = 'detail';
248
+ }
249
+ }
250
+ }
116
251
  else {
252
+ // detail view
117
253
  if (state.confirmDelete) {
118
254
  if (key.name === 'y') {
119
255
  const { rawPerm, filePath } = state.confirmDelete;
120
256
  if ((0, scanner_js_1.removePerm)(filePath, rawPerm)) {
121
- const idx = results.findIndex((r) => r.path === filePath);
122
- if (idx >= 0) {
123
- const updated = (0, scanner_js_1.scanFile)(filePath);
124
- if (updated) {
125
- results[idx] = updated;
126
- const entry = withPerms[state.selectedProject];
127
- entry.totalCount = updated.totalCount;
128
- entry.groups = new Map();
129
- for (const g of updated.groups)
130
- entry.groups.set(g.category, g.items.length);
131
- }
132
- }
257
+ refreshProject(results, withPerms, state.selectedProject, filePath);
258
+ dupMap = buildDupMap(results);
133
259
  state.flash = `${colors_js_1.GREEN}✔ Deleted${colors_js_1.NC}`;
134
260
  }
135
261
  state.confirmDelete = undefined;
@@ -141,7 +267,15 @@ function startInteractive(merged, results) {
141
267
  else if (state.confirmGlobal) {
142
268
  if (key.name === 'y') {
143
269
  if ((0, scanner_js_1.addPermToGlobal)(state.confirmGlobal.rawPerm)) {
144
- state.flash = `${colors_js_1.GREEN}✔ Copied to global${colors_js_1.NC}`;
270
+ // Refresh global scan result so dupMap picks up new global perm
271
+ const globalIdx = results.findIndex((r) => r.isGlobal);
272
+ if (globalIdx >= 0) {
273
+ const updated = (0, scanner_js_1.scanFile)(results[globalIdx].path);
274
+ if (updated)
275
+ results[globalIdx] = updated;
276
+ }
277
+ dupMap = buildDupMap(results);
278
+ state.flash = `${colors_js_1.GREEN}✔ Added to global${colors_js_1.NC}`;
145
279
  }
146
280
  else {
147
281
  state.flash = `${colors_js_1.DIM}· Already in global${colors_js_1.NC}`;
@@ -153,6 +287,7 @@ function startInteractive(merged, results) {
153
287
  state.view = 'list';
154
288
  state.detailCursor = 0;
155
289
  state.detailScroll = 0;
290
+ state.searchQuery = '';
156
291
  }
157
292
  else if (key.name === 'up') {
158
293
  state.detailCursor = Math.max(0, state.detailCursor - 1);
@@ -172,6 +307,13 @@ function startInteractive(merged, results) {
172
307
  else if (key.name === 'g') {
173
308
  state._global = true;
174
309
  }
310
+ else if (_str === '/' && !state.confirmDelete && !state.confirmGlobal) {
311
+ state.searchActive = true;
312
+ state.searchQuery = '';
313
+ state.view = 'search';
314
+ state.cursor = 0;
315
+ state.scrollOffset = 0;
316
+ }
175
317
  }
176
318
  render();
177
319
  };
@@ -209,27 +351,27 @@ function buildRiskMap(results) {
209
351
  }
210
352
  return map;
211
353
  }
212
- function renderList(state, withPerms, emptyCount, riskMap, depMap) {
354
+ function renderList(state, withPerms, emptyCount, riskMap, depMap, dupMap) {
213
355
  const rows = process.stdout.rows || 24;
214
356
  const cols = process.stdout.columns || 80;
215
357
  const cats = ['Bash', 'MCP', 'Tools'];
216
358
  const catsPresent = cats.filter((c) => withPerms.some((r) => r.groups.has(c)));
217
359
  const hasRisk = [...riskMap.values()].some((v) => v.critical > 0 || v.high > 0);
218
360
  const hasDep = depMap.size > 0;
361
+ const hasDup = dupMap.size > 0;
219
362
  const riskColWidth = hasRisk ? 3 : 0;
220
363
  const depColWidth = hasDep ? 3 : 0;
364
+ const dupColWidth = hasDup ? 4 : 0;
221
365
  const catColWidth = catsPresent.length * 7;
222
366
  const typeColWidth = 7;
223
367
  const maxName = Math.max(...withPerms.map((r) => r.shortName.length), 7);
224
368
  const nameColWidth = Math.min(maxName + typeColWidth, 35);
225
369
  const nameWidth = nameColWidth - typeColWidth;
226
- // content: marker(2) + nameCol + gap(2) + catCols + gap(2) + total(5) + riskCol(3) + depCol(3)
227
- const contentWidth = 2 + nameColWidth + 2 + catColWidth + 2 + 5 + (hasRisk ? riskColWidth : 0) + (hasDep ? depColWidth : 0);
370
+ const contentWidth = 2 + nameColWidth + 2 + catColWidth + 2 + 5 + (hasRisk ? riskColWidth : 0) + (hasDep ? depColWidth : 0) + (hasDup ? dupColWidth : 0);
228
371
  const w = Math.min(cols, contentWidth + 4);
229
- const inner = w - 4;
230
372
  const hasGlobalSep = withPerms.some((r) => r.isGlobal) && withPerms.some((r) => !r.isGlobal);
231
- // box takes: top(1) + header(2) + sep(1) + content + globalSep?(1) + emptyLine?(1) + bottom(1)
232
- const chrome = 5 + (hasGlobalSep ? 1 : 0) + (emptyCount > 0 ? 1 : 0);
373
+ const hasLegend = hasRisk || hasDep || hasDup;
374
+ const chrome = 5 + (hasGlobalSep ? 1 : 0) + (emptyCount > 0 ? 1 : 0) + (hasLegend ? 1 : 0);
233
375
  const visibleRows = Math.min(25, Math.max(1, rows - chrome));
234
376
  if (state.cursor < state.scrollOffset)
235
377
  state.scrollOffset = state.cursor;
@@ -240,9 +382,9 @@ function renderList(state, withPerms, emptyCount, riskMap, depMap) {
240
382
  lines.push(boxTop('ccperm', scrollInfo, w));
241
383
  const riskHeader = hasRisk ? ` ${rpad('!', 2)}` : '';
242
384
  const depHeader = hasDep ? ` ${rpad('†', 2)}` : '';
243
- lines.push(boxLine(`${colors_js_1.DIM} ${pad('PROJECT', nameColWidth)} ${catsPresent.map((c) => rpad(c, 5)).join(' ')} ${rpad('TOTAL', 5)}${riskHeader}${depHeader}${colors_js_1.NC}`, w));
385
+ const dupHeader = hasDup ? ` ${rpad('G', 3)}` : '';
386
+ lines.push(boxLine(`${colors_js_1.DIM} ${pad('PROJECT', nameColWidth)} ${catsPresent.map((c) => rpad(c, 5)).join(' ')} ${rpad('TOTAL', 5)}${riskHeader}${depHeader}${dupHeader}${colors_js_1.NC}`, w));
244
387
  lines.push(boxSep(w));
245
- const globalCount = withPerms.filter((r) => r.isGlobal).length;
246
388
  const end = Math.min(state.scrollOffset + visibleRows, withPerms.length);
247
389
  for (let i = state.scrollOffset; i < end; i++) {
248
390
  const r = withPerms[i];
@@ -277,7 +419,17 @@ function renderList(state, withPerms, emptyCount, riskMap, depMap) {
277
419
  else
278
420
  depCol = ` ${colors_js_1.DIM}${rpad('·', 2)}${colors_js_1.NC}`;
279
421
  }
280
- lines.push(boxLine(`${nameCol} ${catCols} ${totalCol}${riskCol}${depCol}`, w));
422
+ let dupCol = '';
423
+ if (hasDup) {
424
+ const dup = dupMap.get(r.display);
425
+ if (dup && dup.global > 0) {
426
+ dupCol = ` ${colors_js_1.YELLOW}${rpad(dup.global, 3)}${colors_js_1.NC}`;
427
+ }
428
+ else {
429
+ dupCol = ` ${colors_js_1.DIM}${rpad('·', 3)}${colors_js_1.NC}`;
430
+ }
431
+ }
432
+ lines.push(boxLine(`${nameCol} ${catCols} ${totalCol}${riskCol}${depCol}${dupCol}`, w));
281
433
  // separator after global section
282
434
  if (r.isGlobal && i + 1 < withPerms.length && !withPerms[i + 1].isGlobal) {
283
435
  lines.push(boxSep(w));
@@ -286,10 +438,93 @@ function renderList(state, withPerms, emptyCount, riskMap, depMap) {
286
438
  if (emptyCount > 0) {
287
439
  lines.push(boxLine(`${colors_js_1.DIM}+ ${emptyCount} projects with no permissions${colors_js_1.NC}`, w));
288
440
  }
289
- lines.push(boxBottom('[↑↓] navigate [Enter] detail [q] quit', w));
441
+ const legendParts = [];
442
+ if (hasRisk)
443
+ legendParts.push(`${colors_js_1.RED}!${colors_js_1.NC} risk`);
444
+ if (hasDep)
445
+ legendParts.push(`${colors_js_1.DIM}†${colors_js_1.NC} deprecated`);
446
+ if (hasDup)
447
+ legendParts.push(`${colors_js_1.YELLOW}G${colors_js_1.NC} in global`);
448
+ if (legendParts.length > 0) {
449
+ const legendStr = legendParts.join(' ');
450
+ const legendVisLen = visLen(legendStr);
451
+ const padLeft = Math.max(0, w - 4 - legendVisLen);
452
+ lines.push(boxLine(`${' '.repeat(padLeft)}${legendStr}`, w));
453
+ lines.push(boxBottom('[↑↓] navigate [Enter] detail [/] search [q] quit', w));
454
+ }
455
+ else {
456
+ lines.push(boxBottom('[↑↓] navigate [Enter] detail [/] search [q] quit', w));
457
+ }
458
+ process.stdout.write(lines.join('\n') + '\n');
459
+ }
460
+ function buildSearchHits(query, withPerms, results) {
461
+ if (!query)
462
+ return [];
463
+ const q = query.toLowerCase();
464
+ const hits = [];
465
+ for (let pi = 0; pi < withPerms.length; pi++) {
466
+ const entry = withPerms[pi];
467
+ const r = results.find((r) => r.display === entry.display);
468
+ if (!r)
469
+ continue;
470
+ const matched = r.permissions.filter((p) => p.toLowerCase().includes(q));
471
+ if (matched.length === 0)
472
+ continue;
473
+ hits.push({ projectName: entry.shortName, projectIdx: pi, perm: '', rawPerm: '', filePath: r.path, isHeader: true });
474
+ for (const p of matched) {
475
+ hits.push({ projectName: entry.shortName, projectIdx: pi, perm: p, rawPerm: p, filePath: r.path, isHeader: false });
476
+ }
477
+ }
478
+ return hits;
479
+ }
480
+ function renderSearch(state, withPerms, results) {
481
+ const rows = process.stdout.rows || 24;
482
+ const cols = process.stdout.columns || 80;
483
+ const w = Math.min(cols, 82);
484
+ const hits = buildSearchHits(state.searchQuery, withPerms, results);
485
+ const permCount = hits.filter((h) => !h.isHeader).length;
486
+ if (state.cursor >= hits.length)
487
+ state.cursor = Math.max(0, hits.length - 1);
488
+ // Skip header rows when navigating
489
+ if (hits[state.cursor]?.isHeader && state.cursor + 1 < hits.length)
490
+ state.cursor++;
491
+ const chrome = 3; // top + bottom + search bar
492
+ const visibleRows = Math.max(1, rows - chrome);
493
+ if (state.cursor < state.scrollOffset)
494
+ state.scrollOffset = state.cursor;
495
+ if (state.cursor >= state.scrollOffset + visibleRows)
496
+ state.scrollOffset = state.cursor - visibleRows + 1;
497
+ const lines = [];
498
+ const scrollInfo = hits.length > visibleRows ? `${state.cursor + 1}/${hits.length}` : '';
499
+ lines.push(boxTop(`search: ${state.searchQuery} ${permCount} permissions`, scrollInfo, w));
500
+ const end = Math.min(state.scrollOffset + visibleRows, hits.length);
501
+ for (let i = state.scrollOffset; i < end; i++) {
502
+ const hit = hits[i];
503
+ const isCursor = i === state.cursor;
504
+ if (hit.isHeader) {
505
+ const tag = withPerms[hit.projectIdx]?.isGlobal ? 'global' : withPerms[hit.projectIdx]?.fileType || '';
506
+ lines.push(boxLine(`${colors_js_1.YELLOW} ${hit.projectName}${colors_js_1.NC} ${colors_js_1.DIM}${tag}${colors_js_1.NC}`, w));
507
+ }
508
+ else {
509
+ const prefix = isCursor ? `${colors_js_1.CYAN}▸ ` : ' ';
510
+ const clean = cleanLabel(hit.perm);
511
+ const maxLen = w - 8;
512
+ const name = clean.length > maxLen ? clean.slice(0, maxLen - 1) + '…' : clean;
513
+ lines.push(boxLine(`${prefix} ${colors_js_1.DIM}${name}${colors_js_1.NC}`, w));
514
+ }
515
+ }
516
+ if (hits.length === 0 && state.searchQuery) {
517
+ lines.push(boxLine(`${colors_js_1.DIM} No matches${colors_js_1.NC}`, w));
518
+ }
519
+ if (state.searchActive) {
520
+ lines.push(boxBottom(`/ ${state.searchQuery}█ (${permCount} matches)`, w));
521
+ }
522
+ else {
523
+ lines.push(boxBottom(`[↑↓] navigate [Enter] go to project [/] new search [Esc] back`, w));
524
+ }
290
525
  process.stdout.write(lines.join('\n') + '\n');
291
526
  }
292
- function renderDetail(state, withPerms, results) {
527
+ function renderDetail(state, withPerms, results, dupMap) {
293
528
  const rows = process.stdout.rows || 24;
294
529
  const cols = process.stdout.columns || 80;
295
530
  const w = Math.min(cols, 82);
@@ -299,35 +534,47 @@ function renderDetail(state, withPerms, results) {
299
534
  const fileResult = results.find((r) => r.display === project.display);
300
535
  if (!fileResult || fileResult.totalCount === 0)
301
536
  return;
537
+ // Compute dup info for this file
538
+ const globalPerms = getGlobalPerms(results);
539
+ const dupInfo = project.isGlobal ? { exact: [], globalDup: [] } : (0, scanner_js_1.findDuplicates)(fileResult.path, globalPerms);
540
+ const exactSet = new Set(dupInfo.exact);
541
+ const globalDupSet = new Set(dupInfo.globalDup);
302
542
  // build navigable rows
303
- const navRows = [];
543
+ const allNavRows = [];
304
544
  for (const group of fileResult.groups) {
305
545
  const key = `${fileResult.path}:${group.category}`;
306
546
  const isOpen = state.expanded.has(key);
307
547
  const arrow = isOpen ? '▾' : '▸';
308
- navRows.push({ text: `${colors_js_1.YELLOW}${arrow} ${group.category}${colors_js_1.NC} ${colors_js_1.DIM}(${group.items.length})${colors_js_1.NC}`, key });
548
+ allNavRows.push({ text: `${colors_js_1.YELLOW}${arrow} ${group.category}${colors_js_1.NC} ${colors_js_1.DIM}(${group.items.length})${colors_js_1.NC}`, key });
309
549
  if (isOpen) {
310
550
  for (const item of group.items) {
311
551
  const clean = cleanLabel(item.name);
312
- // Find the raw permission string from the original permissions array
313
552
  const rawPerm = fileResult.permissions.find((p) => p.includes(item.name)) || '';
553
+ // Dup tag
554
+ let dupTag = '';
555
+ if (globalDupSet.has(rawPerm))
556
+ dupTag = ` ${colors_js_1.YELLOW}(in global)${colors_js_1.NC}`;
557
+ else if (exactSet.has(rawPerm))
558
+ dupTag = ` ${colors_js_1.DIM}(dup)${colors_js_1.NC}`;
314
559
  if (state.showInfo) {
315
560
  const info = (0, explain_js_1.explain)(group.category, item.name);
316
561
  const tag = severityTag(info.risk);
317
- const tagLen = info.risk.length + 2; // tag visual width (e.g. "CRITICAL" + 2 spaces)
562
+ const tagLen = info.risk.length + 2;
318
563
  const nameMax = Math.min(30, w - tagLen - 14);
319
564
  const name = clean.length > nameMax ? clean.slice(0, nameMax - 1) + '…' : clean;
320
565
  const desc = info.description ? `${colors_js_1.DIM}${info.description}${colors_js_1.NC}` : '';
321
- navRows.push({ text: ` ${pad(name, nameMax)} ${tag} ${desc}`, perm: item.name, rawPerm });
566
+ allNavRows.push({ text: ` ${pad(name, nameMax)} ${tag} ${desc}${dupTag}`, perm: item.name, rawPerm });
322
567
  }
323
568
  else {
324
- const maxLen = w - 8;
569
+ const dupTagVis = dupTag ? 10 : 0; // reserve space for tag
570
+ const maxLen = w - 8 - dupTagVis;
325
571
  const name = clean.length > maxLen ? clean.slice(0, maxLen - 1) + '…' : clean;
326
- navRows.push({ text: ` ${colors_js_1.DIM}${name}${colors_js_1.NC}`, perm: item.name, rawPerm });
572
+ allNavRows.push({ text: ` ${colors_js_1.DIM}${name}${colors_js_1.NC}${dupTag}`, perm: item.name, rawPerm });
327
573
  }
328
574
  }
329
575
  }
330
576
  }
577
+ const navRows = allNavRows;
331
578
  // handle toggle
332
579
  if (state._toggle) {
333
580
  delete state._toggle;
@@ -337,7 +584,7 @@ function renderDetail(state, withPerms, results) {
337
584
  state.expanded.delete(row.key);
338
585
  else
339
586
  state.expanded.add(row.key);
340
- renderDetail(state, withPerms, results);
587
+ renderDetail(state, withPerms, results, dupMap);
341
588
  return;
342
589
  }
343
590
  }
@@ -359,8 +606,9 @@ function renderDetail(state, withPerms, results) {
359
606
  }
360
607
  if (state.detailCursor >= navRows.length)
361
608
  state.detailCursor = Math.max(0, navRows.length - 1);
362
- // box chrome: top(1) + sep(1) + bottom(1) = 3
363
- const visibleRows = Math.max(1, rows - 3);
609
+ // top(1) + bottom(2 for hint, or 1 for flash/confirm/search)
610
+ const bottomChrome = (!state.flash && !state.confirmDelete && !state.confirmGlobal && !state.searchActive) ? 4 : 3;
611
+ const visibleRows = Math.max(1, rows - bottomChrome);
364
612
  if (state.detailCursor < state.detailScroll)
365
613
  state.detailScroll = state.detailCursor;
366
614
  if (state.detailCursor >= state.detailScroll + visibleRows)
@@ -369,7 +617,10 @@ function renderDetail(state, withPerms, results) {
369
617
  const scrollInfo = navRows.length > visibleRows ? `${state.detailCursor + 1}/${navRows.length}` : '';
370
618
  const lines = [];
371
619
  const typeTag = project.fileType === 'global' ? 'global' : project.fileType;
372
- lines.push(boxTop(`${project.shortName} (${typeTag}) ${project.totalCount} permissions`, scrollInfo, w));
620
+ const dupCount = dupMap.get(project.display);
621
+ const globalCount = dupCount?.global || 0;
622
+ const dupSuffix = globalCount > 0 ? ` ${colors_js_1.YELLOW}${globalCount} in global${colors_js_1.NC}` : '';
623
+ lines.push(boxTop(`${project.shortName} (${typeTag}) ${project.totalCount} permissions${dupSuffix}`, scrollInfo, w));
373
624
  for (let i = 0; i < visible.length; i++) {
374
625
  const globalIdx = state.detailScroll + i;
375
626
  const isCursor = globalIdx === state.detailCursor;
@@ -391,10 +642,13 @@ function renderDetail(state, withPerms, results) {
391
642
  const truncName = name.length > 30 ? name.slice(0, 29) + '…' : name;
392
643
  lines.push(boxBottom(`${colors_js_1.CYAN}Copy "${truncName}" to global? [y/N]${colors_js_1.NC}`, w));
393
644
  }
645
+ else if (state.searchActive) {
646
+ lines.push(boxBottom(`/ ${state.searchQuery}█ (${navRows.length} matches)`, w));
647
+ }
394
648
  else {
395
649
  const infoHint = state.showInfo ? '[i] hide info' : '[i] info';
396
- const globalHint = project.isGlobal ? '' : ' [g] global';
397
- lines.push(boxBottom(`[↑↓] navigate [Enter] expand ${infoHint} [d] delete${globalHint} [Esc] back [q] quit`, w));
650
+ const globalHint = project.isGlobal ? '' : ' [g] +global';
651
+ lines.push(boxBottom2(`${colors_js_1.DIM}[↑↓] navigate [Enter] expand ${infoHint} [d] delete${globalHint}${colors_js_1.NC}`, `[/] search [Esc] back [q] quit`, w));
398
652
  }
399
653
  process.stdout.write(lines.join('\n') + '\n');
400
654
  }
package/dist/scanner.js CHANGED
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.removePerm = removePerm;
7
7
  exports.addPermToGlobal = addPermToGlobal;
8
+ exports.findDuplicates = findDuplicates;
8
9
  exports.countDeprecated = countDeprecated;
9
10
  exports.findSettingsFiles = findSettingsFiles;
10
11
  exports.scanFile = scanFile;
@@ -79,6 +80,38 @@ function addPermToGlobal(rawPerm) {
79
80
  writeAudit('COPY_TO_GLOBAL', globalPath, rawPerm, before, allow);
80
81
  return true;
81
82
  }
83
+ function findDuplicates(filePath, globalPerms) {
84
+ let content;
85
+ try {
86
+ content = node_fs_1.default.readFileSync(filePath, 'utf8');
87
+ }
88
+ catch {
89
+ return { exact: [], globalDup: [] };
90
+ }
91
+ let json;
92
+ try {
93
+ json = JSON.parse(content);
94
+ }
95
+ catch {
96
+ return { exact: [], globalDup: [] };
97
+ }
98
+ const allow = json?.permissions?.allow;
99
+ if (!Array.isArray(allow))
100
+ return { exact: [], globalDup: [] };
101
+ const exact = [];
102
+ const seen = new Set();
103
+ for (const p of allow) {
104
+ if (seen.has(p)) {
105
+ if (!exact.includes(p))
106
+ exact.push(p);
107
+ }
108
+ else
109
+ seen.add(p);
110
+ }
111
+ const globalSet = new Set(globalPerms);
112
+ const globalDup = [...new Set(allow)].filter((p) => globalSet.has(p));
113
+ return { exact, globalDup };
114
+ }
82
115
  function countDeprecated(results) {
83
116
  const out = [];
84
117
  for (const r of results) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccperm",
3
- "version": "1.13.0",
3
+ "version": "1.14.1",
4
4
  "description": "Audit Claude Code permissions across all your projects",
5
5
  "bin": {
6
6
  "ccperm": "bin/ccperm.js"
@@ -24,7 +24,7 @@
24
24
  "files": [
25
25
  "bin/ccperm.js",
26
26
  "dist",
27
- "screenshot.png"
27
+ "demo.gif"
28
28
  ],
29
29
  "devDependencies": {
30
30
  "@types/node": "^25.3.0",
package/screenshot.png DELETED
Binary file