@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,688 @@
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, mkdirSync, writeFileSync, chmodSync, readFileSync, appendFileSync } from 'fs';
5
+ import { join } from 'path';
6
+ import { HOME, AIRC, ZSHRC, CLAUDE_SETTINGS, OPENCODE_CONFIG, HCLAUDE, HOPENCODE, AGENT_IDS, SCOPE_IDS, GIT_SETTING_IDS, FEATURE_ORDER, AGENT_LABELS, SCOPE_LABELS, GIT_SETTING_LABELS, FEATURE_LABELS, defaultSelection, getFeatureItems, toggleSelection, addGitignoreEntries, removeGitignoreEntries, run, stream, commandExists, isGitRepo, nvmShell, canWrite, patchJson, ensureFile, appendIfMissing, writeExecutable, HEADROOM_START_FUNC, HCLAUDE_CONTENT, HOPENCODE_CONTENT, BASH_COMMIT_MSG_HOOK, STRIP_PR_WORKFLOW, addSessionStartHook, FEATURE_SCOPES, AGENT_MD_HEADER, removeSectionByHeader, } from './common.js';
7
+ import { runUninstall } from './uninstall.js';
8
+ const FORM_WIDTH = 55;
9
+ // ── Install functions ────────────────────────────────────────────────────────
10
+ async function installHeadroom(onLine) {
11
+ if (!commandExists('uv')) {
12
+ onLine('Installing uv...');
13
+ await stream('sh', ['-c', 'curl -LsSf https://astral.sh/uv/install.sh | sh'], onLine);
14
+ process.env['PATH'] = `${HOME}/.local/bin:${process.env['PATH']}`;
15
+ }
16
+ else {
17
+ onLine('uv already installed');
18
+ }
19
+ if (!commandExists('headroom')) {
20
+ onLine('Installing headroom-ai...');
21
+ await stream('uv', ['tool', 'install', 'headroom-ai[all]'], onLine);
22
+ }
23
+ else {
24
+ onLine('headroom-ai already installed');
25
+ }
26
+ ensureFile(AIRC, '# ~/.airc - AI tool configuration\n');
27
+ appendIfMissing(AIRC, 'headroom_start', HEADROOM_START_FUNC);
28
+ onLine('~/.airc updated');
29
+ for (const [path, content] of [[HCLAUDE, HCLAUDE_CONTENT], [HOPENCODE, HOPENCODE_CONTENT]]) {
30
+ if (!existsSync(path)) {
31
+ await writeExecutable(path, content, onLine);
32
+ onLine(`${path} created`);
33
+ }
34
+ else {
35
+ onLine(`${path} already exists`);
36
+ }
37
+ }
38
+ appendIfMissing(ZSHRC, '[ -f ~/.airc ]', '\n[ -f ~/.airc ] && source ~/.airc\n');
39
+ onLine('~/.zshrc updated');
40
+ }
41
+ async function installStopTelemetry(onLine) {
42
+ const aircExists = existsSync(AIRC);
43
+ if (aircExists && !canWrite(AIRC)) {
44
+ throw new Error(`~/.airc exists but is not writable by you. Fix ownership and re-run:\n` +
45
+ ` sudo chown "$USER" ~/.airc`);
46
+ }
47
+ if (aircExists) {
48
+ const lines = readFileSync(AIRC, 'utf8').split('\n');
49
+ const cleaned = lines.filter(l => !l.includes('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'));
50
+ if (cleaned.length !== lines.length) {
51
+ writeFileSync(AIRC, cleaned.join('\n'));
52
+ onLine('Removed old CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC from ~/.airc');
53
+ }
54
+ }
55
+ ensureFile(AIRC, '# ~/.airc - AI tool configuration\n');
56
+ appendFileSync(AIRC, '\nexport CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1\n');
57
+ onLine('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC added to ~/.airc');
58
+ if (canWrite(ZSHRC) || !existsSync(ZSHRC)) {
59
+ appendIfMissing(ZSHRC, '[ -f ~/.airc ]', '\n[ -f ~/.airc ] && source ~/.airc\n');
60
+ }
61
+ }
62
+ async function installStopAttributionConfig(scopes, onLine) {
63
+ if (scopes.has('global')) {
64
+ patchJson(CLAUDE_SETTINGS, d => { d['attribution'] = { commit: '', pr: '' }; });
65
+ onLine('attribution config set in ~/.claude/settings.json');
66
+ }
67
+ if (scopes.has('project')) {
68
+ patchJson('.claude/settings.json', d => { d['attribution'] = { commit: '', pr: '' }; });
69
+ onLine('attribution config set in .claude/settings.json');
70
+ }
71
+ }
72
+ async function installStopAttributionHook(onLine) {
73
+ const writeHook = (hookPath) => {
74
+ mkdirSync(join(hookPath, '..'), { recursive: true });
75
+ writeFileSync(hookPath, BASH_COMMIT_MSG_HOOK);
76
+ chmodSync(hookPath, 0o755);
77
+ };
78
+ const gitDir = await run('git', ['rev-parse', '--git-dir']);
79
+ const hooksDir = join(gitDir, 'hooks');
80
+ mkdirSync(hooksDir, { recursive: true });
81
+ writeHook(join(hooksDir, 'commit-msg'));
82
+ onLine('Project git commit-msg hook installed');
83
+ }
84
+ async function installStripCommitAttribution(gitUser, gitEmail, personalize, onLine) {
85
+ const name = personalize ? gitUser : '';
86
+ const email = personalize ? gitEmail : '';
87
+ const hookScript = [
88
+ '#!/bin/bash',
89
+ ...(name ? [`git config user.name "${name}"`] : []),
90
+ ...(email ? [`git config user.email "${email}"`] : []),
91
+ 'mkdir -p .git/hooks',
92
+ `cat > .git/hooks/commit-msg << 'CMHOOK'\n${BASH_COMMIT_MSG_HOOK.trimEnd()}\nCMHOOK`,
93
+ 'chmod +x .git/hooks/commit-msg',
94
+ 'chmod +x .claude/hooks/*.sh 2>/dev/null || true',
95
+ ].join('\n') + '\n';
96
+ mkdirSync('.claude/hooks', { recursive: true });
97
+ writeFileSync('.claude/hooks/strip-commit-attribution.sh', hookScript);
98
+ chmodSync('.claude/hooks/strip-commit-attribution.sh', 0o755);
99
+ onLine('.claude/hooks/strip-commit-attribution.sh written');
100
+ addSessionStartHook('.claude/settings.json', 'bash .claude/hooks/strip-commit-attribution.sh');
101
+ onLine('SessionStart hook registered in .claude/settings.json');
102
+ }
103
+ async function installStripPrAttribution(onLine) {
104
+ mkdirSync('.github/workflows', { recursive: true });
105
+ writeFileSync('.github/workflows/strip-pr-attribution.yml', STRIP_PR_WORKFLOW);
106
+ onLine('.github/workflows/strip-pr-attribution.yml written');
107
+ }
108
+ async function installRenameClaudeBranch(prefix, onLine) {
109
+ const d = '$';
110
+ const newExpr = prefix ? `"${prefix}/${d}suffix"` : `"${d}suffix"`;
111
+ const hookScript = `#!/bin/bash
112
+ branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) || exit 0
113
+ if [[ "$branch" != claude/* && "$branch" != claude-* ]]; then exit 0; fi
114
+ suffix="${d}{branch#claude/}"
115
+ suffix="${d}{suffix#claude-}"
116
+ new=${newExpr}
117
+ git branch -m "$branch" "$new"
118
+ git push origin -u "$new" 2>/dev/null || true
119
+ git push origin --delete "$branch" 2>/dev/null || true
120
+ echo "Renamed $branch → $new"
121
+ `;
122
+ mkdirSync('.claude/hooks', { recursive: true });
123
+ writeFileSync('.claude/hooks/strip-claude-branch.sh', hookScript);
124
+ chmodSync('.claude/hooks/strip-claude-branch.sh', 0o755);
125
+ onLine('.claude/hooks/strip-claude-branch.sh written');
126
+ addSessionStartHook('.claude/settings.json', 'bash .claude/hooks/strip-claude-branch.sh');
127
+ onLine('SessionStart hook registered in .claude/settings.json');
128
+ }
129
+ async function installAgentMdFile(scopes, agents, gitUser, gitEmail, personalize, onLine) {
130
+ const name = personalize ? gitUser : '';
131
+ const email = personalize ? gitEmail : '';
132
+ const authorLine = name && email ? `- Git author/email is \`${name} <${email}>\`; do not change it.` : '';
133
+ const claudeContent = [
134
+ AGENT_MD_HEADER,
135
+ '',
136
+ '- Never add `Co-Authored-By:` trailers, session URLs (`https://claude.ai/code/session_...`), or any other Claude attribution to commit messages.',
137
+ '- Never add "Generated with Claude", session URLs, or any other Claude attribution to PR bodies.',
138
+ '- Do not mention Claude Code, the assistant, or the session in commit messages or PR descriptions. Describe the change as if a human authored it.',
139
+ ...(authorLine ? [authorLine] : []),
140
+ '- Branch names must not start with `claude/` or `claude-`.',
141
+ ].join('\n') + '\n';
142
+ const openCodeContent = [
143
+ AGENT_MD_HEADER,
144
+ '',
145
+ '- Never add `Co-Authored-By:` trailers or any other attribution markers to commit messages.',
146
+ '- Never add AI-generated notices or attribution to PR bodies.',
147
+ '- Describe changes as if a human authored them. Do not mention AI tools, assistants, or code generators in commit messages or PR descriptions.',
148
+ ...(authorLine ? [authorLine] : []),
149
+ ].join('\n') + '\n';
150
+ if (agents.has('claudeCode')) {
151
+ if (scopes.has('global')) {
152
+ const path = join(HOME, '.claude', 'CLAUDE.md');
153
+ removeSectionByHeader(path, AGENT_MD_HEADER);
154
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
155
+ const sep = existing.length > 0 && !existing.endsWith('\n') ? '\n\n' : (existing.endsWith('\n') ? '\n' : '');
156
+ writeFileSync(path, existing + sep + claudeContent + '\n');
157
+ onLine('CLAUDE.md updated (~/.claude/CLAUDE.md)');
158
+ }
159
+ if (scopes.has('project')) {
160
+ removeSectionByHeader('CLAUDE.md', AGENT_MD_HEADER);
161
+ const existing = existsSync('CLAUDE.md') ? readFileSync('CLAUDE.md', 'utf8') : '';
162
+ const sep = existing.length > 0 && !existing.endsWith('\n') ? '\n\n' : (existing.endsWith('\n') ? '\n' : '');
163
+ writeFileSync('CLAUDE.md', existing + sep + claudeContent + '\n');
164
+ onLine('CLAUDE.md updated (project)');
165
+ }
166
+ }
167
+ if (agents.has('openCode')) {
168
+ if (scopes.has('global')) {
169
+ const path = join(HOME, '.config', 'opencode', 'AGENTS.md');
170
+ removeSectionByHeader(path, AGENT_MD_HEADER);
171
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
172
+ const sep = existing.length > 0 && !existing.endsWith('\n') ? '\n\n' : (existing.endsWith('\n') ? '\n' : '');
173
+ writeFileSync(path, existing + sep + openCodeContent + '\n');
174
+ onLine('AGENTS.md updated (~/.config/opencode/AGENTS.md)');
175
+ }
176
+ if (scopes.has('project')) {
177
+ removeSectionByHeader('AGENTS.md', AGENT_MD_HEADER);
178
+ const existing = existsSync('AGENTS.md') ? readFileSync('AGENTS.md', 'utf8') : '';
179
+ const sep = existing.length > 0 && !existing.endsWith('\n') ? '\n\n' : (existing.endsWith('\n') ? '\n' : '');
180
+ writeFileSync('AGENTS.md', existing + sep + openCodeContent + '\n');
181
+ onLine('AGENTS.md updated (project)');
182
+ }
183
+ }
184
+ }
185
+ async function installLsp(langs, agents, onLine) {
186
+ if (agents.has('claudeCode')) {
187
+ patchJson(CLAUDE_SETTINGS, d => {
188
+ if (typeof d['env'] !== 'object' || !d['env'])
189
+ d['env'] = {};
190
+ d['env']['ENABLE_LSP_TOOL'] = '1';
191
+ });
192
+ onLine('ENABLE_LSP_TOOL set in ~/.claude/settings.json');
193
+ try {
194
+ await stream('claude', ['plugin', 'marketplace', 'update', 'claude-plugins-official'], onLine);
195
+ }
196
+ catch { /* non-fatal */ }
197
+ for (const lang of langs) {
198
+ const plugin = lang === 'cpp' ? 'clangd-lsp' : 'swift-lsp';
199
+ onLine(`Installing ${plugin}...`);
200
+ try {
201
+ await stream('claude', ['plugin', 'install', `${plugin}@claude-plugins-official`], onLine);
202
+ onLine(`${plugin} installed`);
203
+ }
204
+ catch {
205
+ onLine(`Warning: ${plugin} install failed — retry manually`);
206
+ }
207
+ }
208
+ if (langs.has('cpp') && !commandExists('clangd')) {
209
+ onLine('Installing llvm via brew...');
210
+ await stream('brew', ['install', 'llvm'], onLine);
211
+ const llvmBin = (await run('brew', ['--prefix', 'llvm'])) + '/bin';
212
+ appendIfMissing(AIRC, llvmBin, `\nexport PATH="${llvmBin}:$PATH"\n`);
213
+ onLine('clangd installed');
214
+ }
215
+ }
216
+ if (agents.has('openCode')) {
217
+ patchJson(OPENCODE_CONFIG, d => { d['lsp'] = true; });
218
+ onLine('LSP enabled in OpenCode config');
219
+ }
220
+ }
221
+ async function installOpenspec(scopes, onLine) {
222
+ if (commandExists('openspec')) {
223
+ onLine('@fission-ai/openspec already installed');
224
+ }
225
+ else {
226
+ const nvmDir = join(HOME, '.nvm');
227
+ if (!existsSync(nvmDir)) {
228
+ onLine('Installing nvm...');
229
+ await stream('sh', ['-c', 'curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash'], onLine);
230
+ }
231
+ onLine('Ensuring Node LTS...');
232
+ try {
233
+ await nvmShell('nvm install --lts', onLine);
234
+ }
235
+ catch {
236
+ onLine('Warning: Node install issue');
237
+ }
238
+ onLine('Installing @fission-ai/openspec...');
239
+ await nvmShell('npm install -g @fission-ai/openspec@latest', onLine);
240
+ onLine('@fission-ai/openspec installed');
241
+ }
242
+ if (scopes.has('project')) {
243
+ onLine('Running openspec init...');
244
+ try {
245
+ await nvmShell('openspec init', onLine);
246
+ }
247
+ catch {
248
+ onLine('Warning: openspec init failed — run manually');
249
+ }
250
+ }
251
+ }
252
+ export async function runInstall(selection, formState, updateStep) {
253
+ const { features, scopes, agents, lspLanguages } = selection;
254
+ const { personalize, gitUser, gitEmail, branchPrefix } = formState;
255
+ const scopeOk = (feat) => scopes.size === 0 || FEATURE_SCOPES[feat].some(s => scopes.has(s));
256
+ const tasks = [];
257
+ if (features.has('headroom') && scopeOk('headroom'))
258
+ tasks.push(['headroom', () => installHeadroom(l => updateStep('headroom', { line: l }))]);
259
+ if (features.has('stopTelemetry') && scopeOk('stopTelemetry'))
260
+ tasks.push(['stopTelemetry', () => installStopTelemetry(l => updateStep('stopTelemetry', { line: l }))]);
261
+ if (features.has('stopAttributionConfig') && scopeOk('stopAttributionConfig'))
262
+ tasks.push(['stopAttributionConfig', () => installStopAttributionConfig(scopes, l => updateStep('stopAttributionConfig', { line: l }))]);
263
+ if (features.has('stopAttributionHook') && scopeOk('stopAttributionHook'))
264
+ tasks.push(['stopAttributionHook', () => installStopAttributionHook(l => updateStep('stopAttributionHook', { line: l }))]);
265
+ if (features.has('stripCommitAttribution') && scopeOk('stripCommitAttribution'))
266
+ tasks.push(['stripCommitAttribution', () => installStripCommitAttribution(gitUser, gitEmail, personalize, l => updateStep('stripCommitAttribution', { line: l }))]);
267
+ if (features.has('stripPrAttribution') && scopeOk('stripPrAttribution'))
268
+ tasks.push(['stripPrAttribution', () => installStripPrAttribution(l => updateStep('stripPrAttribution', { line: l }))]);
269
+ if (features.has('renameClaudeBranch') && scopeOk('renameClaudeBranch'))
270
+ tasks.push(['renameClaudeBranch', () => installRenameClaudeBranch(branchPrefix, l => updateStep('renameClaudeBranch', { line: l }))]);
271
+ if (features.has('agentMdFile') && scopeOk('agentMdFile'))
272
+ tasks.push(['agentMdFile', () => installAgentMdFile(scopes, agents, gitUser, gitEmail, personalize, l => updateStep('agentMdFile', { line: l }))]);
273
+ if (features.has('lsp') && scopeOk('lsp'))
274
+ tasks.push(['lsp', () => installLsp(lspLanguages, agents, l => updateStep('lsp', { line: l }))]);
275
+ if (features.has('openspec') && scopeOk('openspec'))
276
+ tasks.push(['openspec', () => installOpenspec(scopes, l => updateStep('openspec', { line: l }))]);
277
+ for (const [id, fn] of tasks) {
278
+ updateStep(id, { status: 'running' });
279
+ try {
280
+ await fn();
281
+ updateStep(id, { status: 'done' });
282
+ }
283
+ catch (e) {
284
+ updateStep(id, { status: 'error', line: String(e) });
285
+ }
286
+ }
287
+ }
288
+ function buildSteps(sel) {
289
+ const steps = [];
290
+ for (const id of FEATURE_ORDER) {
291
+ if (!sel.features.has(id))
292
+ continue;
293
+ if (sel.scopes.size > 0 && !FEATURE_SCOPES[id].some(s => sel.scopes.has(s)))
294
+ continue;
295
+ steps.push({ id, label: FEATURE_LABELS[id], status: 'pending', lines: [] });
296
+ }
297
+ return steps;
298
+ }
299
+ function validate(sel) {
300
+ const needsScope = ['stopAttributionConfig', 'stopAttributionHook', 'agentMdFile', 'openspec'];
301
+ const hasScoped = needsScope.some(f => sel.features.has(f));
302
+ if (hasScoped && sel.scopes.size === 0)
303
+ return 'Attribution and OpenSpec require a scope — check Global or Project';
304
+ return null;
305
+ }
306
+ // ── UI components ────────────────────────────────────────────────────────────
307
+ const IN_GIT_REPO = isGitRepo();
308
+ function isDisabledAt(p, idx) {
309
+ if (p !== 'scope')
310
+ return false;
311
+ return SCOPE_IDS[idx] === 'project' && !IN_GIT_REPO;
312
+ }
313
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
314
+ function useSpinner() {
315
+ const [i, setI] = useState(0);
316
+ useEffect(() => {
317
+ const t = setInterval(() => setI(n => (n + 1) % SPINNER.length), 80);
318
+ return () => clearInterval(t);
319
+ }, []);
320
+ return SPINNER[i];
321
+ }
322
+ const StatusIcon = ({ status }) => {
323
+ const spin = useSpinner();
324
+ const [icon, color] = (status === 'done' ? ['✓', 'green'] :
325
+ status === 'error' ? ['✗', 'red'] :
326
+ status === 'running' ? [spin, 'yellow'] :
327
+ status === 'skipped' ? ['-', 'gray'] :
328
+ ['○', 'gray']);
329
+ return _jsx(Text, { color: color, children: icon });
330
+ };
331
+ const Checkbox = ({ state, label, focused, indent, disabled, note, focusColor = 'yellow' }) => {
332
+ const arrow = focused && !disabled ? '▸ ' : ' ';
333
+ const indentStr = indent ? ' ' : '';
334
+ const box = disabled ? ' ' : state === 'checked' ? '*' : state === 'partial' ? '+' : ' ';
335
+ return (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { dimColor: disabled, children: [arrow, indentStr] }), _jsxs(Text, { color: disabled ? undefined : (focused ? focusColor : undefined), dimColor: disabled, strikethrough: disabled, children: ["[", box, "] ", label] }), note && _jsxs(Text, { dimColor: true, children: [" ", note] })] }));
336
+ };
337
+ const PanelBox = ({ title, num, active, items, cursor, isChecked, isDisabled, noteFor, focusColor = 'yellow' }) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { bold: true, color: active ? focusColor : 'white', children: [num, " ", title] }), items.map((item, i) => (_jsx(Checkbox, { state: isChecked(item), label: item.label, focused: active && cursor === i, indent: item.indent, disabled: isDisabled?.(item), note: noteFor?.(item), focusColor: focusColor }, item.id)))] }));
338
+ const SelectScreen = ({ onConfirm }) => {
339
+ const { exit } = useApp();
340
+ const [panel, setPanel] = useState('agents');
341
+ const [cursors, setCursors] = useState({ agents: 0, scope: 0, features: 0, gitSettings: 0 });
342
+ const [sel, setSel] = useState(() => {
343
+ const s = defaultSelection();
344
+ if (!IN_GIT_REPO)
345
+ s.scopes.delete('project');
346
+ return s;
347
+ });
348
+ const [warning, setWarning] = useState('');
349
+ const [mode, setMode] = useState('install');
350
+ const focusColor = mode === 'install' ? 'yellow' : 'blue';
351
+ const featureItems = useMemo(() => getFeatureItems(sel.agents, sel.scopes), [sel.agents, sel.scopes]);
352
+ // clamp features cursor when items change (e.g. claudeCode toggled)
353
+ useEffect(() => {
354
+ setCursors(c => ({
355
+ ...c,
356
+ features: Math.min(c.features, Math.max(0, featureItems.length - 1)),
357
+ }));
358
+ }, [featureItems.length]);
359
+ const panelSize = useCallback((p) => {
360
+ if (p === 'agents')
361
+ return AGENT_IDS.length;
362
+ if (p === 'scope')
363
+ return SCOPE_IDS.length;
364
+ if (p === 'gitSettings')
365
+ return GIT_SETTING_IDS.length;
366
+ return featureItems.length;
367
+ }, [featureItems.length]);
368
+ useInput((input, key) => {
369
+ if (key.tab) {
370
+ setMode(m => m === 'install' ? 'uninstall' : 'install');
371
+ setWarning('');
372
+ return;
373
+ }
374
+ if (key.escape) {
375
+ exit();
376
+ return;
377
+ }
378
+ if (input === '1') {
379
+ setPanel('agents');
380
+ setCursors(c => ({ ...c, agents: 0 }));
381
+ return;
382
+ }
383
+ if (input === '2') {
384
+ setPanel('scope');
385
+ setCursors(c => ({ ...c, scope: 0 }));
386
+ return;
387
+ }
388
+ if (input === '3') {
389
+ setPanel('features');
390
+ setCursors(c => ({ ...c, features: 0 }));
391
+ return;
392
+ }
393
+ if (input === '4') {
394
+ setPanel('gitSettings');
395
+ setCursors(c => ({ ...c, gitSettings: 0 }));
396
+ return;
397
+ }
398
+ const PANEL_ORDER = ['agents', 'scope', 'features', 'gitSettings'];
399
+ const sizeOf = (p) => p === 'agents' ? AGENT_IDS.length :
400
+ p === 'scope' ? SCOPE_IDS.length :
401
+ p === 'gitSettings' ? GIT_SETTING_IDS.length :
402
+ featureItems.length;
403
+ // Returns first non-disabled index scanning in direction from start, or null if none found.
404
+ const firstEnabled = (p, start, dir) => {
405
+ const size = sizeOf(p);
406
+ for (let i = start; i >= 0 && i < size; i += dir) {
407
+ if (isDisabledAt(p, i))
408
+ continue;
409
+ return i;
410
+ }
411
+ return null;
412
+ };
413
+ if (key.upArrow) {
414
+ const next = firstEnabled(panel, cursors[panel] - 1, -1);
415
+ if (next !== null) {
416
+ setCursors(c => ({ ...c, [panel]: next }));
417
+ }
418
+ else {
419
+ const idx = PANEL_ORDER.indexOf(panel);
420
+ const prevPanel = PANEL_ORDER[idx === 0 ? PANEL_ORDER.length - 1 : idx - 1];
421
+ const pos = firstEnabled(prevPanel, sizeOf(prevPanel) - 1, -1) ?? 0;
422
+ setPanel(prevPanel);
423
+ setCursors(c => ({ ...c, [prevPanel]: pos }));
424
+ }
425
+ return;
426
+ }
427
+ if (key.downArrow) {
428
+ const next = firstEnabled(panel, cursors[panel] + 1, 1);
429
+ if (next !== null) {
430
+ setCursors(c => ({ ...c, [panel]: next }));
431
+ }
432
+ else {
433
+ const idx = PANEL_ORDER.indexOf(panel);
434
+ const nextPanel = PANEL_ORDER[idx === PANEL_ORDER.length - 1 ? 0 : idx + 1];
435
+ const pos = firstEnabled(nextPanel, 0, 1) ?? 0;
436
+ setPanel(nextPanel);
437
+ setCursors(c => ({ ...c, [nextPanel]: pos }));
438
+ }
439
+ return;
440
+ }
441
+ if (input === ' ') {
442
+ if (panel === 'scope' && SCOPE_IDS[cursors.scope] === 'project' && !IN_GIT_REPO)
443
+ return;
444
+ setSel(s => toggleSelection(panel, cursors[panel], s, featureItems));
445
+ setWarning('');
446
+ return;
447
+ }
448
+ if (key.return) {
449
+ const err = validate(sel);
450
+ if (err) {
451
+ setWarning(err);
452
+ return;
453
+ }
454
+ onConfirm(sel, mode);
455
+ }
456
+ });
457
+ const agentItems = AGENT_IDS.map(id => ({ id, label: AGENT_LABELS[id], indent: false }));
458
+ const scopeItems = SCOPE_IDS.map(id => ({ id, label: SCOPE_LABELS[id], indent: false }));
459
+ const gitSettingItems = GIT_SETTING_IDS.map(id => ({ id, label: GIT_SETTING_LABELS[id], indent: false }));
460
+ return (_jsxs(Box, { flexDirection: "column", alignItems: "center", paddingY: 2, width: "100%", children: [_jsxs(Box, { flexDirection: "column", alignItems: "center", marginBottom: 1, children: [_jsx(Text, { bold: true, children: "unclaude \u2014 AI dev tool setup" }), _jsx(Text, { dimColor: true, children: "Configure privacy, code intelligence, and tool wrappers for multiple agents." })] }), _jsxs(Box, { width: FORM_WIDTH, flexDirection: "column", children: [_jsx(PanelBox, { num: "1", title: "Agents", active: panel === 'agents', items: agentItems, cursor: cursors.agents, isChecked: item => sel.agents.has(item.id) ? 'checked' : 'unchecked', focusColor: focusColor }), _jsx(PanelBox, { num: "2", title: "Scope", active: panel === 'scope', items: scopeItems, cursor: cursors.scope, isChecked: item => sel.scopes.has(item.id) ? 'checked' : 'unchecked', isDisabled: item => item.id === 'project' && !IN_GIT_REPO, noteFor: item => item.id === 'project' && !IN_GIT_REPO ? '(.git not found)' : undefined, focusColor: focusColor }), _jsx(PanelBox, { num: "3", title: "Features", active: panel === 'features', items: featureItems, cursor: cursors.features, focusColor: focusColor, isChecked: item => {
461
+ if (item.id === 'lsp.cpp')
462
+ return sel.lspLanguages.has('cpp') ? 'checked' : 'unchecked';
463
+ if (item.id === 'lsp.swift')
464
+ return sel.lspLanguages.has('swift') ? 'checked' : 'unchecked';
465
+ if (item.id === 'lsp') {
466
+ const hasCpp = sel.lspLanguages.has('cpp');
467
+ const hasSwift = sel.lspLanguages.has('swift');
468
+ if (hasCpp && hasSwift)
469
+ return 'checked';
470
+ if (hasCpp || hasSwift)
471
+ return 'partial';
472
+ return 'unchecked';
473
+ }
474
+ if (item.id === 'stopAttribution.hook')
475
+ return sel.features.has('stopAttributionHook') ? 'checked' : 'unchecked';
476
+ if (item.id === 'stopAttribution.config')
477
+ return sel.features.has('stopAttributionConfig') ? 'checked' : 'unchecked';
478
+ if (item.id === 'stopAttribution.stripCommit')
479
+ return sel.features.has('stripCommitAttribution') ? 'checked' : 'unchecked';
480
+ if (item.id === 'stopAttribution.stripPr')
481
+ return sel.features.has('stripPrAttribution') ? 'checked' : 'unchecked';
482
+ if (item.id === 'stopAttribution.renameBranch')
483
+ return sel.features.has('renameClaudeBranch') ? 'checked' : 'unchecked';
484
+ if (item.id === 'stopAttribution.mdFile')
485
+ return sel.features.has('agentMdFile') ? 'checked' : 'unchecked';
486
+ if (item.id === 'stopAttribution') {
487
+ const subs = [];
488
+ if (featureItems.some(fi => fi.id === 'stopAttribution.hook'))
489
+ subs.push('stopAttributionHook');
490
+ if (featureItems.some(fi => fi.id === 'stopAttribution.config'))
491
+ subs.push('stopAttributionConfig');
492
+ if (featureItems.some(fi => fi.id === 'stopAttribution.stripCommit'))
493
+ subs.push('stripCommitAttribution');
494
+ if (featureItems.some(fi => fi.id === 'stopAttribution.renameBranch'))
495
+ subs.push('renameClaudeBranch');
496
+ if (featureItems.some(fi => fi.id === 'stopAttribution.stripPr'))
497
+ subs.push('stripPrAttribution');
498
+ if (featureItems.some(fi => fi.id === 'stopAttribution.mdFile'))
499
+ subs.push('agentMdFile');
500
+ const count = subs.filter(f => sel.features.has(f)).length;
501
+ if (count === subs.length)
502
+ return 'checked';
503
+ if (count > 0)
504
+ return 'partial';
505
+ return 'unchecked';
506
+ }
507
+ return sel.features.has(item.id) ? 'checked' : 'unchecked';
508
+ } }), _jsx(PanelBox, { num: "4", title: "Git Settings", active: panel === 'gitSettings', items: gitSettingItems, cursor: cursors.gitSettings, focusColor: focusColor, isChecked: item => sel.gitSettings.has(item.id) ? 'checked' : 'unchecked' }), warning && _jsxs(Text, { color: "red", children: ["\u26A0 ", warning] })] }), _jsx(Box, { marginTop: 1, alignItems: "center", children: _jsxs(Text, { dimColor: true, children: ["\u2191\u2193 navigate space toggle enter ", mode, " tab mode 1/2/3/4 jump panel esc quit"] }) })] }));
509
+ };
510
+ // ── InstallScreen ─────────────────────────────────────────────────────────────
511
+ const TAIL = 4; // output lines shown per running step
512
+ const InstallScreen = ({ selection, formState }) => {
513
+ const { exit } = useApp();
514
+ const [steps, setSteps] = useState(() => buildSteps(selection));
515
+ const [finished, setFinished] = useState(false);
516
+ const updateStep = useCallback((id, update) => {
517
+ setSteps(prev => prev.map(s => {
518
+ if (s.id !== id)
519
+ return s;
520
+ return {
521
+ ...s,
522
+ ...(update.status ? { status: update.status } : {}),
523
+ lines: update.line ? [...s.lines, update.line] : s.lines,
524
+ };
525
+ }));
526
+ }, []);
527
+ useEffect(() => {
528
+ runInstall(selection, formState, updateStep)
529
+ .then(() => { if (selection.gitSettings.has('gitignore'))
530
+ addGitignoreEntries(selection.features); })
531
+ .finally(() => setFinished(true));
532
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
533
+ useEffect(() => { if (finished)
534
+ exit(); }, [finished]); // eslint-disable-line react-hooks/exhaustive-deps
535
+ const allDone = steps.every(s => s.status === 'done' || s.status === 'skipped');
536
+ const hasError = steps.some(s => s.status === 'error');
537
+ const agentList = AGENT_IDS.filter(a => selection.agents.has(a)).map(a => AGENT_LABELS[a]).join(', ');
538
+ const scopeList = SCOPE_IDS.filter(s => selection.scopes.has(s)).map(s => SCOPE_LABELS[s]).join(', ');
539
+ const gitignoring = selection.gitSettings.has('gitignore');
540
+ return (_jsxs(Box, { flexDirection: "column", alignItems: "center", paddingY: 2, width: "100%", children: [_jsxs(Box, { flexDirection: "column", alignItems: "center", children: [_jsx(Text, { bold: true, children: "Installing..." }), _jsx(Text, { dimColor: true, children: agentList }), _jsx(Text, { dimColor: true, children: scopeList }), gitignoring && _jsx(Text, { dimColor: true, children: ".gitignore" })] }), _jsx(Box, { width: FORM_WIDTH, 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 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", alignItems: "center", children: [_jsx(Text, { bold: true, color: hasError ? 'red' : 'green', children: hasError ? '✗ Completed with errors' : '✓ Done' }), selection.features.has('stopTelemetry') && (_jsx(Text, { dimColor: true, children: "Open a new shell (or: . ~/.airc) to pick up the telemetry export." }))] }))] }));
541
+ };
542
+ // ── UninstallScreen ───────────────────────────────────────────────────────────
543
+ const UninstallScreen = ({ selection }) => {
544
+ const { exit } = useApp();
545
+ const [steps, setSteps] = useState(() => buildSteps(selection));
546
+ const [finished, setFinished] = useState(false);
547
+ const updateStep = useCallback((id, update) => {
548
+ setSteps(prev => prev.map(s => {
549
+ if (s.id !== id)
550
+ return s;
551
+ return {
552
+ ...s,
553
+ ...(update.status ? { status: update.status } : {}),
554
+ lines: update.line ? [...s.lines, update.line] : s.lines,
555
+ };
556
+ }));
557
+ }, []);
558
+ useEffect(() => {
559
+ runUninstall(selection, updateStep)
560
+ .then(() => { if (selection.gitSettings.has('gitignore'))
561
+ removeGitignoreEntries(); })
562
+ .finally(() => setFinished(true));
563
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
564
+ useEffect(() => { if (finished)
565
+ exit(); }, [finished]); // eslint-disable-line react-hooks/exhaustive-deps
566
+ const hasError = steps.some(s => s.status === 'error');
567
+ const agentList = AGENT_IDS.filter(a => selection.agents.has(a)).map(a => AGENT_LABELS[a]).join(', ');
568
+ const scopeList = SCOPE_IDS.filter(s => selection.scopes.has(s)).map(s => SCOPE_LABELS[s]).join(', ');
569
+ const gitignoring = selection.gitSettings.has('gitignore');
570
+ return (_jsxs(Box, { flexDirection: "column", alignItems: "center", paddingY: 2, width: "100%", children: [_jsxs(Box, { flexDirection: "column", alignItems: "center", children: [_jsx(Text, { bold: true, children: "Uninstalling..." }), _jsx(Text, { dimColor: true, children: agentList }), _jsx(Text, { dimColor: true, children: scopeList }), gitignoring && _jsx(Text, { dimColor: true, children: ".gitignore" })] }), _jsx(Box, { width: FORM_WIDTH, 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, alignItems: "center", children: _jsx(Text, { bold: true, color: hasError ? 'red' : 'green', children: hasError ? '✗ Completed with errors' : '✓ Done' }) }))] }));
571
+ };
572
+ const FormScreen = ({ initialValues, onSubmit, onCancel }) => {
573
+ const [personalize, setPersonalize] = useState(initialValues.personalize);
574
+ const [gitUser, setGitUser] = useState(initialValues.gitUser);
575
+ const [gitEmail, setGitEmail] = useState(initialValues.gitEmail);
576
+ const [branchPrefix, setBranchPrefix] = useState(initialValues.branchPrefix);
577
+ const [focusIdx, setFocusIdx] = useState(initialValues.personalize ? 3 : 0);
578
+ useEffect(() => {
579
+ (async () => {
580
+ try {
581
+ const name = (await run('git', ['config', '--global', 'user.name'])).trim();
582
+ if (name)
583
+ setGitUser(name);
584
+ }
585
+ catch { /* no global git config */ }
586
+ try {
587
+ const email = (await run('git', ['config', '--global', 'user.email'])).trim();
588
+ if (email)
589
+ setGitEmail(email);
590
+ }
591
+ catch { /* no global git config */ }
592
+ })();
593
+ }, []);
594
+ useEffect(() => {
595
+ if (!personalize && focusIdx !== 0)
596
+ setFocusIdx(0);
597
+ }, [personalize, focusIdx]);
598
+ const items = useMemo(() => [
599
+ { id: 'personalize', label: 'Personalize to user', isCheckbox: true },
600
+ { id: 'gitUser', label: 'Git user', isCheckbox: false, value: gitUser, setter: setGitUser },
601
+ { id: 'gitEmail', label: 'Git email', isCheckbox: false, value: gitEmail, setter: setGitEmail },
602
+ { id: 'branchPrefix', label: 'Branch prefix', isCheckbox: false, value: branchPrefix, setter: setBranchPrefix },
603
+ ], [gitUser, gitEmail, branchPrefix]);
604
+ const enabledIndices = useMemo(() => (personalize ? [0, 1, 2, 3] : [0]), [personalize]);
605
+ useInput((input, key) => {
606
+ if (key.escape) {
607
+ onCancel();
608
+ return;
609
+ }
610
+ if (key.return) {
611
+ onSubmit({ personalize, gitUser, gitEmail, branchPrefix });
612
+ return;
613
+ }
614
+ if (input === ' ') {
615
+ if (focusIdx === 0) {
616
+ setPersonalize(p => !p);
617
+ return;
618
+ }
619
+ }
620
+ const pos = enabledIndices.indexOf(focusIdx);
621
+ if (key.upArrow) {
622
+ const nextPos = pos > 0 ? pos - 1 : enabledIndices.length - 1;
623
+ setFocusIdx(enabledIndices[nextPos]);
624
+ return;
625
+ }
626
+ if (key.downArrow) {
627
+ const nextPos = pos < enabledIndices.length - 1 ? pos + 1 : 0;
628
+ setFocusIdx(enabledIndices[nextPos]);
629
+ return;
630
+ }
631
+ if (focusIdx === 0)
632
+ return;
633
+ const item = items[focusIdx];
634
+ if (!item || item.isCheckbox)
635
+ return;
636
+ const textItem = item;
637
+ if (key.backspace || key.delete) {
638
+ textItem.setter(v => v.slice(0, -1));
639
+ return;
640
+ }
641
+ if (input && input.length === 1 && !key.ctrl && !key.meta) {
642
+ textItem.setter(v => v + input);
643
+ return;
644
+ }
645
+ });
646
+ const scrollValue = (value, label, focused) => {
647
+ const prefix = 4 + 2 + label.length + 2 + (focused ? 1 : 0);
648
+ const max = Math.max(0, FORM_WIDTH - prefix);
649
+ if (value.length <= max)
650
+ return value;
651
+ return value.slice(-max);
652
+ };
653
+ return (_jsxs(Box, { flexDirection: "column", alignItems: "center", paddingY: 2, width: "100%", children: [_jsxs(Box, { flexDirection: "column", alignItems: "center", children: [_jsx(Text, { bold: true, children: "Personalization" }), _jsx(Text, { dimColor: true, children: "Customize git identity and branch prefix for selected features." })] }), _jsxs(Box, { width: FORM_WIDTH, flexDirection: "column", marginTop: 1, alignItems: "flex-start", children: [_jsxs(Box, { children: [_jsx(Text, { children: focusIdx === 0 ? '▸ ' : ' ' }), _jsxs(Text, { color: focusIdx === 0 ? 'yellow' : undefined, children: ["[", personalize ? '*' : ' ', "] Personalize to user"] })] }), items.slice(1).map((item, rawI) => {
654
+ const i = rawI + 1;
655
+ const focused = focusIdx === i;
656
+ const textItem = item;
657
+ const display = scrollValue(textItem.value, textItem.label, focused);
658
+ return (_jsxs(Box, { marginLeft: 4, children: [_jsx(Text, { children: focused ? '▸ ' : ' ' }), _jsxs(Text, { color: focused ? 'yellow' : undefined, dimColor: !personalize, children: [textItem.label, ": ", display] }), focused && personalize && _jsx(Text, { inverse: true, children: " " })] }, item.id));
659
+ })] }), _jsx(Box, { marginTop: 1, alignItems: "center", children: _jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate space toggle enter submit esc back" }) })] }));
660
+ };
661
+ export const App = () => {
662
+ const [screen, setScreen] = useState('select');
663
+ const [selection, setSelection] = useState(defaultSelection());
664
+ const [formState, setFormState] = useState({ personalize: true, gitUser: '', gitEmail: '', branchPrefix: '' });
665
+ const needsForm = (sel) => ['stripCommitAttribution', 'agentMdFile', 'renameClaudeBranch'].some(f => sel.features.has(f));
666
+ if (screen === 'select') {
667
+ return (_jsx(SelectScreen, { onConfirm: (sel, mode) => {
668
+ setSelection(sel);
669
+ if (mode === 'uninstall') {
670
+ setScreen('uninstall');
671
+ }
672
+ else if (needsForm(sel)) {
673
+ setFormState({ personalize: true, gitUser: '', gitEmail: '', branchPrefix: '' });
674
+ setScreen('form');
675
+ }
676
+ else {
677
+ setScreen('install');
678
+ }
679
+ } }));
680
+ }
681
+ if (screen === 'uninstall') {
682
+ return _jsx(UninstallScreen, { selection: selection });
683
+ }
684
+ if (screen === 'form') {
685
+ return (_jsx(FormScreen, { initialValues: formState, onSubmit: values => { setFormState(values); setScreen('install'); }, onCancel: () => setScreen('select') }));
686
+ }
687
+ return _jsx(InstallScreen, { selection: selection, formState: formState });
688
+ };