ccperm 1.12.1 → 1.14.0

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,24 @@ 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 `:*` 패턴).
45
45
 
46
46
  ```
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 ────┘
47
+ ┌ ccperm ──────────────────────────── 1/8 ┐
48
+ PROJECT Bash MCP Tools TOTAL
49
+ ├─────────────────────────────────────────┤
50
+ ~/.claude 2 2
51
+ ├─────────────────────────────────────────┤
52
+ my-project local 5 · 3 8
53
+ other-app shared 2 3 · 5
54
+ └ [↑↓] navigate [Enter] detail [q] quit┘
56
55
  ```
57
56
 
58
- **상세 뷰** — Enter로 프로젝트를 펼칩니다. 카테고리를 Enter로 접고 펼 수 있습니다.
57
+ **상세 뷰** — Enter로 프로젝트를 펼칩니다. 카테고리를 Enter로 접고 펼 수 있습니다. `[d]`로 권한 삭제, `[g]`로 글로벌 설정에 복사할 수 있습니다.
59
58
 
60
- **정보 모드** — `[i]`를 누르면 각 권한에 대한 설명이 나타납니다.
59
+ **정보 모드** — `[i]`를 누르면 각 권한의 위험도와 설명이 나타납니다.
61
60
 
62
- 키 조작: `↑↓` 이동, `Enter` 선택/펼치기, `[i]` 정보 토글, `Esc`/`Backspace` 뒤로, `q`/`Ctrl+C` 종료.
61
+ 키 조작: `↑↓` 이동, `Enter` 선택/펼치기, `[i]` 정보, `[d]` 삭제, `[g]` 글로벌 복사, `Esc` 뒤로, `q` 종료.
63
62
 
64
63
  ## 텍스트 출력
65
64
 
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,24 @@ 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 warnings), `†` (deprecated `:*` patterns).
45
45
 
46
46
  ```
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 ────┘
47
+ ┌ ccperm ──────────────────────────── 1/8 ┐
48
+ PROJECT Bash MCP Tools TOTAL
49
+ ├─────────────────────────────────────────┤
50
+ ~/.claude 2 2
51
+ ├─────────────────────────────────────────┤
52
+ my-project local 5 · 3 8
53
+ other-app shared 2 3 · 5
54
+ └ [↑↓] navigate [Enter] detail [q] quit┘
56
55
  ```
57
56
 
58
- **Detail view** — Press Enter to expand a project. Categories are collapsible; press Enter to toggle.
57
+ **Detail view** — Press Enter to expand a project. Categories are collapsible; press Enter to toggle. Press `[d]` to delete a permission, `[g]` to copy it to global settings.
59
58
 
60
- **Info mode** — Press `[i]` to show descriptions for each permission.
59
+ **Info mode** — Press `[i]` to show risk level and description for each permission.
61
60
 
62
- Keys: `↑↓` navigate, `Enter` select/expand, `[i]` toggle info, `Esc`/`Backspace` back, `q`/`Ctrl+C` quit.
61
+ Keys: `↑↓` navigate, `Enter` select/expand, `[i]` info, `[d]` delete, `[g]` copy to global, `Esc` back, `q` quit.
63
62
 
64
63
  ## Static Output
65
64
 
package/demo.gif ADDED
Binary file
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.startInteractive = startInteractive;
7
7
  const node_readline_1 = __importDefault(require("node:readline"));
8
8
  const colors_js_1 = require("./colors.js");
9
+ const scanner_js_1 = require("./scanner.js");
9
10
  const explain_js_1 = require("./explain.js");
