lostvibe 0.0.0-stage → 0.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.
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "lostvibe",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.7.0",
4
+ "description": "프로젝트의 객관 지표를 로컬 로그와 git에서 읽는 CLI. 코드와 대화 내용은 보내지 않는다.",
5
+ "keywords": [
6
+ "lostvibe",
7
+ "vibe-coding",
8
+ "ai",
9
+ "metrics",
10
+ "cli",
11
+ "claude-code",
12
+ "codex"
13
+ ],
14
+ "homepage": "https://lostvibesociety.com",
15
+ "type": "module",
16
+ "bin": {
17
+ "lostvibe": "bin/lostvibe.mjs"
18
+ },
19
+ "files": [
20
+ "bin",
21
+ "src",
22
+ "README.md"
23
+ ],
24
+ "engines": {
25
+ "node": ">=22.15"
26
+ },
27
+ "license": "MIT",
28
+ "scripts": {
29
+ "test": "node --test test/*.test.mjs"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ }
34
+ }
@@ -0,0 +1,129 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFile } from 'node:child_process';
4
+ import { activeConnection, saveJson, selectedProjects, siteOf, request, sendStatus } from './connections.mjs';
5
+
6
+ export function validateCommand(raw) {
7
+ if (
8
+ !Array.isArray(raw) ||
9
+ !raw.length ||
10
+ raw.length > 32 ||
11
+ raw.some((s) => typeof s !== 'string' || !s || s.length > 2000 || s.includes('\0'))
12
+ )
13
+ throw new Error('실행 명령을 JSON 문자열 배열로 지정해 주세요.');
14
+ return raw;
15
+ }
16
+ export function parseCollected(source) {
17
+ let b;
18
+ try {
19
+ b = JSON.parse(source);
20
+ } catch {
21
+ throw new Error('수집 명령은 JSON 하나를 출력해야 해요.');
22
+ }
23
+ if (!b || typeof b.value !== 'number' || !Number.isFinite(b.value) || b.value < 0 || b.value >= 1e15)
24
+ throw new Error('성과를 읽지 못했어요. 이전 값을 유지해요.');
25
+ if (
26
+ b.date !== undefined &&
27
+ (!/^\d{4}-\d{2}-\d{2}$/.test(b.date) ||
28
+ new Date(b.date).toISOString().slice(0, 10) !== b.date ||
29
+ Date.parse(b.date) > Date.now() + 86400000)
30
+ )
31
+ throw new Error('성과 날짜가 맞지 않아요.');
32
+ const now = new Date();
33
+ return {
34
+ value: b.value,
35
+ date:
36
+ b.date ||
37
+ `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`,
38
+ };
39
+ }
40
+ export function addCollector(o) {
41
+ if (!['result', 'result2'].includes(o.slot)) throw new Error('--slot result 또는 result2가 필요해요.');
42
+ let raw;
43
+ try {
44
+ raw = JSON.parse(o.commandJson);
45
+ } catch {
46
+ throw new Error('--command-json 에 명령 배열이 필요해요.');
47
+ }
48
+ const command = validateCommand(raw);
49
+ const { dir, state, account } = activeConnection(o);
50
+ const p = Object.values(account?.projects || {}).find((p) => p.id === o.project);
51
+ if (!p) throw new Error('먼저 이 프로젝트를 setup으로 연결해 주세요.');
52
+ // Store only an explicitly registered command in user state. Never execute imported/repository config.
53
+ p.collectors ||= {};
54
+ p.collectors[o.slot] = { command, cwd: p.cwd };
55
+ saveJson(path.join(dir, 'connections.json'), state);
56
+ }
57
+ export function removeCollector(o) {
58
+ const { dir, state, account } = activeConnection(o);
59
+ const p = Object.values(account?.projects || {}).find((p) => p.id === o.project);
60
+ if (!p || !['result', 'result2'].includes(o.slot)) throw new Error('프로젝트와 성과를 지정해 주세요.');
61
+ delete p.collectors?.[o.slot];
62
+ saveJson(path.join(dir, 'connections.json'), state);
63
+ }
64
+ async function run(command, cwd) {
65
+ const env = { ...process.env };
66
+ for (const k of ['LOSTVIBE_TOKEN', 'LOSTVIBE_METRICS_TOKEN']) delete env[k];
67
+ return new Promise((resolve, reject) =>
68
+ execFile(
69
+ command[0],
70
+ command.slice(1),
71
+ { cwd, env, timeout: 20000, maxBuffer: 64_000, windowsHide: true },
72
+ (e, stdout) =>
73
+ e ? reject(new Error('성과 조회 명령이 실패했어요. 이전 값을 유지해요.')) : resolve(stdout),
74
+ ),
75
+ );
76
+ }
77
+ export async function collect(o, token) {
78
+ const site = siteOf(o),
79
+ { dir } = activeConnection(o);
80
+ const selected = selectedProjects(o);
81
+ if (!selected.length) return;
82
+ const connection = await request(site, token, '/api/me/connection');
83
+ const ids = new Set(connection.projects.map((p) => p.id));
84
+ const skipped = new Set((connection.skipped || []).map((p) => p.sourceId));
85
+ let failed = 0;
86
+ for (const p of selected.filter((p) => ids.has(p.id) && !skipped.has(p.sourceId))) {
87
+ if (!Object.keys(p.collectors || {}).length) continue;
88
+ const current = await request(site, token, `/api/projects/${p.id}`);
89
+ for (const [slot, c] of Object.entries(p.collectors || {})) {
90
+ if (!current[slot]?.name) continue;
91
+ const lock = path.join(dir, `collector-${p.id}-${slot}.lock`);
92
+ try {
93
+ const old = Number(fs.readFileSync(lock, 'utf8'));
94
+ try {
95
+ process.kill(old, 0);
96
+ continue;
97
+ } catch {
98
+ fs.rmSync(lock, { force: true });
99
+ }
100
+ } catch {
101
+ /* no active lock */
102
+ }
103
+ try {
104
+ fs.writeFileSync(lock, String(process.pid), { flag: 'wx', mode: 0o600 });
105
+ } catch {
106
+ continue;
107
+ }
108
+ try {
109
+ const sample = parseCollected(await run(validateCommand(c.command), c.cwd));
110
+ const tokenFile = path.join(p.cwd, '.lostvibe', `metrics-token-${p.id}`);
111
+ const scoped = fs.readFileSync(tokenFile, 'utf8').trim();
112
+ const result = await request(site, scoped, `/api/me/projects/${p.id}/metrics`, 'POST', {
113
+ metrics: [{ slot, ...sample }],
114
+ });
115
+ if (!result.saved || result.refused?.length)
116
+ throw new Error('성과가 연결되어 있지 않거나 제외됐어요.');
117
+ await sendStatus(site, scoped, p.id, slot, true);
118
+ if (!o.quiet) console.log(`${p.name} · ${slot}: ${sample.value} (${sample.date})`);
119
+ } catch (e) {
120
+ failed++;
121
+ await sendStatus(site, token, p.id, slot, false);
122
+ if (!o.quiet) console.error(`${p.name} · ${slot}: ${e.message}`);
123
+ } finally {
124
+ fs.rmSync(lock, { force: true });
125
+ }
126
+ }
127
+ }
128
+ if (failed) throw new Error(`성과 ${failed}개를 갱신하지 못했어요. 이전 값은 유지했어요.`);
129
+ }
@@ -0,0 +1,116 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { resolveCwd, projectIdentity, sha } from './gitinfo.mjs';
5
+ export const VERSION = '0.7.0';
6
+ export const stateDir = (o = {}) =>
7
+ path.resolve(o.out || process.env.LOSTVIBE_DIR || path.join(os.homedir(), '.lostvibe'));
8
+ export function readState(dir) {
9
+ try {
10
+ return JSON.parse(fs.readFileSync(path.join(dir, 'connections.json'), 'utf8'));
11
+ } catch {
12
+ return { accounts: {} };
13
+ }
14
+ }
15
+ export function saveJson(file, data) {
16
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
17
+ const tmp = `${file}.${process.pid}.tmp`;
18
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
19
+ fs.renameSync(tmp, file);
20
+ }
21
+ export function activeConnection(o = {}) {
22
+ const dir = stateDir(o),
23
+ state = readState(dir);
24
+ const account = state.accounts?.[state.active];
25
+ return { dir, state, account };
26
+ }
27
+ export function currentIdentity() {
28
+ const r = resolveCwd(process.cwd());
29
+ if (!r.repoPath) throw new Error('프로젝트의 git 폴더에서 실행해 주세요.');
30
+ return { ...r, sourceId: projectIdentity(r.repoPath, r.key) };
31
+ }
32
+ export function siteOf(o = {}) {
33
+ const { account } = activeConnection(o);
34
+ const site = (
35
+ o.site ||
36
+ process.env.LOSTVIBE_SITE ||
37
+ account?.site ||
38
+ 'https://lostvibesociety.com'
39
+ ).replace(/\/$/, '');
40
+ const u = new URL(site);
41
+ if (
42
+ u.username ||
43
+ u.password ||
44
+ !['https:', 'http:'].includes(u.protocol) ||
45
+ (u.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(u.hostname))
46
+ )
47
+ throw new Error('HTTPS 사이트 주소가 필요해요.');
48
+ return site;
49
+ }
50
+ export async function request(site, token, url, method = 'GET', body) {
51
+ let res;
52
+ for (let i = 0; ; i++) {
53
+ res = await fetch(site + url, {
54
+ method,
55
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
56
+ body: body === undefined ? undefined : JSON.stringify(body),
57
+ signal: AbortSignal.timeout(30000),
58
+ });
59
+ if (res.status !== 429 || i >= 3) break;
60
+ await new Promise((r) =>
61
+ setTimeout(r, Math.min(5, Math.max(1, Number(res.headers.get('retry-after')) || 2)) * 1000),
62
+ );
63
+ }
64
+ const d = await res.json().catch(() => ({}));
65
+ if (!res.ok) throw new Error(`Lostvibe 요청 실패 (${res.status}). 키와 응답 본문은 출력하지 않았어요.`);
66
+ return d;
67
+ }
68
+ export async function registerCurrent(o, token) {
69
+ const dir = stateDir(o),
70
+ site = siteOf(o),
71
+ connection = await request(site, token, '/api/me/connection');
72
+ const current = currentIdentity();
73
+ const existing = connection.projects.find((p) => p.sourceId === current.sourceId);
74
+ if (o.project && (!existing || existing.id !== o.project))
75
+ throw new Error('선택한 프로젝트와 현재 git 폴더가 달라요. 올바른 폴더에서 다시 실행해 주세요.');
76
+ if (connection.skipped?.some((p) => p.sourceId === current.sourceId))
77
+ throw new Error('넘긴 프로젝트예요. 사이트에서 다시 가져온 뒤 연결해 주세요.');
78
+ const target =
79
+ existing ||
80
+ (await request(site, token, '/api/me/projects', 'POST', {
81
+ name: current.name,
82
+ sourceId: current.sourceId,
83
+ }));
84
+ const state = readState(dir);
85
+ const key = sha(`${site}:${connection.login}`);
86
+ state.accounts ||= {};
87
+ state.accounts[key] ||= { site, login: connection.login, projects: {} };
88
+ const account = state.accounts[key];
89
+ account.projects[current.sourceId] = {
90
+ ...account.projects[current.sourceId],
91
+ sourceId: current.sourceId,
92
+ id: target.id,
93
+ cwd: current.repoPath,
94
+ name: current.name,
95
+ };
96
+ state.active = key;
97
+ saveJson(path.join(dir, 'connections.json'), state);
98
+ return account.projects[current.sourceId];
99
+ }
100
+ export function selectedProjects(o = {}) {
101
+ const { account } = activeConnection(o);
102
+ if (!account) return [];
103
+ const selected = o.only ? new Set(o.only.split(',').map((x) => x.trim())) : null;
104
+ return Object.values(account.projects).filter(
105
+ (p) =>
106
+ (!o.project || p.id === o.project) &&
107
+ (!selected || selected.has(p.id) || selected.has(p.sourceId) || selected.has(p.name)),
108
+ );
109
+ }
110
+ export async function sendStatus(site, token, id, kind, ok) {
111
+ try {
112
+ await request(site, token, `/api/me/projects/${id}/sync-status`, 'POST', { kind, ok });
113
+ } catch {
114
+ /* A status report must not hide the original result. */
115
+ }
116
+ }
@@ -0,0 +1,176 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { localDate } from './logs.mjs';
6
+
7
+ function git(dir, args) {
8
+ return execFileSync('git', ['-C', dir, ...args], {
9
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024 * 256, timeout: 120000,
10
+ }).trim();
11
+ }
12
+
13
+ export function sha(s) {
14
+ return crypto.createHash('sha256').update(s).digest('hex').slice(0, 32);
15
+ }
16
+
17
+ export function normalizeRemote(url) {
18
+ if (!url) return null;
19
+ let u = url.trim().replace(/\/+$/, '').replace(/\.git$/, '');
20
+ const ssh = u.match(/^git@([^:]+):(.+)$/);
21
+ if (ssh) u = `${ssh[1]}/${ssh[2]}`;
22
+ u = u.replace(/^[a-z+]+:\/\//, '').replace(/^[^@/]+@/, '').replace(/:22\//, '/');
23
+ return u.toLowerCase();
24
+ }
25
+
26
+ function nearestExisting(p) {
27
+ let cur = p;
28
+ while (cur && cur !== path.dirname(cur)) {
29
+ try { if (fs.statSync(cur).isDirectory()) return cur; } catch { /* keep walking */ }
30
+ cur = path.dirname(cur);
31
+ }
32
+ return null;
33
+ }
34
+
35
+ const cache = new Map();
36
+
37
+ // The oldest commit is the same on every computer, and reading it writes nothing into the repo.
38
+ export function rootCommit(repo) {
39
+ try {
40
+ const roots = git(repo, ['rev-list', '--max-parents=0', '--branches', '--tags', '--remotes']).split('\n').filter(Boolean).sort();
41
+ return roots[0] || null;
42
+ } catch { return null; }
43
+ }
44
+
45
+ // A remote names the project, so two forks of one template stay two projects.
46
+ // Without a remote the first commit does, so copies on two computers still join.
47
+ export function projectIdentity(repo, key) {
48
+ if (!repo || key.startsWith('remote:')) return sha(key);
49
+ const root = rootCommit(repo);
50
+ return root ? sha(`root:${root}`) : sha(key);
51
+ }
52
+
53
+ // Other names this project may already be known by. Earlier CLI versions saved one in git config.
54
+ export function projectAliases(repo, key) {
55
+ const out = [sha(key)];
56
+ if (repo) {
57
+ try {
58
+ const saved = git(repo, ['config', '--local', '--get', 'lostvibe.project-id']);
59
+ if (/^[a-f0-9]{32}$/.test(saved)) out.push(saved);
60
+ } catch { /* nothing saved */ }
61
+ }
62
+ return out;
63
+ }
64
+
65
+
66
+ // A working directory resolves to one project: the main repo for worktrees and subfolders,
67
+ // keyed by git remote so the same repo at two paths is one project.
68
+ export function resolveCwd(cwd) {
69
+ if (cache.has(cwd)) return cache.get(cwd);
70
+ const res = resolveUncached(cwd);
71
+ cache.set(cwd, res);
72
+ return res;
73
+ }
74
+
75
+ function resolveUncached(cwd) {
76
+ const dir = nearestExisting(cwd);
77
+ let root = null;
78
+ if (dir) {
79
+ try {
80
+ const top = git(dir, ['rev-parse', '--show-toplevel']);
81
+ let common = git(dir, ['rev-parse', '--git-common-dir']);
82
+ if (!path.isAbsolute(common)) common = path.resolve(dir, common);
83
+ root = path.basename(common) === '.git' ? path.dirname(common) : top;
84
+ } catch { /* not a repo */ }
85
+ }
86
+ if (root) {
87
+ let remote = null;
88
+ try { remote = normalizeRemote(git(root, ['remote', 'get-url', 'origin'])); } catch { /* no remote */ }
89
+ return { key: remote ? `remote:${remote}` : `path:${root}`, repoPath: root, remote, name: path.basename(root) };
90
+ }
91
+ return { key: `folder:${path.basename(cwd).toLowerCase()}`, repoPath: null, remote: null, name: path.basename(cwd), exists: dir === cwd };
92
+ }
93
+
94
+ export function commitCount(repo) {
95
+ try { return Number(git(repo, ['rev-list', '--count', '--branches', '--tags', '--remotes', '--no-merges'])) || 0; } catch { return 0; }
96
+ }
97
+
98
+ export function gitMetrics(repo) {
99
+ let out = '';
100
+ try {
101
+ out = git(repo, ['log', '--branches', '--tags', '--remotes', '--no-merges', '--format=%aI%x09%ae']);
102
+ } catch { /* empty repo */ }
103
+ const daily = new Map();
104
+ const people = new Set();
105
+ let commits = 0;
106
+ for (const line of out.split('\n')) {
107
+ if (!line) continue;
108
+ const [timestamp, email] = line.split('\t');
109
+ const parsed = new Date(timestamp);
110
+ if (Number.isNaN(parsed.getTime())) continue;
111
+ const date = localDate(parsed);
112
+ commits++;
113
+ daily.set(date, (daily.get(date) || 0) + 1);
114
+ if (email && !email.includes('[bot]')) people.add(email.toLowerCase());
115
+ }
116
+ const dates = [...daily.keys()].sort();
117
+ const first = dates[0] || null;
118
+ const last = dates[dates.length - 1] || null;
119
+ const days = first ? Math.round((Date.parse(last) - Date.parse(first)) / 86400000) + 1 : 0;
120
+ return {
121
+ commits, first_commit: first, last_commit: last, days_alive: days, contributors: people.size,
122
+ daily_commits: dates.map((date) => ({ date, n: daily.get(date) })),
123
+ };
124
+ }
125
+
126
+ const EXCLUDE_DIR = /(^|\/)(node_modules|dist|build|out|\.next|\.nuxt|\.vercel|\.turbo|\.cache|coverage|vendor|Pods|DerivedData|__pycache__|\.venv|venv|target|\.gradle)(\/|$)/;
127
+ const EXCLUDE_FILE = /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|Cargo\.lock|Gemfile\.lock|Podfile\.lock|poetry\.lock|composer\.lock|go\.sum|uv\.lock|Package\.resolved)$|\.min\.(js|css)$|\.map$/;
128
+ const BINARY = /\.(png|jpe?g|gif|webp|avif|ico|icns|svg|pdf|zip|gz|tar|tgz|rar|7z|mp3|mp4|mov|wav|m4a|webm|woff2?|ttf|otf|eot|heic|psd|ai|sketch|fig|bin|exe|dll|so|dylib|a|o|class|jar|wasm|sqlite3?|db|xcassets|car|p12|mobileprovision)$/i;
129
+
130
+ // Data and documents are counted separately so a 900k-line JSON catalog does not read as code.
131
+ const NON_CODE = new Set(['JSON', 'CSV', 'Text', 'Markdown', 'XML', 'YAML', 'TOML', 'Other']);
132
+
133
+ const LANGS = {
134
+ ts: 'TypeScript', tsx: 'TypeScript', mts: 'TypeScript', cts: 'TypeScript', js: 'JavaScript', jsx: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript',
135
+ py: 'Python', swift: 'Swift', kt: 'Kotlin', java: 'Java', go: 'Go', rs: 'Rust', rb: 'Ruby', php: 'PHP', c: 'C', h: 'C', cpp: 'C++', cc: 'C++', hpp: 'C++', m: 'Objective-C', mm: 'Objective-C', cs: 'C#', dart: 'Dart', vue: 'Vue', svelte: 'Svelte',
136
+ html: 'HTML', css: 'CSS', scss: 'CSS', sql: 'SQL', sh: 'Shell', bash: 'Shell', zsh: 'Shell', prisma: 'Prisma', md: 'Markdown', mdx: 'Markdown', json: 'JSON', yaml: 'YAML', yml: 'YAML', toml: 'TOML', csv: 'CSV', tsv: 'CSV', txt: 'Text', xml: 'XML', plist: 'XML', graphql: 'GraphQL',
137
+ };
138
+
139
+ export function codeMetrics(repo) {
140
+ let list;
141
+ try {
142
+ list = execFileSync('git', ['-C', repo, 'ls-files', '-z'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024 * 256 }).split('\0').filter(Boolean);
143
+ } catch { return null; }
144
+ let lines = 0;
145
+ let nonCode = 0;
146
+ let files = 0;
147
+ const byLang = new Map();
148
+ for (const rel of list) {
149
+ if (EXCLUDE_DIR.test(rel) || EXCLUDE_FILE.test(rel) || BINARY.test(rel)) continue;
150
+ let buf;
151
+ try {
152
+ const st = fs.statSync(path.join(repo, rel));
153
+ if (!st.isFile()) continue;
154
+ buf = fs.readFileSync(path.join(repo, rel));
155
+ } catch { continue; }
156
+ if (buf.subarray(0, 8192).includes(0)) continue;
157
+ let n = 0;
158
+ for (let i = 0; i < buf.length; i++) if (buf[i] === 10) n++;
159
+ if (buf.length && buf[buf.length - 1] !== 10) n++;
160
+ files++;
161
+ const ext = path.extname(rel).slice(1).toLowerCase();
162
+ const lang = LANGS[ext] || 'Other';
163
+ if (NON_CODE.has(lang)) { nonCode += n; continue; }
164
+ lines += n;
165
+ byLang.set(lang, (byLang.get(lang) || 0) + n);
166
+ }
167
+ return {
168
+ lines, files, non_code_lines: nonCode,
169
+ languages: [...byLang.entries()].sort((a, b) => b[1] - a[1]).map(([name, n]) => ({ name, lines: n })),
170
+ };
171
+ }
172
+
173
+ export function githubSlug(remote) {
174
+ const m = remote && remote.match(/^github\.com\/([^/]+)\/([^/]+)$/);
175
+ return m ? { owner: m[1], repo: m[2] } : { owner: '', repo: '' };
176
+ }
package/src/hooks.mjs ADDED
@@ -0,0 +1,98 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { VERSION, stateDir, saveJson } from './connections.mjs';
6
+ const quote = (s) => `'${s.replace(/'/g, "'\\''")}'`;
7
+ function load(file, fallback = {}) {
8
+ if (!fs.existsSync(file)) return fallback;
9
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
10
+ if (!data || typeof data !== 'object' || Array.isArray(data))
11
+ throw new Error(`설정 형식을 읽지 못했어요: ${path.basename(file)}`);
12
+ return data;
13
+ }
14
+ function targets() {
15
+ const list = [];
16
+ const claude = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
17
+ const codex = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
18
+ if (fs.existsSync(claude))
19
+ list.push({ client: 'Claude Code', file: path.join(claude, 'settings.json'), event: 'SessionEnd' });
20
+ if (fs.existsSync(codex))
21
+ for (const event of ['Stop', 'SessionEnd'])
22
+ list.push({ client: 'Codex', file: path.join(codex, 'hooks.json'), event });
23
+ return list;
24
+ }
25
+ function ownGroups(config, event, command, remove = false) {
26
+ config.hooks ||= {};
27
+ const groups = config.hooks[event] || [];
28
+ if (!Array.isArray(groups)) throw new Error('기존 훅 형식을 읽지 못했어요. 변경하지 않았어요.');
29
+ if (remove) {
30
+ config.hooks[event] = groups
31
+ .map((g) => ({ ...g, hooks: (g.hooks || []).filter((h) => h.command !== command) }))
32
+ .filter((g) => g.hooks.length);
33
+ if (!config.hooks[event].length) delete config.hooks[event];
34
+ } else if (!groups.some((g) => g.hooks?.some((h) => h.command === command))) {
35
+ config.hooks[event] = [...groups, { hooks: [{ type: 'command', command, timeout: 10 }] }];
36
+ }
37
+ return config;
38
+ }
39
+ export function installHooks(o) {
40
+ const dir = stateDir(o),
41
+ chosen = targets().filter((t) => !o.client || t.client.toLowerCase().startsWith(o.client.toLowerCase()));
42
+ if (!chosen.length) throw new Error('연결할 Claude Code 또는 Codex 설정 폴더를 찾지 못했어요.');
43
+ const packageRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
44
+ const runtime = path.join(dir, 'runtime', VERSION);
45
+ fs.mkdirSync(runtime, { recursive: true });
46
+ for (const part of ['src', 'bin'])
47
+ if (path.resolve(packageRoot) !== path.resolve(runtime))
48
+ fs.cpSync(path.join(packageRoot, part), path.join(runtime, part), { recursive: true });
49
+ const command = `${quote(process.execPath)} ${quote(path.join(runtime, 'bin', 'lostvibe.mjs'))} hook --out ${quote(dir)}`;
50
+ const registryFile = path.join(dir, 'hooks.json');
51
+ const registry = load(registryFile, { entries: [] });
52
+ // Validate all files before changing any. Keep other hooks and notify untouched.
53
+ const configs = new Map();
54
+ for (const t of chosen) if (!configs.has(t.file)) configs.set(t.file, load(t.file));
55
+ for (const t of chosen) {
56
+ const config = configs.get(t.file);
57
+ for (const old of registry.entries.filter((e) => e.file === t.file && e.event === t.event))
58
+ ownGroups(config, t.event, old.command, true);
59
+ ownGroups(config, t.event, command);
60
+ }
61
+ for (const [file, config] of configs) {
62
+ fs.mkdirSync(path.dirname(file), { recursive: true });
63
+ if (fs.existsSync(file) && !fs.existsSync(file + '.lostvibe-backup'))
64
+ fs.copyFileSync(file, file + '.lostvibe-backup');
65
+ saveJson(file, config);
66
+ }
67
+ registry.entries = [
68
+ ...registry.entries.filter((e) => !chosen.some((t) => t.file === e.file && t.event === e.event)),
69
+ ...chosen.map((t) => ({ ...t, command })),
70
+ ];
71
+ saveJson(registryFile, registry);
72
+ return [...new Set(chosen.map((t) => t.client))];
73
+ }
74
+ export function removeHooks(o) {
75
+ const file = path.join(stateDir(o), 'hooks.json');
76
+ const registry = load(file, { entries: [] });
77
+ const keep = [];
78
+ for (const e of registry.entries) {
79
+ if (o.client && !e.client.toLowerCase().startsWith(o.client.toLowerCase())) {
80
+ keep.push(e);
81
+ continue;
82
+ }
83
+ if (fs.existsSync(e.file)) {
84
+ const c = load(e.file);
85
+ saveJson(e.file, ownGroups(c, e.event, e.command, true));
86
+ }
87
+ }
88
+ registry.entries = keep;
89
+ saveJson(file, registry);
90
+ }
91
+ export function hookStatus(o) {
92
+ const registry = load(path.join(stateDir(o), 'hooks.json'), { entries: [] });
93
+ return registry.entries.map((e) => ({
94
+ ...e,
95
+ installed:
96
+ load(e.file).hooks?.[e.event]?.some((g) => g.hooks?.some((h) => h.command === e.command)) || false,
97
+ }));
98
+ }