ccperm 1.13.0 → 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 +13 -14
- package/README.md +13 -14
- package/demo.gif +0 -0
- package/dist/interactive.js +298 -44
- package/dist/scanner.js +33 -0
- package/package.json +2 -2
- package/screenshot.png +0 -0
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="./
|
|
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
|
-
**목록 뷰** — 프로젝트가 권한 수 기준으로 정렬됩니다.
|
|
44
|
+
**목록 뷰** — 프로젝트가 권한 수 기준으로 정렬됩니다. 컬럼: Bash, MCP, Tools, TOTAL, `!` (위험도 경고), `†` (deprecated `:*` 패턴).
|
|
45
45
|
|
|
46
46
|
```
|
|
47
|
-
┌ ccperm
|
|
48
|
-
│
|
|
49
|
-
|
|
50
|
-
│
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
│
|
|
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]`
|
|
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="./
|
|
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.
|
|
44
|
+
**List view** — Projects sorted by permission count. Columns: Bash, MCP, Tools, TOTAL, `!` (risk warnings), `†` (deprecated `:*` patterns).
|
|
45
45
|
|
|
46
46
|
```
|
|
47
|
-
┌ ccperm
|
|
48
|
-
│
|
|
49
|
-
|
|
50
|
-
│
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
│
|
|
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
|
|
59
|
+
**Info mode** — Press `[i]` to show risk level and description for each permission.
|
|
61
60
|
|
|
62
|
-
Keys: `↑↓` navigate, `Enter` select/expand, `[i]`
|
|
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
|
package/dist/interactive.js
CHANGED
|
@@ -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
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
566
|
+
allNavRows.push({ text: ` ${pad(name, nameMax)} ${tag} ${desc}${dupTag}`, perm: item.name, rawPerm });
|
|
322
567
|
}
|
|
323
568
|
else {
|
|
324
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
363
|
-
const
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
-
"
|
|
27
|
+
"demo.gif"
|
|
28
28
|
],
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@types/node": "^25.3.0",
|
package/screenshot.png
DELETED
|
Binary file
|