10
11
  function severityTag(s) {
11
12
  const labels = {
@@ -41,20 +42,62 @@ function boxLine(text, width) {
41
42
  }
42
43
  function boxTop(title, info, width) {
43
44
  const inner = width - 2;
44
- const titlePart = ` ${title} `;
45
45
  const infoPart = info ? ` ${info} ` : '';
46
- 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);
47
55
  return `${colors_js_1.DIM}┌${titlePart}${'─'.repeat(fill)}${infoPart}┐${colors_js_1.NC}`;
48
56
  }
49
57
  function boxBottom(hint, width) {
50
58
  const inner = width - 2;
51
59
  const hintPart = ` ${hint} `;
52
- const fill = Math.max(0, inner - hintPart.length);
60
+ const fill = Math.max(0, inner - visLen(hintPart));
53
61
  return `${colors_js_1.DIM}└${'─'.repeat(fill)}${hintPart}┘${colors_js_1.NC}`;
54
62
  }
63
+ function boxBottom2(line1, line2, width) {
64
+ return boxLine(line1, width) + '\n' + boxBottom(line2, width);
65
+ }
55
66
  function boxSep(width) {
56
67
  return `${colors_js_1.DIM}├${'─'.repeat(width - 2)}┤${colors_js_1.NC}`;
57
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
+ }
58
101
  function startInteractive(merged, results) {
59
102
  return new Promise((resolve) => {
60
103
  const globals = merged.filter((r) => r.isGlobal);
@@ -63,12 +106,13 @@ function startInteractive(merged, results) {
63
106
  const emptyCount = merged.filter((r) => r.totalCount === 0 && !r.isGlobal).length;
64
107
  const riskMap = buildRiskMap(results);
65
108
  const depMap = buildDeprecatedMap(results);
109
+ let dupMap = buildDupMap(results);
66
110
  if (withPerms.length === 0) {
67
111
  console.log(`\n ${colors_js_1.GREEN}No projects with permissions found.${colors_js_1.NC}\n`);
68
112
  resolve();
69
113
  return;
70
114
  }
71
- 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: '' };
72
116
  node_readline_1.default.emitKeypressEvents(process.stdin);
73
117
  if (process.stdin.isTTY)
74
118
  process.stdin.setRawMode(true);
@@ -84,21 +128,83 @@ function startInteractive(merged, results) {
84
128
  const onSigint = () => { cleanup(); process.exit(0); };
85
129
  process.on('SIGINT', onSigint);
86
130
  const render = () => {
87
- process.stdout.write('\x1b[2J\x1b[H\x1b[?25l');
88
- if (state.view === 'list')
89
- 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);
90
137
  else
91
- renderDetail(state, withPerms, results);
138
+ renderDetail(state, withPerms, results, dupMap);
92
139
  };
93
140
  const onKey = (_str, key) => {
94
141
  if (!key)
95
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
+ }
96
192
  if (key.name === 'q' || (key.name === 'c' && key.ctrl)) {
97
193
  cleanup();
98
194
  console.log('');
99
195
  resolve();
100
196
  return;
101
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
+ }
102
208
  if (state.view === 'list') {
103
209
  if (key.name === 'up')
104
210
  state.cursor = Math.max(0, state.cursor - 1);
@@ -112,11 +218,76 @@ function startInteractive(merged, results) {
112
218
  state.view = 'detail';
113
219
  }
114
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
+ }
115
251
  else {
116
- if (key.name === 'escape' || key.name === 'backspace') {
252
+ // detail view
253
+ if (state.confirmDelete) {
254
+ if (key.name === 'y') {
255
+ const { rawPerm, filePath } = state.confirmDelete;
256
+ if ((0, scanner_js_1.removePerm)(filePath, rawPerm)) {
257
+ refreshProject(results, withPerms, state.selectedProject, filePath);
258
+ dupMap = buildDupMap(results);
259
+ state.flash = `${colors_js_1.GREEN}✔ Deleted${colors_js_1.NC}`;
260
+ }
261
+ state.confirmDelete = undefined;
262
+ }
263
+ else {
264
+ state.confirmDelete = undefined;
265
+ }
266
+ }
267
+ else if (state.confirmGlobal) {
268
+ if (key.name === 'y') {
269
+ if ((0, scanner_js_1.addPermToGlobal)(state.confirmGlobal.rawPerm)) {
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}`;
279
+ }
280
+ else {
281
+ state.flash = `${colors_js_1.DIM}· Already in global${colors_js_1.NC}`;
282
+ }
283
+ }
284
+ state.confirmGlobal = undefined;
285
+ }
286
+ else if (key.name === 'escape' || key.name === 'backspace') {
117
287
  state.view = 'list';
118
288
  state.detailCursor = 0;
119
289
  state.detailScroll = 0;
290
+ state.searchQuery = '';
120
291
  }
121
292
  else if (key.name === 'up') {
122
293
  state.detailCursor = Math.max(0, state.detailCursor - 1);
@@ -130,6 +301,19 @@ function startInteractive(merged, results) {
130
301
  else if (key.name === 'i') {
131
302
  state.showInfo = !state.showInfo;
132
303
  }
304
+ else if (key.name === 'd') {
305
+ state._delete = true;
306
+ }
307
+ else if (key.name === 'g') {
308
+ state._global = true;
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
+ }
133
317
  }
134
318
  render();
135
319
  };
@@ -167,27 +351,27 @@ function buildRiskMap(results) {
167
351
  }
168
352
  return map;
169
353
  }
170
- function renderList(state, withPerms, emptyCount, riskMap, depMap) {
354
+ function renderList(state, withPerms, emptyCount, riskMap, depMap, dupMap) {
171
355
  const rows = process.stdout.rows || 24;
172
356
  const cols = process.stdout.columns || 80;
173
357
  const cats = ['Bash', 'MCP', 'Tools'];
174
358
  const catsPresent = cats.filter((c) => withPerms.some((r) => r.groups.has(c)));
175
359
  const hasRisk = [...riskMap.values()].some((v) => v.critical > 0 || v.high > 0);
176
360
  const hasDep = depMap.size > 0;
361
+ const hasDup = dupMap.size > 0;
177
362
  const riskColWidth = hasRisk ? 3 : 0;
178
363
  const depColWidth = hasDep ? 3 : 0;
364
+ const dupColWidth = hasDup ? 4 : 0;
179
365
  const catColWidth = catsPresent.length * 7;
180
366
  const typeColWidth = 7;
181
367
  const maxName = Math.max(...withPerms.map((r) => r.shortName.length), 7);
182
368
  const nameColWidth = Math.min(maxName + typeColWidth, 35);
183
369
  const nameWidth = nameColWidth - typeColWidth;
184
- // content: marker(2) + nameCol + gap(2) + catCols + gap(2) + total(5) + riskCol(3) + depCol(3)
185
- 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);
186
371
  const w = Math.min(cols, contentWidth + 4);
187
- const inner = w - 4;
188
372
  const hasGlobalSep = withPerms.some((r) => r.isGlobal) && withPerms.some((r) => !r.isGlobal);
189
- // box takes: top(1) + header(2) + sep(1) + content + globalSep?(1) + emptyLine?(1) + bottom(1)
190
- 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);
191
375
  const visibleRows = Math.min(25, Math.max(1, rows - chrome));
192
376
  if (state.cursor < state.scrollOffset)
193
377
  state.scrollOffset = state.cursor;
@@ -198,9 +382,9 @@ function renderList(state, withPerms, emptyCount, riskMap, depMap) {
198
382
  lines.push(boxTop('ccperm', scrollInfo, w));
199
383
  const riskHeader = hasRisk ? ` ${rpad('!', 2)}` : '';
200
384
  const depHeader = hasDep ? ` ${rpad('†', 2)}` : '';
201
- 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));
202
387
  lines.push(boxSep(w));
203
- const globalCount = withPerms.filter((r) => r.isGlobal).length;
204
388
  const end = Math.min(state.scrollOffset + visibleRows, withPerms.length);
205
389
  for (let i = state.scrollOffset; i < end; i++) {
206
390
  const r = withPerms[i];
@@ -235,7 +419,17 @@ function renderList(state, withPerms, emptyCount, riskMap, depMap) {
235
419
  else
236
420
  depCol = ` ${colors_js_1.DIM}${rpad('·', 2)}${colors_js_1.NC}`;
237
421
  }
238
- 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));
239
433
  // separator after global section
240
434
  if (r.isGlobal && i + 1 < withPerms.length && !withPerms[i + 1].isGlobal) {
241
435
  lines.push(boxSep(w));
@@ -244,10 +438,93 @@ function renderList(state, withPerms, emptyCount, riskMap, depMap) {
244
438
  if (emptyCount > 0) {
245
439
  lines.push(boxLine(`${colors_js_1.DIM}+ ${emptyCount} projects with no permissions${colors_js_1.NC}`, w));
246
440
  }
247
- 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
+ }
248
458
  process.stdout.write(lines.join('\n') + '\n');
249
459
  }
250
- function renderDetail(state, withPerms, results) {
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
+ }
525
+ process.stdout.write(lines.join('\n') + '\n');
526
+ }
527
+ function renderDetail(state, withPerms, results, dupMap) {
251
528
  const rows = process.stdout.rows || 24;
252
529
  const cols = process.stdout.columns || 80;
253
530
  const w = Math.min(cols, 82);
@@ -257,33 +534,47 @@ function renderDetail(state, withPerms, results) {
257
534
  const fileResult = results.find((r) => r.display === project.display);
258
535
  if (!fileResult || fileResult.totalCount === 0)
259
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);
260
542
  // build navigable rows
261
- const navRows = [];
543
+ const allNavRows = [];
262
544
  for (const group of fileResult.groups) {
263
545
  const key = `${fileResult.path}:${group.category}`;
264
546
  const isOpen = state.expanded.has(key);
265
547
  const arrow = isOpen ? '▾' : '▸';
266
- 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 });
267
549
  if (isOpen) {
268
550
  for (const item of group.items) {
269
551
  const clean = cleanLabel(item.name);
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}`;
270
559
  if (state.showInfo) {
271
560
  const info = (0, explain_js_1.explain)(group.category, item.name);
272
561
  const tag = severityTag(info.risk);
273
- const tagLen = info.risk.length + 2; // tag visual width (e.g. "CRITICAL" + 2 spaces)
562
+ const tagLen = info.risk.length + 2;
274
563
  const nameMax = Math.min(30, w - tagLen - 14);
275
564
  const name = clean.length > nameMax ? clean.slice(0, nameMax - 1) + '…' : clean;
276
565
  const desc = info.description ? `${colors_js_1.DIM}${info.description}${colors_js_1.NC}` : '';
277
- navRows.push({ text: ` ${pad(name, nameMax)} ${tag} ${desc}`, perm: item.name });
566
+ allNavRows.push({ text: ` ${pad(name, nameMax)} ${tag} ${desc}${dupTag}`, perm: item.name, rawPerm });
278
567
  }
279
568
  else {
280
- const maxLen = w - 8;
569
+ const dupTagVis = dupTag ? 10 : 0; // reserve space for tag
570
+ const maxLen = w - 8 - dupTagVis;
281
571
  const name = clean.length > maxLen ? clean.slice(0, maxLen - 1) + '…' : clean;
282
- navRows.push({ text: ` ${colors_js_1.DIM}${name}${colors_js_1.NC}`, perm: item.name });
572
+ allNavRows.push({ text: ` ${colors_js_1.DIM}${name}${colors_js_1.NC}${dupTag}`, perm: item.name, rawPerm });
283
573
  }
284
574
  }
285
575
  }
286
576
  }
577
+ const navRows = allNavRows;
287
578
  // handle toggle
288
579
  if (state._toggle) {
289
580
  delete state._toggle;
@@ -293,14 +584,31 @@ function renderDetail(state, withPerms, results) {
293
584
  state.expanded.delete(row.key);
294
585
  else
295
586
  state.expanded.add(row.key);
296
- renderDetail(state, withPerms, results);
587
+ renderDetail(state, withPerms, results, dupMap);
297
588
  return;
298
589
  }
299
590
  }
591
+ // handle delete
592
+ if (state._delete) {
593
+ delete state._delete;
594
+ const row = navRows[state.detailCursor];
595
+ if (row?.rawPerm) {
596
+ state.confirmDelete = { perm: row.perm, rawPerm: row.rawPerm, filePath: fileResult.path };
597
+ }
598
+ }
599
+ // handle global copy
600
+ if (state._global) {
601
+ delete state._global;
602
+ const row = navRows[state.detailCursor];
603
+ if (row?.rawPerm && !project.isGlobal) {
604
+ state.confirmGlobal = { perm: row.perm, rawPerm: row.rawPerm };
605
+ }
606
+ }
300
607
  if (state.detailCursor >= navRows.length)
301
608
  state.detailCursor = Math.max(0, navRows.length - 1);
302
- // box chrome: top(1) + sep(1) + bottom(1) = 3
303
- 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);
304
612
  if (state.detailCursor < state.detailScroll)
305
613
  state.detailScroll = state.detailCursor;
306
614
  if (state.detailCursor >= state.detailScroll + visibleRows)
@@ -309,7 +617,10 @@ function renderDetail(state, withPerms, results) {
309
617
  const scrollInfo = navRows.length > visibleRows ? `${state.detailCursor + 1}/${navRows.length}` : '';
310
618
  const lines = [];
311
619
  const typeTag = project.fileType === 'global' ? 'global' : project.fileType;
312
- 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));
313
624
  for (let i = 0; i < visible.length; i++) {
314
625
  const globalIdx = state.detailScroll + i;
315
626
  const isCursor = globalIdx === state.detailCursor;
@@ -317,8 +628,28 @@ function renderDetail(state, withPerms, results) {
317
628
  const prefix = isCursor ? `${colors_js_1.CYAN}▸ ` : ' ';
318
629
  lines.push(boxLine(`${prefix}${row.text}`, w));
319
630
  }
320
- const infoHint = state.showInfo ? '[i] hide info' : '[i] info';
321
- lines.push(boxBottom(`[↑↓] navigate [Enter] expand ${infoHint} [Esc] back [q] quit`, w));
631
+ if (state.flash) {
632
+ lines.push(boxBottom(state.flash, w));
633
+ state.flash = undefined;
634
+ }
635
+ else if (state.confirmDelete) {
636
+ const name = cleanLabel(state.confirmDelete.perm);
637
+ const truncName = name.length > 30 ? name.slice(0, 29) + '…' : name;
638
+ lines.push(boxBottom(`${colors_js_1.RED}Delete "${truncName}"? [y/N]${colors_js_1.NC}`, w));
639
+ }
640
+ else if (state.confirmGlobal) {
641
+ const name = cleanLabel(state.confirmGlobal.perm);
642
+ const truncName = name.length > 30 ? name.slice(0, 29) + '…' : name;
643
+ lines.push(boxBottom(`${colors_js_1.CYAN}Copy "${truncName}" to global? [y/N]${colors_js_1.NC}`, w));
644
+ }
645
+ else if (state.searchActive) {
646
+ lines.push(boxBottom(`/ ${state.searchQuery}█ (${navRows.length} matches)`, w));
647
+ }
648
+ else {
649
+ const infoHint = state.showInfo ? '[i] hide info' : '[i] info';
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));
652
+ }
322
653
  process.stdout.write(lines.join('\n') + '\n');
323
654
  }
324
655
  function pad(s, n) {
package/dist/scanner.js CHANGED
@@ -3,6 +3,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.removePerm = removePerm;
7
+ exports.addPermToGlobal = addPermToGlobal;
8
+ exports.findDuplicates = findDuplicates;
6
9
  exports.countDeprecated = countDeprecated;
7
10
  exports.findSettingsFiles = findSettingsFiles;
8
11
  exports.scanFile = scanFile;
@@ -11,6 +14,104 @@ const node_path_1 = __importDefault(require("node:path"));
11
14
  const node_os_1 = __importDefault(require("node:os"));
12
15
  const PERM_RE = /"(Bash|Write|Edit|Read|Glob|Grep|WebSearch|WebFetch|mcp_)[^"]*"/g;
13
16
  const DEPRECATED_RE = /:\*\)|:\*"/g;
