@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.
package/dist/common.js ADDED
@@ -0,0 +1,526 @@
1
+ import { execFile, execFileSync, spawn } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, chmodSync, accessSync, constants, } from 'fs';
4
+ import { dirname, join } from 'path';
5
+ import { homedir } from 'os';
6
+ // ── Paths ────────────────────────────────────────────────────────────────────
7
+ export const HOME = homedir();
8
+ export const AIRC = join(HOME, '.airc');
9
+ export const ZSHRC = join(HOME, '.zshrc');
10
+ export const CLAUDE_SETTINGS = join(HOME, '.claude', 'settings.json');
11
+ export const OPENCODE_CONFIG = join(HOME, '.config', 'opencode', 'opencode.json');
12
+ export const HCLAUDE = '/usr/local/bin/hclaude';
13
+ export const HOPENCODE = '/usr/local/bin/hopencode';
14
+ export const AGENT_IDS = ['claudeCode', 'openCode'];
15
+ export const SCOPE_IDS = ['global', 'project'];
16
+ export const GIT_SETTING_IDS = ['gitignore'];
17
+ export const AGENT_LABELS = {
18
+ claudeCode: 'Claude Code',
19
+ openCode: 'OpenCode',
20
+ };
21
+ export const SCOPE_LABELS = {
22
+ global: 'Global',
23
+ project: 'Project',
24
+ };
25
+ export const GIT_SETTING_LABELS = {
26
+ gitignore: '.gitignore',
27
+ };
28
+ export const FEATURE_LABELS = {
29
+ headroom: 'Headroom',
30
+ stopTelemetry: 'Stop Telemetry',
31
+ stopAttributionConfig: 'Agent config for git commits',
32
+ stopAttributionHook: 'Git pre-commit hook for git commits',
33
+ stripCommitAttribution: 'Agent SessionStart hook for git commits',
34
+ stripPrAttribution: 'GitHub Action for PR body',
35
+ renameClaudeBranch: 'Agent SessionStart hook for git branches',
36
+ agentMdFile: 'Agent md file for all git ops',
37
+ lsp: 'LSP',
38
+ openspec: 'OpenSpec',
39
+ };
40
+ export const FEATURE_ORDER = [
41
+ 'stopTelemetry', 'stopAttributionConfig', 'stopAttributionHook',
42
+ 'stripCommitAttribution', 'renameClaudeBranch', 'stripPrAttribution',
43
+ 'agentMdFile', 'lsp', 'headroom', 'openspec',
44
+ ];
45
+ export const FEATURE_SCOPES = {
46
+ headroom: ['global'],
47
+ stopTelemetry: ['global'],
48
+ stopAttributionConfig: ['global', 'project'],
49
+ stopAttributionHook: ['project'],
50
+ stripCommitAttribution: ['project'],
51
+ stripPrAttribution: ['project'],
52
+ renameClaudeBranch: ['project'],
53
+ agentMdFile: ['global', 'project'],
54
+ lsp: ['global'],
55
+ openspec: ['global', 'project'],
56
+ };
57
+ export const FEATURE_AGENTS = {
58
+ stopTelemetry: ['claudeCode'],
59
+ stopAttributionConfig: ['claudeCode'],
60
+ stopAttributionHook: ['claudeCode', 'openCode'],
61
+ stripCommitAttribution: ['claudeCode'],
62
+ renameClaudeBranch: ['claudeCode'],
63
+ stripPrAttribution: ['claudeCode', 'openCode'],
64
+ agentMdFile: ['claudeCode', 'openCode'],
65
+ lsp: ['claudeCode', 'openCode'],
66
+ headroom: ['claudeCode', 'openCode'],
67
+ openspec: ['claudeCode', 'openCode'],
68
+ };
69
+ export const DEFAULT_FEATURES = [
70
+ 'stopTelemetry', 'stopAttributionConfig', 'stopAttributionHook',
71
+ 'stripCommitAttribution', 'renameClaudeBranch', 'stripPrAttribution',
72
+ 'agentMdFile',
73
+ ];
74
+ export function defaultSelection() {
75
+ return {
76
+ agents: new Set(['claudeCode', 'openCode']),
77
+ scopes: new Set(['global', 'project']),
78
+ features: new Set(DEFAULT_FEATURES),
79
+ lspLanguages: new Set(),
80
+ gitSettings: new Set(['gitignore']),
81
+ };
82
+ }
83
+ export function getFeatureItems(agents, scopes) {
84
+ const supportsScope = (feature) => {
85
+ if (scopes.size === 0)
86
+ return false;
87
+ return FEATURE_SCOPES[feature].some(s => scopes.has(s));
88
+ };
89
+ const supportsAgent = (feature) => {
90
+ if (agents.size === 0)
91
+ return false;
92
+ return FEATURE_AGENTS[feature].some(a => agents.has(a));
93
+ };
94
+ const supported = (feature) => supportsScope(feature) && supportsAgent(feature);
95
+ const items = [];
96
+ if (supported('stopTelemetry')) {
97
+ items.push({ id: 'stopTelemetry', label: FEATURE_LABELS.stopTelemetry, indent: false });
98
+ }
99
+ const stopAttributionChildren = [
100
+ { id: 'stopAttribution.hook', feature: 'stopAttributionHook', label: FEATURE_LABELS.stopAttributionHook },
101
+ { id: 'stopAttribution.config', feature: 'stopAttributionConfig', label: FEATURE_LABELS.stopAttributionConfig },
102
+ { id: 'stopAttribution.stripCommit', feature: 'stripCommitAttribution', label: FEATURE_LABELS.stripCommitAttribution },
103
+ { id: 'stopAttribution.renameBranch', feature: 'renameClaudeBranch', label: FEATURE_LABELS.renameClaudeBranch },
104
+ { id: 'stopAttribution.stripPr', feature: 'stripPrAttribution', label: FEATURE_LABELS.stripPrAttribution },
105
+ { id: 'stopAttribution.mdFile', feature: 'agentMdFile', label: FEATURE_LABELS.agentMdFile },
106
+ ].filter(c => supported(c.feature));
107
+ if (stopAttributionChildren.length > 0) {
108
+ items.push({ id: 'stopAttribution', label: 'Stop Attribution', indent: false });
109
+ for (const child of stopAttributionChildren) {
110
+ items.push({ id: child.id, label: child.label, indent: true });
111
+ }
112
+ }
113
+ if (supported('lsp')) {
114
+ items.push({ id: 'lsp', label: FEATURE_LABELS.lsp, indent: false });
115
+ if (agents.has('claudeCode')) {
116
+ items.push({ id: 'lsp.cpp', label: 'C++ (clangd)', indent: true }, { id: 'lsp.swift', label: 'Swift', indent: true });
117
+ }
118
+ }
119
+ if (supported('headroom')) {
120
+ items.push({ id: 'headroom', label: FEATURE_LABELS.headroom, indent: false });
121
+ }
122
+ if (supported('openspec')) {
123
+ items.push({ id: 'openspec', label: FEATURE_LABELS.openspec, indent: false });
124
+ }
125
+ return items;
126
+ }
127
+ export function toggleSelection(panel, cursor, sel, featureItems) {
128
+ const next = {
129
+ agents: new Set(sel.agents),
130
+ scopes: new Set(sel.scopes),
131
+ features: new Set(sel.features),
132
+ lspLanguages: new Set(sel.lspLanguages),
133
+ gitSettings: new Set(sel.gitSettings),
134
+ };
135
+ if (panel === 'agents') {
136
+ const id = AGENT_IDS[cursor];
137
+ if (!id)
138
+ return sel;
139
+ if (next.agents.has(id)) {
140
+ next.agents.delete(id);
141
+ }
142
+ else {
143
+ next.agents.add(id);
144
+ }
145
+ }
146
+ else if (panel === 'scope') {
147
+ const id = SCOPE_IDS[cursor];
148
+ if (!id)
149
+ return sel;
150
+ if (next.scopes.has(id))
151
+ next.scopes.delete(id);
152
+ else
153
+ next.scopes.add(id);
154
+ }
155
+ else if (panel === 'gitSettings') {
156
+ const id = GIT_SETTING_IDS[cursor];
157
+ if (!id)
158
+ return sel;
159
+ if (next.gitSettings.has(id))
160
+ next.gitSettings.delete(id);
161
+ else
162
+ next.gitSettings.add(id);
163
+ }
164
+ else {
165
+ const item = featureItems[cursor];
166
+ if (!item)
167
+ return sel;
168
+ if (item.id === 'lsp.cpp') {
169
+ if (next.lspLanguages.has('cpp'))
170
+ next.lspLanguages.delete('cpp');
171
+ else
172
+ next.lspLanguages.add('cpp');
173
+ if (next.lspLanguages.size > 0)
174
+ next.features.add('lsp');
175
+ else
176
+ next.features.delete('lsp');
177
+ }
178
+ else if (item.id === 'lsp.swift') {
179
+ if (next.lspLanguages.has('swift'))
180
+ next.lspLanguages.delete('swift');
181
+ else
182
+ next.lspLanguages.add('swift');
183
+ if (next.lspLanguages.size > 0)
184
+ next.features.add('lsp');
185
+ else
186
+ next.features.delete('lsp');
187
+ }
188
+ else if (item.id === 'stopAttribution.hook') {
189
+ if (next.features.has('stopAttributionHook'))
190
+ next.features.delete('stopAttributionHook');
191
+ else
192
+ next.features.add('stopAttributionHook');
193
+ }
194
+ else if (item.id === 'stopAttribution.config') {
195
+ if (next.features.has('stopAttributionConfig'))
196
+ next.features.delete('stopAttributionConfig');
197
+ else
198
+ next.features.add('stopAttributionConfig');
199
+ }
200
+ else if (item.id === 'stopAttribution.stripCommit') {
201
+ if (next.features.has('stripCommitAttribution'))
202
+ next.features.delete('stripCommitAttribution');
203
+ else
204
+ next.features.add('stripCommitAttribution');
205
+ }
206
+ else if (item.id === 'stopAttribution.stripPr') {
207
+ if (next.features.has('stripPrAttribution'))
208
+ next.features.delete('stripPrAttribution');
209
+ else
210
+ next.features.add('stripPrAttribution');
211
+ }
212
+ else if (item.id === 'stopAttribution.renameBranch') {
213
+ if (next.features.has('renameClaudeBranch'))
214
+ next.features.delete('renameClaudeBranch');
215
+ else
216
+ next.features.add('renameClaudeBranch');
217
+ }
218
+ else if (item.id === 'stopAttribution.mdFile') {
219
+ if (next.features.has('agentMdFile'))
220
+ next.features.delete('agentMdFile');
221
+ else
222
+ next.features.add('agentMdFile');
223
+ }
224
+ else if (item.id === 'stopAttribution') {
225
+ const visibleSubs = [];
226
+ if (featureItems.some(fi => fi.id === 'stopAttribution.hook'))
227
+ visibleSubs.push('stopAttributionHook');
228
+ if (featureItems.some(fi => fi.id === 'stopAttribution.config'))
229
+ visibleSubs.push('stopAttributionConfig');
230
+ if (featureItems.some(fi => fi.id === 'stopAttribution.stripCommit'))
231
+ visibleSubs.push('stripCommitAttribution');
232
+ if (featureItems.some(fi => fi.id === 'stopAttribution.renameBranch'))
233
+ visibleSubs.push('renameClaudeBranch');
234
+ if (featureItems.some(fi => fi.id === 'stopAttribution.stripPr'))
235
+ visibleSubs.push('stripPrAttribution');
236
+ if (featureItems.some(fi => fi.id === 'stopAttribution.mdFile'))
237
+ visibleSubs.push('agentMdFile');
238
+ if (visibleSubs.length === 0)
239
+ return sel;
240
+ const allOn = visibleSubs.every(f => next.features.has(f));
241
+ if (allOn)
242
+ visibleSubs.forEach(f => next.features.delete(f));
243
+ else
244
+ visibleSubs.forEach(f => next.features.add(f));
245
+ }
246
+ else {
247
+ const id = item.id;
248
+ if (next.features.has(id)) {
249
+ next.features.delete(id);
250
+ if (id === 'lsp')
251
+ next.lspLanguages.clear();
252
+ }
253
+ else {
254
+ next.features.add(id);
255
+ if (id === 'lsp') {
256
+ next.lspLanguages.add('cpp');
257
+ next.lspLanguages.add('swift');
258
+ }
259
+ }
260
+ }
261
+ }
262
+ return next;
263
+ }
264
+ // ── Exec utilities ───────────────────────────────────────────────────────────
265
+ const execFileAsync = promisify(execFile);
266
+ export async function run(cmd, args = []) {
267
+ const { stdout } = await execFileAsync(cmd, args);
268
+ return stdout.trim();
269
+ }
270
+ export function stream(cmd, args, onLine, opts) {
271
+ return new Promise((resolve, reject) => {
272
+ const proc = spawn(cmd, args, {
273
+ shell: false,
274
+ env: opts?.env ?? process.env,
275
+ cwd: opts?.cwd,
276
+ });
277
+ const emit = (data) => String(data).split('\n').filter(Boolean).forEach(onLine);
278
+ proc.stdout.on('data', emit);
279
+ proc.stderr.on('data', emit);
280
+ proc.on('close', code => (code === 0 ? resolve() : reject(new Error(`${cmd} exited ${code}`))));
281
+ proc.on('error', reject);
282
+ });
283
+ }
284
+ export function commandExists(cmd) {
285
+ try {
286
+ execFileSync('which', [cmd], { stdio: 'ignore' });
287
+ return true;
288
+ }
289
+ catch {
290
+ return false;
291
+ }
292
+ }
293
+ export function isGitRepo() {
294
+ try {
295
+ execFileSync('git', ['rev-parse', '--git-dir'], { stdio: 'ignore' });
296
+ return true;
297
+ }
298
+ catch {
299
+ return false;
300
+ }
301
+ }
302
+ export function canWrite(path) {
303
+ try {
304
+ accessSync(path, constants.W_OK);
305
+ return true;
306
+ }
307
+ catch {
308
+ return false;
309
+ }
310
+ }
311
+ export async function nvmShell(cmd, onLine) {
312
+ const nvmDir = join(HOME, '.nvm');
313
+ await stream('bash', ['-c', `. "${nvmDir}/nvm.sh" && ${cmd}`], onLine, {
314
+ env: { ...process.env, NVM_DIR: nvmDir },
315
+ });
316
+ }
317
+ // ── File utilities ───────────────────────────────────────────────────────────
318
+ export function readJson(filePath) {
319
+ if (!existsSync(filePath))
320
+ return {};
321
+ try {
322
+ return JSON.parse(readFileSync(filePath, 'utf8'));
323
+ }
324
+ catch {
325
+ return {};
326
+ }
327
+ }
328
+ export function writeJson(filePath, data) {
329
+ mkdirSync(dirname(filePath), { recursive: true });
330
+ writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
331
+ }
332
+ export function patchJson(filePath, patch) {
333
+ const data = readJson(filePath);
334
+ patch(data);
335
+ writeJson(filePath, data);
336
+ }
337
+ export function ensureFile(filePath, defaultContent) {
338
+ if (!existsSync(filePath)) {
339
+ mkdirSync(dirname(filePath), { recursive: true });
340
+ writeFileSync(filePath, defaultContent);
341
+ }
342
+ }
343
+ export function appendIfMissing(filePath, marker, content) {
344
+ const existing = existsSync(filePath) ? readFileSync(filePath, 'utf8') : '';
345
+ if (!existing.includes(marker))
346
+ appendFileSync(filePath, content);
347
+ }
348
+ export async function writeExecutable(filePath, content, onLine) {
349
+ if (canWrite(dirname(filePath))) {
350
+ writeFileSync(filePath, content);
351
+ chmodSync(filePath, 0o755);
352
+ }
353
+ else {
354
+ onLine(`sudo required for ${filePath}`);
355
+ await new Promise((resolve, reject) => {
356
+ const proc = spawn('sudo', ['tee', filePath], { stdio: ['pipe', 'ignore', 'pipe'] });
357
+ proc.stdin.write(content);
358
+ proc.stdin.end();
359
+ proc.on('close', c => (c === 0 ? resolve() : reject(new Error('sudo tee failed'))));
360
+ proc.on('error', reject);
361
+ });
362
+ await run('sudo', ['chmod', '+x', filePath]);
363
+ }
364
+ }
365
+ // ── Template strings ─────────────────────────────────────────────────────────
366
+ export const HEADROOM_START_FUNC = `
367
+ headroom_start() {
368
+ if ! lsof -ti:8787 > /dev/null 2>&1; then
369
+ HEADROOM_CODE_AWARE_ENABLED=1 headroom proxy --port 8787 > /dev/null 2>&1 &
370
+ disown
371
+ fi
372
+ }
373
+ `;
374
+ export const HCLAUDE_CONTENT = `#!/bin/sh
375
+ . ~/.airc
376
+ headroom_start
377
+ ANTHROPIC_BASE_URL=http://localhost:8787 claude "$@"
378
+ `;
379
+ export const HOPENCODE_CONTENT = `#!/bin/sh
380
+ . ~/.airc
381
+ headroom_start
382
+ OPENAI_BASE_URL=http://localhost:8787/v1 opencode "$@"
383
+ `;
384
+ // Bash commit-msg hook used by the SessionStart strip-commit-attribution script.
385
+ export const BASH_COMMIT_MSG_HOOK = `#!/bin/bash
386
+ sed -i '' \\
387
+ -e '/claude\\.ai\\/code\\/session/d' \\
388
+ -e '/Co-[Aa]uthored-[Bb]y:.*[Cc]laude/d' \\
389
+ -e '/[Gg]enerated [bw][yi]t*h*.*[Cc]laude/d' \\
390
+ -e '/[[:space:]]*🤖 [Gg]enerated/d' \\
391
+ -e '/^---[[:space:]]*$/d' \\
392
+ "$1"
393
+ awk 'BEGIN{blank=0} /^$/{blank++; next} {while(blank--)print ""; blank=0; print}' "$1" > "$1.tmp" && mv "$1.tmp" "$1"
394
+ `;
395
+ // GitHub Actions workflow that strips Claude attribution from PR bodies server-side.
396
+ export const STRIP_PR_WORKFLOW = `name: Strip Claude attribution from PR bodies
397
+
398
+ on:
399
+ pull_request:
400
+ types: [opened, edited]
401
+
402
+ permissions:
403
+ pull-requests: write
404
+ contents: read
405
+
406
+ jobs:
407
+ strip:
408
+ runs-on: ubuntu-latest
409
+ steps:
410
+ - name: Strip attribution lines
411
+ env:
412
+ GH_TOKEN: ${'$'}{{ secrets.GITHUB_TOKEN }}
413
+ PR: ${'$'}{{ github.event.pull_request.number }}
414
+ REPO: ${'$'}{{ github.repository }}
415
+ run: |
416
+ set -e
417
+ body=$(gh pr view "$PR" -R "$REPO" --json body --jq .body)
418
+ cleaned=$(printf '%s' "$body" | sed \\
419
+ -e '/claude\\.ai\\/code\\/session/d' \\
420
+ -e '/Co-[Aa]uthored-[Bb]y:.*[Cc]laude/d' \\
421
+ -e '/[Gg]enerated [bw][yi]t*h*.*[Cc]laude/d' \\
422
+ -e '/[[:space:]]*🤖 [Gg]enerated/d' \\
423
+ -e '/^---[[:space:]]*$/d' \\
424
+ | awk 'BEGIN{blank=0} /^$/{blank++; next} {while(blank--)print ""; blank=0; print}')
425
+ if [ "$body" = "$cleaned" ]; then
426
+ echo "PR #$PR body already clean"
427
+ exit 0
428
+ fi
429
+ printf '%s' "$cleaned" | gh pr edit "$PR" -R "$REPO" --body-file -
430
+ echo "Cleaned PR #$PR"
431
+ `;
432
+ // ── Agent markdown file content ──────────────────────────────────────────────
433
+ export const AGENT_MD_HEADER = '# UNCLAUDE';
434
+ const FEATURE_PROJECT_FILES = {
435
+ stopAttributionConfig: ['.claude/settings.json'],
436
+ stripCommitAttribution: ['.claude/settings.json', '.claude/hooks/strip-commit-attribution.sh'],
437
+ renameClaudeBranch: ['.claude/settings.json', '.claude/hooks/strip-claude-branch.sh'],
438
+ stripPrAttribution: ['.github/workflows/strip-pr-attribution.yml'],
439
+ agentMdFile: ['CLAUDE.md', 'AGENTS.md'],
440
+ };
441
+ export function addGitignoreEntries(features) {
442
+ const files = new Set();
443
+ for (const feature of features) {
444
+ for (const p of FEATURE_PROJECT_FILES[feature] ?? [])
445
+ files.add(p);
446
+ }
447
+ if (files.size === 0)
448
+ return;
449
+ appendSectionIfMissing('.gitignore', AGENT_MD_HEADER, `${AGENT_MD_HEADER}\n${[...files].join('\n')}`);
450
+ }
451
+ export function removeGitignoreEntries() {
452
+ removeSectionByHeader('.gitignore', AGENT_MD_HEADER);
453
+ }
454
+ // ── Markdown section helpers ─────────────────────────────────────────────────
455
+ export function appendSectionIfMissing(filePath, marker, content) {
456
+ const existing = existsSync(filePath) ? readFileSync(filePath, 'utf8') : '';
457
+ if (!existing.includes(marker)) {
458
+ const sep = existing.length > 0 && !existing.endsWith('\n') ? '\n\n' : (existing.endsWith('\n') ? '\n' : '');
459
+ writeFileSync(filePath, existing + sep + content + '\n');
460
+ }
461
+ }
462
+ export function removeSectionByHeader(filePath, header) {
463
+ if (!existsSync(filePath))
464
+ return false;
465
+ const content = readFileSync(filePath, 'utf8');
466
+ const lines = content.split('\n');
467
+ const startIdx = lines.findIndex(l => l.trim() === header);
468
+ if (startIdx === -1)
469
+ return false;
470
+ let endIdx = lines.length;
471
+ for (let i = startIdx + 1; i < lines.length; i++) {
472
+ if (lines[i].startsWith('# ')) {
473
+ endIdx = i;
474
+ break;
475
+ }
476
+ }
477
+ const before = lines.slice(0, startIdx);
478
+ const after = lines.slice(endIdx);
479
+ while (before.length > 0 && before[before.length - 1].trim() === '')
480
+ before.pop();
481
+ while (after.length > 0 && after[0].trim() === '')
482
+ after.shift();
483
+ let newContent = [...before, ...after].join('\n');
484
+ if (newContent.length > 0 && !newContent.endsWith('\n'))
485
+ newContent += '\n';
486
+ writeFileSync(filePath, newContent);
487
+ return true;
488
+ }
489
+ // ── SessionStart hook helpers ─────────────────────────────────────────────────
490
+ export function addSessionStartHook(settingsPath, command) {
491
+ patchJson(settingsPath, d => {
492
+ if (typeof d['hooks'] !== 'object' || !d['hooks'])
493
+ d['hooks'] = {};
494
+ const h = d['hooks'];
495
+ if (!Array.isArray(h['SessionStart']))
496
+ h['SessionStart'] = [];
497
+ const arr = h['SessionStart'];
498
+ const exists = arr.some(g => {
499
+ const inner = g['hooks'];
500
+ return inner?.some(e => e['command'] === command);
501
+ });
502
+ if (!exists)
503
+ arr.push({ hooks: [{ type: 'command', command }] });
504
+ });
505
+ }
506
+ export function removeSessionStartHook(settingsPath, command) {
507
+ patchJson(settingsPath, d => {
508
+ const h = d['hooks'];
509
+ if (!h || !Array.isArray(h['SessionStart']))
510
+ return;
511
+ h['SessionStart'] = h['SessionStart'].filter(g => {
512
+ const inner = g['hooks'];
513
+ return !inner?.some(e => e['command'] === command);
514
+ });
515
+ });
516
+ }
517
+ export function hasSessionStartHook(settingsPath, command) {
518
+ const d = readJson(settingsPath);
519
+ const h = d['hooks'];
520
+ if (!h || !Array.isArray(h['SessionStart']))
521
+ return false;
522
+ return h['SessionStart'].some(g => {
523
+ const inner = g['hooks'];
524
+ return inner?.some(e => e['command'] === command);
525
+ });
526
+ }