ccperm 1.5.1 → 1.7.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.
@@ -18,7 +18,8 @@ function mergeByProject(results) {
18
18
  const dir = projectDir(r.display);
19
19
  let merged = map.get(dir);
20
20
  if (!merged) {
21
- merged = { display: r.display, shortName: shortPath(r.display), totalCount: 0, groups: new Map() };
21
+ const name = r.isGlobal ? 'GLOBAL' : shortPath(r.display);
22
+ merged = { display: r.display, shortName: name, totalCount: 0, groups: new Map(), isGlobal: r.isGlobal };
22
23
  map.set(dir, merged);
23
24
  }
24
25
  merged.totalCount += r.totalCount;
@@ -6,9 +6,36 @@ 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
+ // strip ANSI escape codes for visible length
10
+ function visLen(s) {
11
+ return s.replace(/\x1b\[[0-9;]*m/g, '').length;
12
+ }
13
+ function boxLine(text, width) {
14
+ const vis = visLen(text);
15
+ const padRight = Math.max(0, width - vis - 1);
16
+ return `${colors_js_1.DIM}│${colors_js_1.NC} ${text}${' '.repeat(padRight)}${colors_js_1.DIM}│${colors_js_1.NC}`;
17
+ }
18
+ function boxTop(title, info, width) {
19
+ const inner = width - 2;
20
+ const titlePart = ` ${title} `;
21
+ const infoPart = info ? ` ${info} ` : '';
22
+ const fill = Math.max(0, inner - titlePart.length - infoPart.length);
23
+ return `${colors_js_1.DIM}┌${titlePart}${'─'.repeat(fill)}${infoPart}┐${colors_js_1.NC}`;
24
+ }
25
+ function boxBottom(hint, width) {
26
+ const inner = width - 2;
27
+ const hintPart = ` ${hint} `;
28
+ const fill = Math.max(0, inner - hintPart.length);
29
+ return `${colors_js_1.DIM}└${'─'.repeat(fill)}${hintPart}┘${colors_js_1.NC}`;
30
+ }
31
+ function boxSep(width) {
32
+ return `${colors_js_1.DIM}├${'─'.repeat(width - 2)}┤${colors_js_1.NC}`;
33
+ }
9
34
  function startInteractive(merged, results) {
10
35
  return new Promise((resolve) => {
11
- const withPerms = merged.filter((r) => r.totalCount > 0).sort((a, b) => b.totalCount - a.totalCount);
36
+ const globals = merged.filter((r) => r.totalCount > 0 && r.isGlobal).sort((a, b) => b.totalCount - a.totalCount);
37
+ const projects = merged.filter((r) => r.totalCount > 0 && !r.isGlobal).sort((a, b) => b.totalCount - a.totalCount);
38
+ const withPerms = [...globals, ...projects];
12
39
  const emptyCount = merged.filter((r) => r.totalCount === 0).length;
13
40
  if (withPerms.length === 0) {
14
41
  console.log(`\n ${colors_js_1.GREEN}No projects with permissions found.${colors_js_1.NC}\n`);
@@ -26,16 +53,11 @@ function startInteractive(merged, results) {
26
53
  process.stdin.pause();
27
54
  process.stdin.removeListener('keypress', onKey);
28
55
  process.removeListener('SIGINT', onSigint);
29
- // show cursor
30
56
  process.stdout.write('\x1b[?25h');
31
57
  };
32
- const onSigint = () => {
33
- cleanup();
34
- process.exit(0);
35
- };
58
+ const onSigint = () => { cleanup(); process.exit(0); };
36
59
  process.on('SIGINT', onSigint);
37
60
  const render = () => {
38
- // clear screen + move cursor home + hide cursor
39
61
  process.stdout.write('\x1b[2J\x1b[H\x1b[?25l');
40
62
  if (state.view === 'list')
41
63
  renderList(state, withPerms, emptyCount);
@@ -52,12 +74,10 @@ function startInteractive(merged, results) {
52
74
  return;
53
75
  }
54
76
  if (state.view === 'list') {
55
- if (key.name === 'up') {
77
+ if (key.name === 'up')
56
78
  state.cursor = Math.max(0, state.cursor - 1);
57
- }
58
- else if (key.name === 'down') {
79
+ else if (key.name === 'down')
59
80
  state.cursor = Math.min(withPerms.length - 1, state.cursor + 1);
60
- }
61
81
  else if (key.name === 'return') {
62
82
  state.selectedProject = state.cursor;
63
83
  state.detailCursor = 0;
@@ -67,7 +87,6 @@ function startInteractive(merged, results) {
67
87
  }
68
88
  }
69
89
  else {
70
- // detail view
71
90
  if (key.name === 'escape' || key.name === 'backspace') {
72
91
  state.view = 'list';
73
92
  state.detailCursor = 0;
@@ -80,7 +99,6 @@ function startInteractive(merged, results) {
80
99
  state.detailCursor++;
81
100
  }
82
101
  else if (key.name === 'return') {
83
- // toggle handled in renderDetail via detailRows
84
102
  state._toggle = true;
85
103
  }
86
104
  }
@@ -93,30 +111,35 @@ function startInteractive(merged, results) {
93
111
  function renderList(state, withPerms, emptyCount) {
94
112
  const rows = process.stdout.rows || 24;
95
113
  const cols = process.stdout.columns || 80;
114
+ const w = Math.min(cols, 82);
115
+ const inner = w - 4; // box border + 1 space each side
96
116
  const cats = ['Bash', 'WebFetch', 'MCP', 'Tools'];
97
117
  const catsPresent = cats.filter((c) => withPerms.some((r) => r.groups.has(c)));
98
- const nameWidth = Math.min(Math.max(...withPerms.map((r) => r.shortName.length), 7), 40);
99
- // header takes 4 lines, footer takes 3 lines
100
- const headerLines = 4;
101
- const footerLines = 3 + (emptyCount > 0 ? 1 : 0);
102
- const visibleRows = Math.max(1, rows - headerLines - footerLines);
103
- // adjust scroll offset
118
+ const catColWidth = catsPresent.length * 7;
119
+ const nameWidths = withPerms.map((r) => r.isGlobal ? r.shortName.length + 2 : r.shortName.length);
120
+ const nameWidth = Math.min(Math.max(...nameWidths, 7), inner - catColWidth - 8);
121
+ const hasGlobalSep = withPerms.some((r) => r.isGlobal) && withPerms.some((r) => !r.isGlobal);
122
+ // box takes: top(1) + header(2) + sep(1) + content + globalSep?(1) + emptyLine?(1) + bottom(1)
123
+ const chrome = 5 + (hasGlobalSep ? 1 : 0) + (emptyCount > 0 ? 1 : 0);
124
+ const visibleRows = Math.min(25, Math.max(1, rows - chrome));
104
125
  if (state.cursor < state.scrollOffset)
105
126
  state.scrollOffset = state.cursor;
106
127
  if (state.cursor >= state.scrollOffset + visibleRows)
107
128
  state.scrollOffset = state.cursor - visibleRows + 1;
129
+ const scrollInfo = withPerms.length > visibleRows ? `${state.cursor + 1}/${withPerms.length}` : '';
108
130
  const lines = [];
109
- const scrollInfo = withPerms.length > visibleRows ? ` ${colors_js_1.DIM}${state.cursor + 1}/${withPerms.length}${colors_js_1.NC}` : '';
110
- lines.push(` ${colors_js_1.CYAN}${colors_js_1.BOLD}ccperm${colors_js_1.NC} ${colors_js_1.DIM}interactive${colors_js_1.NC}${scrollInfo}\n`);
111
- lines.push(` ${colors_js_1.DIM}${pad('PROJECT', nameWidth)} ${catsPresent.map((c) => rpad(c, 5)).join(' ')} TOTAL${colors_js_1.NC}`);
112
- lines.push(` ${colors_js_1.DIM}${'─'.repeat(nameWidth + catsPresent.length * 7 + 8)}${colors_js_1.NC}`);
131
+ lines.push(boxTop('ccperm', scrollInfo, w));
132
+ lines.push(boxLine(`${colors_js_1.DIM}${pad('PROJECT', nameWidth)} ${catsPresent.map((c) => rpad(c, 5)).join(' ')} TOTAL${colors_js_1.NC}`, w));
133
+ lines.push(boxSep(w));
134
+ const globalCount = withPerms.filter((r) => r.isGlobal).length;
113
135
  const end = Math.min(state.scrollOffset + visibleRows, withPerms.length);
114
136
  for (let i = state.scrollOffset; i < end; i++) {
115
137
  const r = withPerms[i];
116
138
  const isCursor = i === state.cursor;
117
- const truncName = r.shortName.length > nameWidth ? r.shortName.slice(0, nameWidth - 1) + '…' : r.shortName;
118
- const marker = isCursor ? `${colors_js_1.CYAN}> ` : ' ';
119
- const nameStyle = isCursor ? `${colors_js_1.BOLD}` : `${colors_js_1.DIM}`;
139
+ const displayName = r.isGlobal ? `★ ${r.shortName}` : r.shortName;
140
+ const truncName = displayName.length > nameWidth ? displayName.slice(0, nameWidth - 1) + '' : displayName;
141
+ const marker = isCursor ? `${colors_js_1.CYAN}` : ' ';
142
+ const nameStyle = isCursor ? `${colors_js_1.BOLD}` : r.isGlobal ? `${colors_js_1.YELLOW}` : `${colors_js_1.DIM}`;
120
143
  const nameCol = `${marker}${nameStyle}${pad(truncName, nameWidth)}${colors_js_1.NC}`;
121
144
  const catCols = catsPresent.map((c) => {
122
145
  const count = r.groups.get(c) || 0;
@@ -125,18 +148,22 @@ function renderList(state, withPerms, emptyCount) {
125
148
  return `${colors_js_1.DIM}${rpad('·', 5)}${colors_js_1.NC}`;
126
149
  }).join(' ');
127
150
  const totalCol = isCursor ? `${colors_js_1.BOLD}${rpad(r.totalCount, 5)}${colors_js_1.NC}` : rpad(r.totalCount, 5);
128
- lines.push(`${nameCol} ${catCols} ${totalCol}`);
151
+ lines.push(boxLine(`${nameCol} ${catCols} ${totalCol}`, w));
152
+ // separator after global section
153
+ if (r.isGlobal && i + 1 < withPerms.length && !withPerms[i + 1].isGlobal) {
154
+ lines.push(boxSep(w));
155
+ }
129
156
  }
130
157
  if (emptyCount > 0) {
131
- lines.push(`\n ${colors_js_1.DIM}+ ${emptyCount} projects with no permissions${colors_js_1.NC}`);
158
+ lines.push(boxLine(`${colors_js_1.DIM}+ ${emptyCount} projects with no permissions${colors_js_1.NC}`, w));
132
159
  }
133
- lines.push('');
134
- lines.push(` ${colors_js_1.DIM}[↑↓] navigate [Enter] detail [q] quit${colors_js_1.NC}`);
160
+ lines.push(boxBottom('[↑↓] navigate [Enter] detail [q] quit', w));
135
161
  process.stdout.write(lines.join('\n') + '\n');
136
162
  }
137
163
  function renderDetail(state, withPerms, results) {
138
164
  const rows = process.stdout.rows || 24;
139
165
  const cols = process.stdout.columns || 80;
166
+ const w = Math.min(cols, 82);
140
167
  const project = withPerms[state.selectedProject];
141
168
  if (!project)
142
169
  return;
@@ -153,17 +180,17 @@ function renderDetail(state, withPerms, results) {
153
180
  if (result.totalCount === 0)
154
181
  continue;
155
182
  const fileName = result.display.replace(/.*\/\.claude\//, '');
156
- navRows.push({ line: ` ${colors_js_1.CYAN}${fileName}${colors_js_1.NC} ${colors_js_1.DIM}(${result.totalCount})${colors_js_1.NC}` });
183
+ navRows.push({ text: `${colors_js_1.CYAN}${fileName}${colors_js_1.NC} ${colors_js_1.DIM}(${result.totalCount})${colors_js_1.NC}` });
157
184
  for (const group of result.groups) {
158
185
  const key = `${result.path}:${group.category}`;
159
186
  const isOpen = state.expanded.has(key);
160
187
  const arrow = isOpen ? '▾' : '▸';
161
- navRows.push({ line: ` ${colors_js_1.YELLOW}${arrow} ${group.category}${colors_js_1.NC} ${colors_js_1.DIM}(${group.items.length})${colors_js_1.NC}`, key });
188
+ 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 });
162
189
  if (isOpen) {
163
- const maxLen = cols - 10;
190
+ const maxLen = w - 12;
164
191
  for (const item of group.items) {
165
192
  const name = item.name.length > maxLen ? item.name.slice(0, maxLen - 1) + '…' : item.name;
166
- navRows.push({ line: ` ${colors_js_1.DIM}${name}${colors_js_1.NC}` });
193
+ navRows.push({ text: ` ${colors_js_1.DIM}${name}${colors_js_1.NC}` });
167
194
  }
168
195
  }
169
196
  }
@@ -177,39 +204,30 @@ function renderDetail(state, withPerms, results) {
177
204
  state.expanded.delete(row.key);
178
205
  else
179
206
  state.expanded.add(row.key);
180
- // re-render needed — will happen on next render() call
181
207
  renderDetail(state, withPerms, results);
182
208
  return;
183
209
  }
184
210
  }
185
- // clamp cursor
186
211
  if (state.detailCursor >= navRows.length)
187
212
  state.detailCursor = Math.max(0, navRows.length - 1);
188
- // scroll
189
- const headerLines = 3;
190
- const footerLines = 2;
191
- const visibleRows = Math.max(1, rows - headerLines - footerLines);
213
+ // box chrome: top(1) + sep(1) + bottom(1) = 3
214
+ const visibleRows = Math.max(1, rows - 3);
192
215
  if (state.detailCursor < state.detailScroll)
193
216
  state.detailScroll = state.detailCursor;
194
217
  if (state.detailCursor >= state.detailScroll + visibleRows)
195
218
  state.detailScroll = state.detailCursor - visibleRows + 1;
196
219
  const visible = navRows.slice(state.detailScroll, state.detailScroll + visibleRows);
220
+ const scrollInfo = navRows.length > visibleRows ? `${state.detailCursor + 1}/${navRows.length}` : '';
197
221
  const lines = [];
198
- lines.push(` ${colors_js_1.CYAN}${colors_js_1.BOLD}${project.shortName}${colors_js_1.NC} ${colors_js_1.DIM}(${project.totalCount} permissions)${colors_js_1.NC}`);
199
- lines.push('');
222
+ lines.push(boxTop(`${project.shortName} (${project.totalCount})`, scrollInfo, w));
200
223
  for (let i = 0; i < visible.length; i++) {
201
224
  const globalIdx = state.detailScroll + i;
202
225
  const isCursor = globalIdx === state.detailCursor;
203
226
  const row = visible[i];
204
- if (isCursor) {
205
- lines.push(row.line.replace(/^ /, `${colors_js_1.CYAN}> `));
206
- }
207
- else {
208
- lines.push(row.line);
209
- }
227
+ const prefix = isCursor ? `${colors_js_1.CYAN}▸ ` : ' ';
228
+ lines.push(boxLine(`${prefix}${row.text}`, w));
210
229
  }
211
- lines.push('');
212
- lines.push(` ${colors_js_1.DIM}[↑↓] navigate [Enter] expand/collapse [Esc] back [q] quit${colors_js_1.NC}`);
230
+ lines.push(boxBottom('[↑↓] navigate [Enter] expand [Esc] back [q] quit', w));
213
231
  process.stdout.write(lines.join('\n') + '\n');
214
232
  }
215
233
  function pad(s, n) {
package/dist/renderer.js CHANGED
@@ -14,23 +14,33 @@ function rpad(s, n) {
14
14
  function printCompact(merged, summary) {
15
15
  const cats = ['Bash', 'WebFetch', 'MCP', 'Tools'];
16
16
  const catsPresent = cats.filter((c) => merged.some((r) => r.groups.has(c)));
17
- const withPerms = merged.filter((r) => r.totalCount > 0).sort((a, b) => b.totalCount - a.totalCount);
17
+ const globals = merged.filter((r) => r.totalCount > 0 && r.isGlobal).sort((a, b) => b.totalCount - a.totalCount);
18
+ const projects = merged.filter((r) => r.totalCount > 0 && !r.isGlobal).sort((a, b) => b.totalCount - a.totalCount);
19
+ const withPerms = [...globals, ...projects];
18
20
  const emptyCount = merged.filter((r) => r.totalCount === 0).length;
19
21
  // header
20
- const nameWidth = Math.min(Math.max(...withPerms.map((r) => r.shortName.length), 7), 40);
22
+ const nameWidths = withPerms.map((r) => r.isGlobal ? r.shortName.length + 2 : r.shortName.length);
23
+ const nameWidth = Math.min(Math.max(...nameWidths, 7), 40);
21
24
  const header = ` ${colors_js_1.DIM}${pad('PROJECT', nameWidth)} ${catsPresent.map((c) => rpad(c, 5)).join(' ')} TOTAL${colors_js_1.NC}`;
22
25
  console.log(header);
23
26
  console.log(` ${colors_js_1.DIM}${'─'.repeat(nameWidth + catsPresent.length * 7 + 8)}${colors_js_1.NC}`);
24
27
  // rows
25
- for (const result of withPerms) {
26
- const truncName = result.shortName.length > nameWidth ? result.shortName.slice(0, nameWidth - 1) + '…' : result.shortName;
27
- const nameCol = ` ${colors_js_1.DIM}${pad(truncName, nameWidth)}${colors_js_1.NC}`;
28
+ for (let i = 0; i < withPerms.length; i++) {
29
+ const result = withPerms[i];
30
+ const displayName = result.isGlobal ? `★ ${result.shortName}` : result.shortName;
31
+ const truncName = displayName.length > nameWidth ? displayName.slice(0, nameWidth - 1) + '…' : displayName;
32
+ const nameStyle = result.isGlobal ? `${colors_js_1.YELLOW}` : `${colors_js_1.DIM}`;
33
+ const nameCol = ` ${nameStyle}${pad(truncName, nameWidth)}${colors_js_1.NC}`;
28
34
  const catCols = catsPresent.map((c) => {
29
35
  const count = result.groups.get(c) || 0;
30
36
  return count > 0 ? rpad(count, 5) : `${colors_js_1.DIM}${rpad('·', 5)}${colors_js_1.NC}`;
31
37
  }).join(' ');
32
38
  const totalCol = rpad(result.totalCount, 5);
33
39
  console.log(`${nameCol} ${catCols} ${colors_js_1.BOLD}${totalCol}${colors_js_1.NC}`);
40
+ // separator after global section
41
+ if (result.isGlobal && i + 1 < withPerms.length && !withPerms[i + 1].isGlobal) {
42
+ console.log(` ${colors_js_1.DIM}${'─'.repeat(nameWidth + catsPresent.length * 7 + 8)}${colors_js_1.NC}`);
43
+ }
34
44
  }
35
45
  if (emptyCount > 0) {
36
46
  console.log(`\n ${colors_js_1.DIM}+ ${emptyCount} projects with no permissions${colors_js_1.NC}`);
package/dist/scanner.js CHANGED
@@ -93,6 +93,7 @@ async function findSettingsFiles(searchDir, onProgress, debug = false) {
93
93
  function scanFile(filePath) {
94
94
  const home = node_os_1.default.homedir();
95
95
  const display = filePath.startsWith(home) ? '~' + filePath.slice(home.length) : filePath;
96
+ const isGlobal = node_path_1.default.dirname(node_path_1.default.dirname(filePath)) === home;
96
97
  let content;
97
98
  try {
98
99
  content = node_fs_1.default.readFileSync(filePath, 'utf8');
@@ -103,7 +104,7 @@ function scanFile(filePath) {
103
104
  const perms = [...new Set((content.match(PERM_RE) || []).map((s) => s.slice(1, -1)))].sort();
104
105
  const groups = groupPermissions(perms);
105
106
  const totalCount = perms.length;
106
- return { path: filePath, display, permissions: perms, groups, totalCount };
107
+ return { path: filePath, display, permissions: perms, groups, totalCount, isGlobal };
107
108
  }
108
109
  function categorize(perm) {
109
110
  if (perm.startsWith('Bash')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccperm",
3
- "version": "1.5.1",
3
+ "version": "1.7.0",
4
4
  "description": "Audit Claude Code permissions across all your projects",
5
5
  "bin": {
6
6
  "ccperm": "bin/ccperm.js"