17
+ const AUDIT_DIR = node_path_1.default.join(node_os_1.default.homedir(), '.ccperm', 'audit');
18
+ function writeAudit(action, filePath, perm, before, after) {
19
+ try {
20
+ node_fs_1.default.mkdirSync(AUDIT_DIR, { recursive: true });
21
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
22
+ const entry = { action, file: filePath, perm, before, after, timestamp: new Date().toISOString() };
23
+ node_fs_1.default.writeFileSync(node_path_1.default.join(AUDIT_DIR, `${ts}_${action}.json`), JSON.stringify(entry, null, 2) + '\n');
24
+ }
25
+ catch { /* audit is best-effort */ }
26
+ }
27
+ function removePerm(filePath, rawPerm) {
28
+ let content;
29
+ try {
30
+ content = node_fs_1.default.readFileSync(filePath, 'utf8');
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ let json;
36
+ try {
37
+ json = JSON.parse(content);
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ const allow = json?.permissions?.allow;
43
+ if (!Array.isArray(allow))
44
+ return false;
45
+ const idx = allow.indexOf(rawPerm);
46
+ if (idx === -1)
47
+ return false;
48
+ const before = [...allow];
49
+ allow.splice(idx, 1);
50
+ node_fs_1.default.writeFileSync(filePath, JSON.stringify(json, null, 2) + '\n', 'utf8');
51
+ writeAudit('DELETE', filePath, rawPerm, before, allow);
52
+ return true;
53
+ }
54
+ function addPermToGlobal(rawPerm) {
55
+ const globalPath = node_path_1.default.join(node_os_1.default.homedir(), '.claude', 'settings.json');
56
+ let content;
57
+ try {
58
+ content = node_fs_1.default.readFileSync(globalPath, 'utf8');
59
+ }
60
+ catch {
61
+ content = '{}';
62
+ }
63
+ let json;
64
+ try {
65
+ json = JSON.parse(content);
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ if (!json.permissions)
71
+ json.permissions = {};
72
+ if (!Array.isArray(json.permissions.allow))
73
+ json.permissions.allow = [];
74
+ const allow = json.permissions.allow;
75
+ if (allow.includes(rawPerm))
76
+ return false; // already exists
77
+ const before = [...allow];
78
+ allow.push(rawPerm);
79
+ node_fs_1.default.writeFileSync(globalPath, JSON.stringify(json, null, 2) + '\n', 'utf8');
80
+ writeAudit('COPY_TO_GLOBAL', globalPath, rawPerm, before, allow);
81
+ return true;
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
+ }
14
115
  function countDeprecated(results) {
15
116
  const out = [];
16
117
  for (const r of results) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccperm",
3
- "version": "1.12.1",
3
+ "version": "1.14.0",
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