@buffbirb/unclaude 1.0.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.
@@ -0,0 +1,595 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect, useCallback, useMemo } from 'react';
3
+ import { Box, Text, useInput, useApp } from 'ink';
4
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, rmSync, readdirSync } from 'fs';
5
+ import { execFileSync } from 'child_process';
6
+ import { join } from 'path';
7
+ import { HOME, AIRC, CLAUDE_SETTINGS, OPENCODE_CONFIG, HCLAUDE, HOPENCODE, AGENT_IDS, SCOPE_IDS, GIT_SETTING_IDS, FEATURE_ORDER, AGENT_LABELS, SCOPE_LABELS, GIT_SETTING_LABELS, FEATURE_LABELS, getFeatureItems, toggleSelection, removeGitignoreEntries, run, stream, commandExists, nvmShell, readJson, patchJson, HEADROOM_START_FUNC, removeSessionStartHook, AGENT_MD_HEADER, removeSectionByHeader, FEATURE_SCOPES, } from './common.js';
8
+ // ── Detection ─────────────────────────────────────────────────────────────────
9
+ function detectInstalled() {
10
+ const airc = existsSync(AIRC) ? readFileSync(AIRC, 'utf8') : '';
11
+ const globalSettings = readJson(CLAUDE_SETTINGS);
12
+ const opencodeConfig = readJson(OPENCODE_CONFIG);
13
+ const sel = {
14
+ agents: new Set(),
15
+ scopes: new Set(),
16
+ features: new Set(),
17
+ lspLanguages: new Set(),
18
+ gitSettings: new Set(['gitignore']),
19
+ };
20
+ if (commandExists('claude'))
21
+ sel.agents.add('claudeCode');
22
+ if (commandExists('opencode'))
23
+ sel.agents.add('openCode');
24
+ if (existsSync(HCLAUDE) || existsSync(HOPENCODE) || airc.includes('headroom_start')) {
25
+ sel.features.add('headroom');
26
+ }
27
+ if (airc.includes('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC')) {
28
+ sel.features.add('stopTelemetry');
29
+ }
30
+ const attrGlobal = globalSettings['attribution'];
31
+ if (attrGlobal?.['commit'] === '') {
32
+ sel.features.add('stopAttributionConfig');
33
+ sel.scopes.add('global');
34
+ }
35
+ if (existsSync('.claude/settings.json')) {
36
+ const pSettings = readJson('.claude/settings.json');
37
+ const attrProject = pSettings['attribution'];
38
+ if (attrProject?.['commit'] === '') {
39
+ sel.features.add('stopAttributionConfig');
40
+ sel.scopes.add('project');
41
+ }
42
+ }
43
+ if (existsSync('.claude/hooks/strip-commit-attribution.sh'))
44
+ sel.features.add('stripCommitAttribution');
45
+ if (existsSync('.github/workflows/strip-pr-attribution.yml'))
46
+ sel.features.add('stripPrAttribution');
47
+ if (existsSync('.claude/hooks/strip-claude-branch.sh'))
48
+ sel.features.add('renameClaudeBranch');
49
+ if (existsSync(join(HOME, '.config', 'git', 'hooks', 'commit-msg'))) {
50
+ sel.features.add('stopAttributionHook');
51
+ sel.scopes.add('global');
52
+ }
53
+ try {
54
+ const gitDir = execFileSync('git', ['rev-parse', '--git-dir'], { encoding: 'utf8' }).trim();
55
+ if (existsSync(join(gitDir, 'hooks', 'commit-msg'))) {
56
+ sel.features.add('stopAttributionHook');
57
+ sel.scopes.add('project');
58
+ }
59
+ }
60
+ catch { /* not in a git repo */ }
61
+ const envSettings = globalSettings['env'];
62
+ if (envSettings?.['ENABLE_LSP_TOOL'] === '1') {
63
+ sel.features.add('lsp');
64
+ if (commandExists('clangd'))
65
+ sel.lspLanguages.add('cpp');
66
+ sel.lspLanguages.add('swift');
67
+ }
68
+ if (opencodeConfig['lsp'] === true) {
69
+ sel.features.add('lsp');
70
+ }
71
+ if (commandExists('openspec')) {
72
+ sel.features.add('openspec');
73
+ }
74
+ const globalClaudeMd = existsSync(join(HOME, '.claude', 'CLAUDE.md')) ? readFileSync(join(HOME, '.claude', 'CLAUDE.md'), 'utf8') : '';
75
+ if (globalClaudeMd.includes(AGENT_MD_HEADER)) {
76
+ sel.features.add('agentMdFile');
77
+ sel.scopes.add('global');
78
+ }
79
+ if (existsSync('CLAUDE.md')) {
80
+ const projectClaudeMd = readFileSync('CLAUDE.md', 'utf8');
81
+ if (projectClaudeMd.includes(AGENT_MD_HEADER)) {
82
+ sel.features.add('agentMdFile');
83
+ sel.scopes.add('project');
84
+ }
85
+ }
86
+ return sel;
87
+ }
88
+ // ── Uninstall functions ───────────────────────────────────────────────────────
89
+ async function removeExecutable(filePath, onLine) {
90
+ if (!existsSync(filePath))
91
+ return;
92
+ try {
93
+ unlinkSync(filePath);
94
+ }
95
+ catch {
96
+ onLine(`sudo required to remove ${filePath}`);
97
+ try {
98
+ // -n = non-interactive: fail immediately if password needed (avoids TUI hang)
99
+ await run('sudo', ['-n', 'rm', filePath]);
100
+ }
101
+ catch {
102
+ onLine(`Could not remove ${filePath} — run manually: sudo rm ${filePath}`);
103
+ return;
104
+ }
105
+ }
106
+ onLine(`${filePath} removed`);
107
+ }
108
+ async function uninstallHeadroom(onLine) {
109
+ try {
110
+ const pids = await run('lsof', ['-ti:8787']);
111
+ for (const pid of pids.split('\n').filter(Boolean)) {
112
+ await run('kill', [pid]);
113
+ }
114
+ onLine('Headroom proxy stopped');
115
+ }
116
+ catch { /* not running */ }
117
+ await removeExecutable(HCLAUDE, onLine);
118
+ await removeExecutable(HOPENCODE, onLine);
119
+ if (existsSync(AIRC)) {
120
+ const content = readFileSync(AIRC, 'utf8');
121
+ const cleaned = content.replace(HEADROOM_START_FUNC, '');
122
+ if (cleaned !== content) {
123
+ writeFileSync(AIRC, cleaned);
124
+ onLine('headroom_start removed from ~/.airc');
125
+ }
126
+ }
127
+ if (commandExists('uv')) {
128
+ try {
129
+ await stream('uv', ['tool', 'uninstall', 'headroom-ai'], onLine);
130
+ }
131
+ catch {
132
+ onLine('Warning: headroom-ai not found in uv tools');
133
+ }
134
+ }
135
+ }
136
+ async function uninstallStopTelemetry(onLine) {
137
+ if (!existsSync(AIRC)) {
138
+ onLine('~/.airc not found');
139
+ return;
140
+ }
141
+ const lines = readFileSync(AIRC, 'utf8').split('\n');
142
+ const cleaned = lines.filter(l => !l.includes('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'));
143
+ if (cleaned.length !== lines.length) {
144
+ writeFileSync(AIRC, cleaned.join('\n'));
145
+ onLine('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC removed from ~/.airc');
146
+ }
147
+ else {
148
+ onLine('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC not found in ~/.airc');
149
+ }
150
+ }
151
+ async function uninstallStopAttributionConfig(scopes, onLine) {
152
+ if (scopes.has('global')) {
153
+ patchJson(CLAUDE_SETTINGS, d => { delete d['attribution']; });
154
+ onLine('attribution removed from ~/.claude/settings.json');
155
+ }
156
+ if (scopes.has('project') && existsSync('.claude/settings.json')) {
157
+ patchJson('.claude/settings.json', d => { delete d['attribution']; });
158
+ onLine('attribution removed from .claude/settings.json');
159
+ }
160
+ }
161
+ async function uninstallStopAttributionHook(onLine) {
162
+ try {
163
+ const gitDir = await run('git', ['rev-parse', '--git-dir']);
164
+ const hookPath = join(gitDir, 'hooks', 'commit-msg');
165
+ if (existsSync(hookPath)) {
166
+ unlinkSync(hookPath);
167
+ onLine('Project commit-msg hook removed');
168
+ }
169
+ }
170
+ catch {
171
+ onLine('Not in a git repo — skipping project hook removal');
172
+ }
173
+ }
174
+ async function uninstallStripCommitAttribution(onLine) {
175
+ const hookPath = '.claude/hooks/strip-commit-attribution.sh';
176
+ if (existsSync(hookPath)) {
177
+ unlinkSync(hookPath);
178
+ onLine(`${hookPath} removed`);
179
+ }
180
+ else {
181
+ onLine(`${hookPath} not found`);
182
+ }
183
+ if (existsSync('.claude/settings.json')) {
184
+ removeSessionStartHook('.claude/settings.json', 'bash .claude/hooks/strip-commit-attribution.sh');
185
+ onLine('SessionStart hook removed from .claude/settings.json');
186
+ }
187
+ try {
188
+ const gitDir = await run('git', ['rev-parse', '--git-dir']);
189
+ const cmHook = join(gitDir, 'hooks', 'commit-msg');
190
+ if (existsSync(cmHook)) {
191
+ unlinkSync(cmHook);
192
+ onLine('commit-msg hook removed from .git/hooks');
193
+ }
194
+ }
195
+ catch { /* not in a git repo */ }
196
+ }
197
+ async function uninstallStripPrAttribution(onLine) {
198
+ const wfPath = '.github/workflows/strip-pr-attribution.yml';
199
+ if (existsSync(wfPath)) {
200
+ unlinkSync(wfPath);
201
+ onLine(`${wfPath} removed`);
202
+ }
203
+ else {
204
+ onLine(`${wfPath} not found`);
205
+ }
206
+ }
207
+ async function uninstallRenameClaudeBranch(onLine) {
208
+ const hookPath = '.claude/hooks/strip-claude-branch.sh';
209
+ if (existsSync(hookPath)) {
210
+ unlinkSync(hookPath);
211
+ onLine(`${hookPath} removed`);
212
+ }
213
+ else {
214
+ onLine(`${hookPath} not found`);
215
+ }
216
+ if (existsSync('.claude/settings.json')) {
217
+ removeSessionStartHook('.claude/settings.json', 'bash .claude/hooks/strip-claude-branch.sh');
218
+ onLine('SessionStart hook removed from .claude/settings.json');
219
+ }
220
+ }
221
+ async function uninstallAgentMdFile(scopes, agents, onLine) {
222
+ if (agents.has('claudeCode')) {
223
+ if (scopes.has('global')) {
224
+ const removed = removeSectionByHeader(join(HOME, '.claude', 'CLAUDE.md'), AGENT_MD_HEADER);
225
+ onLine(removed ? 'CLAUDE.md cleaned (~/.claude/CLAUDE.md)' : 'CLAUDE.md not found (~/.claude/CLAUDE.md)');
226
+ }
227
+ if (scopes.has('project')) {
228
+ const removed = removeSectionByHeader('CLAUDE.md', AGENT_MD_HEADER);
229
+ onLine(removed ? 'CLAUDE.md cleaned (project)' : 'CLAUDE.md not found (project)');
230
+ }
231
+ }
232
+ if (agents.has('openCode')) {
233
+ if (scopes.has('global')) {
234
+ const removed = removeSectionByHeader(join(HOME, '.config', 'opencode', 'AGENTS.md'), AGENT_MD_HEADER);
235
+ onLine(removed ? 'AGENTS.md cleaned (~/.config/opencode/AGENTS.md)' : 'AGENTS.md not found (~/.config/opencode/AGENTS.md)');
236
+ }
237
+ if (scopes.has('project')) {
238
+ const removed = removeSectionByHeader('AGENTS.md', AGENT_MD_HEADER);
239
+ onLine(removed ? 'AGENTS.md cleaned (project)' : 'AGENTS.md not found (project)');
240
+ }
241
+ }
242
+ }
243
+ async function uninstallLsp(langs, agents, onLine) {
244
+ if (agents.has('claudeCode')) {
245
+ patchJson(CLAUDE_SETTINGS, d => {
246
+ const env = d['env'];
247
+ if (env)
248
+ delete env['ENABLE_LSP_TOOL'];
249
+ });
250
+ onLine('ENABLE_LSP_TOOL removed from ~/.claude/settings.json');
251
+ for (const lang of langs) {
252
+ const plugin = lang === 'cpp' ? 'clangd-lsp' : 'swift-lsp';
253
+ onLine(`Removing ${plugin}...`);
254
+ try {
255
+ await stream('claude', ['plugin', 'remove', plugin], onLine);
256
+ onLine(`${plugin} removed`);
257
+ }
258
+ catch {
259
+ onLine(`Warning: ${plugin} removal failed — remove manually`);
260
+ }
261
+ }
262
+ if (langs.has('cpp') && existsSync(AIRC)) {
263
+ const lines = readFileSync(AIRC, 'utf8').split('\n');
264
+ const cleaned = lines.filter(l => !(l.includes('export PATH=') && l.includes('llvm')));
265
+ if (cleaned.length !== lines.length) {
266
+ writeFileSync(AIRC, cleaned.join('\n'));
267
+ onLine('llvm PATH removed from ~/.airc');
268
+ }
269
+ }
270
+ }
271
+ if (agents.has('openCode')) {
272
+ patchJson(OPENCODE_CONFIG, d => { delete d['lsp']; });
273
+ onLine('LSP disabled in OpenCode config');
274
+ }
275
+ }
276
+ async function uninstallOpenspec(scopes, onLine) {
277
+ if (scopes.has('project')) {
278
+ for (const dir of ['openspec', '.claude/commands/opsx']) {
279
+ if (existsSync(dir)) {
280
+ rmSync(dir, { recursive: true, force: true });
281
+ onLine(`${dir}/ removed`);
282
+ }
283
+ }
284
+ const skillsDir = '.claude/skills';
285
+ if (existsSync(skillsDir)) {
286
+ for (const entry of readdirSync(skillsDir)) {
287
+ if (entry.startsWith('openspec-')) {
288
+ rmSync(join(skillsDir, entry), { recursive: true, force: true });
289
+ onLine(`.claude/skills/${entry} removed`);
290
+ }
291
+ }
292
+ }
293
+ }
294
+ if (scopes.has('global')) {
295
+ onLine('Uninstalling @fission-ai/openspec...');
296
+ const npmUninstallFlags = '--prefer-offline --no-audit --no-fund';
297
+ try {
298
+ await nvmShell(`NO_UPDATE_NOTIFIER=1 npm uninstall -g ${npmUninstallFlags} @fission-ai/openspec`, onLine);
299
+ onLine('@fission-ai/openspec uninstalled');
300
+ }
301
+ catch {
302
+ if (commandExists('npm')) {
303
+ await stream('npm', ['uninstall', '-g', '--prefer-offline', '--no-audit', '--no-fund', '@fission-ai/openspec'], onLine, { env: { ...process.env, NO_UPDATE_NOTIFIER: '1' } });
304
+ onLine('@fission-ai/openspec uninstalled');
305
+ }
306
+ else {
307
+ throw new Error('npm not found — uninstall @fission-ai/openspec manually');
308
+ }
309
+ }
310
+ }
311
+ }
312
+ // ── Orchestrator ──────────────────────────────────────────────────────────────
313
+ export async function runUninstall(selection, updateStep) {
314
+ const { features, scopes, agents, lspLanguages } = selection;
315
+ const scopeOk = (feat) => scopes.size === 0 || FEATURE_SCOPES[feat].some(s => scopes.has(s));
316
+ const tasks = [];
317
+ if (features.has('headroom') && scopeOk('headroom'))
318
+ tasks.push(['headroom', () => uninstallHeadroom(l => updateStep('headroom', { line: l }))]);
319
+ if (features.has('stopTelemetry') && scopeOk('stopTelemetry'))
320
+ tasks.push(['stopTelemetry', () => uninstallStopTelemetry(l => updateStep('stopTelemetry', { line: l }))]);
321
+ if (features.has('stopAttributionConfig') && scopeOk('stopAttributionConfig'))
322
+ tasks.push(['stopAttributionConfig', () => uninstallStopAttributionConfig(scopes, l => updateStep('stopAttributionConfig', { line: l }))]);
323
+ if (features.has('stopAttributionHook') && scopeOk('stopAttributionHook'))
324
+ tasks.push(['stopAttributionHook', () => uninstallStopAttributionHook(l => updateStep('stopAttributionHook', { line: l }))]);
325
+ if (features.has('stripCommitAttribution') && scopeOk('stripCommitAttribution'))
326
+ tasks.push(['stripCommitAttribution', () => uninstallStripCommitAttribution(l => updateStep('stripCommitAttribution', { line: l }))]);
327
+ if (features.has('stripPrAttribution') && scopeOk('stripPrAttribution'))
328
+ tasks.push(['stripPrAttribution', () => uninstallStripPrAttribution(l => updateStep('stripPrAttribution', { line: l }))]);
329
+ if (features.has('renameClaudeBranch') && scopeOk('renameClaudeBranch'))
330
+ tasks.push(['renameClaudeBranch', () => uninstallRenameClaudeBranch(l => updateStep('renameClaudeBranch', { line: l }))]);
331
+ if (features.has('agentMdFile') && scopeOk('agentMdFile'))
332
+ tasks.push(['agentMdFile', () => uninstallAgentMdFile(scopes, agents, l => updateStep('agentMdFile', { line: l }))]);
333
+ if (features.has('lsp') && scopeOk('lsp'))
334
+ tasks.push(['lsp', () => uninstallLsp(lspLanguages, agents, l => updateStep('lsp', { line: l }))]);
335
+ if (features.has('openspec') && scopeOk('openspec'))
336
+ tasks.push(['openspec', () => uninstallOpenspec(scopes, l => updateStep('openspec', { line: l }))]);
337
+ for (const [id, fn] of tasks) {
338
+ updateStep(id, { status: 'running' });
339
+ try {
340
+ await fn();
341
+ updateStep(id, { status: 'done' });
342
+ }
343
+ catch (e) {
344
+ updateStep(id, { status: 'error', line: String(e) });
345
+ }
346
+ }
347
+ }
348
+ function buildSteps(sel) {
349
+ const steps = [];
350
+ for (const id of FEATURE_ORDER) {
351
+ if (!sel.features.has(id))
352
+ continue;
353
+ if (sel.scopes.size > 0 && !FEATURE_SCOPES[id].some(s => sel.scopes.has(s)))
354
+ continue;
355
+ steps.push({ id, label: FEATURE_LABELS[id], status: 'pending', lines: [] });
356
+ }
357
+ return steps;
358
+ }
359
+ function validate(sel) {
360
+ if (sel.features.size === 0)
361
+ return 'Nothing selected to uninstall';
362
+ const needsScope = ['stopAttributionConfig', 'stopAttributionHook', 'agentMdFile'];
363
+ const hasScoped = needsScope.some(f => sel.features.has(f));
364
+ if (hasScoped && sel.scopes.size === 0)
365
+ return 'Attribution features require a scope — check Global or Project';
366
+ return null;
367
+ }
368
+ // ── UI components ─────────────────────────────────────────────────────────────
369
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
370
+ function useSpinner() {
371
+ const [i, setI] = useState(0);
372
+ useEffect(() => {
373
+ const t = setInterval(() => setI(n => (n + 1) % SPINNER.length), 80);
374
+ return () => clearInterval(t);
375
+ }, []);
376
+ return SPINNER[i];
377
+ }
378
+ const StatusIcon = ({ status }) => {
379
+ const spin = useSpinner();
380
+ const [icon, color] = (status === 'done' ? ['✓', 'green'] :
381
+ status === 'error' ? ['✗', 'red'] :
382
+ status === 'running' ? [spin, 'yellow'] :
383
+ status === 'skipped' ? ['-', 'gray'] :
384
+ ['○', 'gray']);
385
+ return _jsx(Text, { color: color, children: icon });
386
+ };
387
+ const Checkbox = ({ state, label, focused, indent }) => (_jsxs(Box, { children: [_jsx(Text, { children: focused ? '▸ ' : ' ' }), _jsx(Box, { marginLeft: indent ? 4 : 0, children: _jsxs(Text, { color: focused ? 'yellow' : undefined, children: ["[", state === 'checked' ? '*' : state === 'partial' ? '+' : ' ', "] ", label] }) })] }));
388
+ const PanelBox = ({ title, num, active, items, cursor, isChecked }) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { bold: true, color: active ? 'yellow' : 'white', children: [num, " ", title] }), items.map((item, i) => (_jsx(Checkbox, { state: isChecked(item), label: item.label, focused: active && cursor === i, indent: item.indent }, item.id)))] }));
389
+ // ── SelectScreen ──────────────────────────────────────────────────────────────
390
+ const SelectScreen = ({ initialSel, onConfirm }) => {
391
+ const { exit } = useApp();
392
+ const [panel, setPanel] = useState('features');
393
+ const [cursors, setCursors] = useState({ agents: 0, scope: 0, features: 0, gitSettings: 0 });
394
+ const [sel, setSel] = useState(initialSel);
395
+ const [warning, setWarning] = useState('');
396
+ const featureItems = useMemo(() => getFeatureItems(sel.agents, sel.scopes), [sel.agents, sel.scopes]);
397
+ useEffect(() => {
398
+ setCursors(c => ({
399
+ ...c,
400
+ features: Math.min(c.features, Math.max(0, featureItems.length - 1)),
401
+ }));
402
+ }, [featureItems.length]);
403
+ const panelSize = useCallback((p) => {
404
+ if (p === 'agents')
405
+ return AGENT_IDS.length;
406
+ if (p === 'scope')
407
+ return SCOPE_IDS.length;
408
+ if (p === 'gitSettings')
409
+ return GIT_SETTING_IDS.length;
410
+ return featureItems.length;
411
+ }, [featureItems.length]);
412
+ useInput((input, key) => {
413
+ if (input === 'q') {
414
+ exit();
415
+ return;
416
+ }
417
+ if (input === '1') {
418
+ setPanel('agents');
419
+ setCursors(c => ({ ...c, agents: 0 }));
420
+ return;
421
+ }
422
+ if (input === '2') {
423
+ setPanel('scope');
424
+ setCursors(c => ({ ...c, scope: 0 }));
425
+ return;
426
+ }
427
+ if (input === '3') {
428
+ setPanel('features');
429
+ setCursors(c => ({ ...c, features: 0 }));
430
+ return;
431
+ }
432
+ if (input === '4') {
433
+ setPanel('gitSettings');
434
+ setCursors(c => ({ ...c, gitSettings: 0 }));
435
+ return;
436
+ }
437
+ const PANEL_ORDER = ['agents', 'scope', 'features', 'gitSettings'];
438
+ if (key.upArrow) {
439
+ const currentCursor = cursors[panel];
440
+ const currentSize = panelSize(panel);
441
+ if (currentCursor > 0) {
442
+ setCursors(c => ({ ...c, [panel]: currentCursor - 1 }));
443
+ }
444
+ else {
445
+ const idx = PANEL_ORDER.indexOf(panel);
446
+ const prevPanel = PANEL_ORDER[idx === 0 ? PANEL_ORDER.length - 1 : idx - 1];
447
+ const prevSize = prevPanel === 'agents' ? AGENT_IDS.length :
448
+ prevPanel === 'scope' ? SCOPE_IDS.length :
449
+ featureItems.length;
450
+ setPanel(prevPanel);
451
+ setCursors(c => ({ ...c, [prevPanel]: prevSize - 1 }));
452
+ }
453
+ return;
454
+ }
455
+ if (key.downArrow) {
456
+ const currentCursor = cursors[panel];
457
+ const currentSize = panelSize(panel);
458
+ if (currentCursor < currentSize - 1) {
459
+ setCursors(c => ({ ...c, [panel]: currentCursor + 1 }));
460
+ }
461
+ else {
462
+ const idx = PANEL_ORDER.indexOf(panel);
463
+ const nextPanel = PANEL_ORDER[idx === PANEL_ORDER.length - 1 ? 0 : idx + 1];
464
+ setPanel(nextPanel);
465
+ setCursors(c => ({ ...c, [nextPanel]: 0 }));
466
+ }
467
+ return;
468
+ }
469
+ if (input === ' ') {
470
+ setSel(s => toggleSelection(panel, cursors[panel], s, featureItems));
471
+ setWarning('');
472
+ return;
473
+ }
474
+ if (key.return) {
475
+ const err = validate(sel);
476
+ if (err) {
477
+ setWarning(err);
478
+ return;
479
+ }
480
+ onConfirm(sel);
481
+ }
482
+ });
483
+ const agentItems = AGENT_IDS.map(id => ({ id, label: AGENT_LABELS[id], indent: false }));
484
+ const scopeItems = SCOPE_IDS.map(id => ({ id, label: SCOPE_LABELS[id], indent: false }));
485
+ const gitSettingItems = GIT_SETTING_IDS.map(id => ({ id, label: GIT_SETTING_LABELS[id], indent: false }));
486
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "red", children: "Select what to uninstall" }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(PanelBox, { num: "1", title: "Agents", active: panel === 'agents', items: agentItems, cursor: cursors.agents, isChecked: item => sel.agents.has(item.id) ? 'checked' : 'unchecked' }), _jsx(PanelBox, { num: "2", title: "Scope", active: panel === 'scope', items: scopeItems, cursor: cursors.scope, isChecked: item => sel.scopes.has(item.id) ? 'checked' : 'unchecked' }), _jsx(PanelBox, { num: "3", title: "Features", active: panel === 'features', items: featureItems, cursor: cursors.features, isChecked: item => {
487
+ if (item.id === 'lsp.cpp')
488
+ return sel.lspLanguages.has('cpp') ? 'checked' : 'unchecked';
489
+ if (item.id === 'lsp.swift')
490
+ return sel.lspLanguages.has('swift') ? 'checked' : 'unchecked';
491
+ if (item.id === 'lsp') {
492
+ const hasCpp = sel.lspLanguages.has('cpp');
493
+ const hasSwift = sel.lspLanguages.has('swift');
494
+ if (hasCpp && hasSwift)
495
+ return 'checked';
496
+ if (hasCpp || hasSwift)
497
+ return 'partial';
498
+ return 'unchecked';
499
+ }
500
+ if (item.id === 'stopAttribution.hook')
501
+ return sel.features.has('stopAttributionHook') ? 'checked' : 'unchecked';
502
+ if (item.id === 'stopAttribution.config')
503
+ return sel.features.has('stopAttributionConfig') ? 'checked' : 'unchecked';
504
+ if (item.id === 'stopAttribution.stripCommit')
505
+ return sel.features.has('stripCommitAttribution') ? 'checked' : 'unchecked';
506
+ if (item.id === 'stopAttribution.stripPr')
507
+ return sel.features.has('stripPrAttribution') ? 'checked' : 'unchecked';
508
+ if (item.id === 'stopAttribution.renameBranch')
509
+ return sel.features.has('renameClaudeBranch') ? 'checked' : 'unchecked';
510
+ if (item.id === 'stopAttribution.mdFile')
511
+ return sel.features.has('agentMdFile') ? 'checked' : 'unchecked';
512
+ if (item.id === 'stopAttribution') {
513
+ const subs = [];
514
+ if (featureItems.some(fi => fi.id === 'stopAttribution.hook'))
515
+ subs.push('stopAttributionHook');
516
+ if (featureItems.some(fi => fi.id === 'stopAttribution.config'))
517
+ subs.push('stopAttributionConfig');
518
+ if (featureItems.some(fi => fi.id === 'stopAttribution.stripCommit'))
519
+ subs.push('stripCommitAttribution');
520
+ if (featureItems.some(fi => fi.id === 'stopAttribution.renameBranch'))
521
+ subs.push('renameClaudeBranch');
522
+ if (featureItems.some(fi => fi.id === 'stopAttribution.stripPr'))
523
+ subs.push('stripPrAttribution');
524
+ if (featureItems.some(fi => fi.id === 'stopAttribution.mdFile'))
525
+ subs.push('agentMdFile');
526
+ const count = subs.filter(f => sel.features.has(f)).length;
527
+ if (count === subs.length)
528
+ return 'checked';
529
+ if (count > 0)
530
+ return 'partial';
531
+ return 'unchecked';
532
+ }
533
+ return sel.features.has(item.id) ? 'checked' : 'unchecked';
534
+ } }), _jsx(PanelBox, { num: "4", title: "Git Settings", active: panel === 'gitSettings', items: gitSettingItems, cursor: cursors.gitSettings, isChecked: item => sel.gitSettings.has(item.id) ? 'checked' : 'unchecked' })] }), warning && _jsxs(Text, { color: "red", children: ["\u26A0 ", warning] }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 change selection space toggle enter confirm 1/2/3/4 jump panel q quit" })] }));
535
+ };
536
+ // ── ConfirmScreen ─────────────────────────────────────────────────────────────
537
+ const ConfirmScreen = ({ selection, onConfirm, onBack }) => {
538
+ const { exit } = useApp();
539
+ useInput((input, key) => {
540
+ if (input === 'y' || input === 'Y') {
541
+ onConfirm();
542
+ return;
543
+ }
544
+ if (input === 'n' || input === 'N' || key.escape) {
545
+ onBack();
546
+ return;
547
+ }
548
+ if (input === 'q')
549
+ exit();
550
+ });
551
+ const scopeLabel = [...selection.scopes].map(s => SCOPE_LABELS[s]).join(' + ');
552
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "red", children: "The following will be removed:" }), _jsxs(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: [FEATURE_ORDER.filter(f => selection.features.has(f)).map(f => (_jsxs(Text, { children: [" \u2022 ", FEATURE_LABELS[f]] }, f))), scopeLabel && _jsxs(Text, { dimColor: true, children: [" Scope: ", scopeLabel] })] }), _jsxs(Text, { children: ["Continue? ", _jsx(Text, { bold: true, color: "yellow", children: "[y/N]" })] })] }));
553
+ };
554
+ // ── UninstallScreen ───────────────────────────────────────────────────────────
555
+ const TAIL = 4;
556
+ const UninstallScreen = ({ selection }) => {
557
+ const { exit } = useApp();
558
+ const [steps, setSteps] = useState(() => buildSteps(selection));
559
+ const [finished, setFinished] = useState(false);
560
+ const updateStep = useCallback((id, update) => {
561
+ setSteps(prev => prev.map(s => {
562
+ if (s.id !== id)
563
+ return s;
564
+ return {
565
+ ...s,
566
+ ...(update.status ? { status: update.status } : {}),
567
+ lines: update.line ? [...s.lines, update.line] : s.lines,
568
+ };
569
+ }));
570
+ }, []);
571
+ useEffect(() => {
572
+ runUninstall(selection, updateStep)
573
+ .then(() => { if (selection.gitSettings.has('gitignore'))
574
+ removeGitignoreEntries(); })
575
+ .finally(() => setFinished(true));
576
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
577
+ useEffect(() => { if (finished)
578
+ exit(); }, [finished]); // eslint-disable-line react-hooks/exhaustive-deps
579
+ const allDone = steps.every(s => s.status === 'done' || s.status === 'skipped');
580
+ const hasError = steps.some(s => s.status === 'error');
581
+ const agentList = AGENT_IDS.filter(a => selection.agents.has(a)).map(a => AGENT_LABELS[a]).join(', ');
582
+ const scopeList = SCOPE_IDS.filter(s => selection.scopes.has(s)).map(s => SCOPE_LABELS[s]).join(', ');
583
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, children: "Uninstalling..." }), _jsx(Text, { dimColor: true, children: agentList }), _jsx(Text, { dimColor: true, children: scopeList }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: steps.map(step => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(StatusIcon, { status: step.status }), _jsx(Text, { children: step.label })] }), step.status === 'running' && step.lines.slice(-TAIL).map((l, i) => (_jsxs(Text, { dimColor: true, children: [" ", l] }, i))), step.status === 'error' && step.lines.slice(-TAIL).map((l, i) => (_jsxs(Text, { color: "red", children: [" ", l] }, i)))] }, step.id))) }), finished && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, color: hasError ? 'red' : 'green', children: hasError ? '✗ Completed with errors' : '✓ Done' }) }))] }));
584
+ };
585
+ export const App = () => {
586
+ const [screen, setScreen] = useState('select');
587
+ const [selection, setSelection] = useState(() => detectInstalled());
588
+ if (screen === 'select') {
589
+ return (_jsx(SelectScreen, { initialSel: selection, onConfirm: sel => { setSelection(sel); setScreen('confirm'); } }));
590
+ }
591
+ if (screen === 'confirm') {
592
+ return (_jsx(ConfirmScreen, { selection: selection, onConfirm: () => setScreen('uninstall'), onBack: () => setScreen('select') }));
593
+ }
594
+ return _jsx(UninstallScreen, { selection: selection });
595
+ };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@buffbirb/unclaude",
3
+ "version": "1.0.0",
4
+ "description": "An opinionated AI dev tool setup script with a terminal UI. Configure privacy, code intelligence, and tool wrappers for Claude Code and OpenCode.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "ai",
8
+ "claude",
9
+ "opencode",
10
+ "cli",
11
+ "terminal",
12
+ "setup",
13
+ "developer-tools",
14
+ "privacy",
15
+ "lsp",
16
+ "headroom",
17
+ "openspec"
18
+ ],
19
+ "license": "MIT",
20
+ "engines": {
21
+ "node": ">=18.0.0"
22
+ },
23
+ "bin": {
24
+ "unclaude": "dist/cli.js"
25
+ },
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "scripts": {
30
+ "dev": "tsx src/cli.tsx",
31
+ "build": "tsc",
32
+ "prepublishOnly": "npm run build"
33
+ },
34
+ "dependencies": {
35
+ "commander": "^14.0.3",
36
+ "ink": "^5.0.1",
37
+ "react": "^18.3.1"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^22.0.0",
41
+ "@types/react": "^18.3.1",
42
+ "tsx": "^4.19.0",
43
+ "typescript": "^5.7.0"
44
+ }
45
+ }