therookie 0.4.3

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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +63 -0
  3. package/bin/rookie.js +6 -0
  4. package/package.json +40 -0
  5. package/src/api.js +67 -0
  6. package/src/cli.js +139 -0
  7. package/src/commands/cluster-todos.js +74 -0
  8. package/src/commands/codex-setup.js +36 -0
  9. package/src/commands/context-diet.js +80 -0
  10. package/src/commands/doctor.js +710 -0
  11. package/src/commands/done-scan.js +162 -0
  12. package/src/commands/init.js +175 -0
  13. package/src/commands/link.js +53 -0
  14. package/src/commands/login.js +160 -0
  15. package/src/commands/orphan-extract.js +44 -0
  16. package/src/commands/save.js +91 -0
  17. package/src/commands/scan-specs.js +153 -0
  18. package/src/commands/status.js +44 -0
  19. package/src/commands/sweep-recovery.js +33 -0
  20. package/src/commands/token.js +105 -0
  21. package/src/commands/unlink.js +40 -0
  22. package/src/commands/update.js +220 -0
  23. package/src/config.js +65 -0
  24. package/src/detect.js +109 -0
  25. package/src/lib/account-ui.js +28 -0
  26. package/src/lib/apply-tool-setup.js +89 -0
  27. package/src/lib/codex-config.js +46 -0
  28. package/src/lib/connect-ui.js +59 -0
  29. package/src/lib/context-diet.js +628 -0
  30. package/src/lib/dead-letter.js +73 -0
  31. package/src/lib/deploy-artifacts.js +185 -0
  32. package/src/lib/hook-command.js +31 -0
  33. package/src/lib/manifest.js +87 -0
  34. package/src/lib/merge-hooks.js +31 -0
  35. package/src/lib/orphan-extract-core.js +113 -0
  36. package/src/lib/orphan-pending.js +49 -0
  37. package/src/lib/parse-hook-cwd.js +11 -0
  38. package/src/lib/poll-state.js +14 -0
  39. package/src/lib/redact-lite.js +55 -0
  40. package/src/lib/resolve-endpoint.js +8 -0
  41. package/src/lib/self-update.js +79 -0
  42. package/src/lib/skill-installer.js +55 -0
  43. package/src/lib/spec-scanner.js +85 -0
  44. package/src/lib/sweep-core.mjs +423 -0
  45. package/src/lib/task-path-match.js +122 -0
  46. package/src/prompt.js +60 -0
  47. package/templates/hook/bin/posttool-recall-nudge.sh +96 -0
  48. package/templates/hook/bin/pretool-md-read.sh +107 -0
  49. package/templates/hook/flush-session.sh +314 -0
  50. package/templates/hook/guard-destructive-db.sh +24 -0
  51. package/templates/hook/lib/config-resolve.sh +106 -0
  52. package/templates/hook/lib/cross-runtime-sweep.sh +202 -0
  53. package/templates/hook/lib/dispatcher.sh +423 -0
  54. package/templates/hook/lib/orphan-recovery.sh +491 -0
  55. package/templates/hook/lib/queue.sh +314 -0
  56. package/templates/hook/lib/skill-gate.sh +28 -0
  57. package/templates/hook/lib/transcript.sh +166 -0
  58. package/templates/hook/refresh-context.sh +163 -0
  59. package/templates/hook/session-start.sh +417 -0
  60. package/templates/hook/settings.partial.json +55 -0
  61. package/templates/hook/sweep-recovery.sh +43 -0
  62. package/templates/hook/user-prompt-skill-gate.sh +90 -0
  63. package/templates/launchd/com.rollingmango.rookie-sweep.plist +38 -0
  64. package/templates/skill/SKILL.md +214 -0
  65. package/version.json +4 -0
@@ -0,0 +1,91 @@
1
+ // spec docs/superpowers/specs/2026-07-14-workspace-binding-and-unlinked-isolation.md W-D1
2
+ //
3
+ // `rookie save` — 저장 요청의 **워크스페이스 식별자를 기계가 주입한다**.
4
+ // 차차(LLM)가 curl 본문에 repo 정보를 손으로 붙이던 구조가 누수의 근원이었다(실측: evidence 51% 전역).
5
+ // CLI 프로세스는 자기 cwd 를 안다 — 잊을 수가 없다.
6
+ //
7
+ // 사용:
8
+ // rookie save --file body.json
9
+ // echo '<json>' | rookie save
10
+ // 플래그: --repo-path/--repo-url (의도적 override 전용), --quiet
11
+
12
+ import { execFileSync } from 'node:child_process'
13
+ import { readFileSync } from 'node:fs'
14
+ import { loadConfig, getHostname } from '../config.js'
15
+ import { apiPost } from '../api.js'
16
+
17
+ function readStdin() {
18
+ // TTY 면 파이프가 없다 — EOF 까지 무한 대기하지 않도록 즉시 빈 값으로 빠진다.
19
+ if (process.stdin.isTTY) return ''
20
+ try {
21
+ return readFileSync(0, 'utf8')
22
+ } catch {
23
+ return ''
24
+ }
25
+ }
26
+
27
+ export async function runSave(flags) {
28
+ const cfg = loadConfig()
29
+ if (!cfg) {
30
+ console.error('✗ 먼저 `rookie init` 을 실행하세요.')
31
+ process.exit(1)
32
+ }
33
+
34
+ const raw = flags.file ? readFileSync(flags.file, 'utf8') : readStdin()
35
+ if (!raw.trim()) {
36
+ console.error('✗ 저장할 본문이 없습니다 — `--file <body.json>` 또는 stdin 으로 주세요.')
37
+ process.exit(1)
38
+ }
39
+
40
+ let body
41
+ try {
42
+ body = JSON.parse(raw)
43
+ } catch (err) {
44
+ console.error(`✗ JSON 파싱 실패: ${err.message}`)
45
+ process.exit(1)
46
+ }
47
+
48
+ const repoPath = flags['repo-path'] || process.cwd()
49
+ let repoUrl = flags['repo-url'] || null
50
+ if (!repoUrl) {
51
+ try {
52
+ // execFileSync — shell 을 거치지 않는다. 경로에 $()·백틱이 있어도 실행되지 않는다.
53
+ repoUrl = execFileSync('git', ['-C', repoPath, 'remote', 'get-url', 'origin'], {
54
+ stdio: ['ignore', 'pipe', 'ignore'],
55
+ })
56
+ .toString()
57
+ .trim()
58
+ } catch {
59
+ repoUrl = null // remote 없는 로컬 전용 repo — repo_path 로 매칭한다.
60
+ }
61
+ }
62
+
63
+ // 워크스페이스 locator 는 **CLI 가 덮어쓴다**. 본문 값을 존중하면 차차가 복붙한 stale locator 가
64
+ // 다시 통과해, 이 명령이 없애려는 바로 그 의존이 살아난다(설계 R1-judgment).
65
+ // project_id(명시 귀속·명시 전역)는 locator 가 아니라 의사 표시이므로 건드리지 않는다.
66
+ body.repo_path = repoPath
67
+ if (repoUrl) body.repo_url = repoUrl
68
+ else delete body.repo_url
69
+
70
+ const res = await apiPost(cfg.endpoint, cfg.token, '/api/rookie/batch-mutate', body, {
71
+ headers: {
72
+ 'x-rookie-hostname': getHostname(),
73
+ 'x-rookie-surface': 'local_cli',
74
+ },
75
+ })
76
+
77
+ if (flags.quiet) return
78
+ const s = res.summary ?? {}
79
+ const parts = []
80
+ if (s.working_cards?.ids?.length) parts.push(`카드 ${s.working_cards.ids.length}`)
81
+ if (s.evidence?.applied) parts.push(`증거 ${s.evidence.applied}`)
82
+ if (s.self_rules?.applied) parts.push(`자기규칙 ${s.self_rules.applied}`)
83
+ if (s.verifications?.applied) parts.push(`검증 ${s.verifications.applied}`)
84
+ const label = parts.length > 0 ? parts.join(' · ') : '0건'
85
+
86
+ if (res.all_committed === false) {
87
+ console.error(`⚠ 일부 미저장 — ${label}`)
88
+ process.exit(1)
89
+ }
90
+ console.log(`✓ 저장: ${label}`)
91
+ }
@@ -0,0 +1,153 @@
1
+ import { execSync } from 'node:child_process'
2
+ import { readFileSync, statSync, readdirSync, writeFileSync, realpathSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { join, isAbsolute, relative, sep } from 'node:path'
5
+ import { loadConfig } from '../config.js'
6
+ import { apiGet, apiPost } from '../api.js'
7
+ import { parseFollowups } from '../lib/spec-scanner.js'
8
+
9
+ const THROTTLE_MS = 24 * 60 * 60 * 1000
10
+
11
+ function markerPath(projectId) {
12
+ return join(homedir(), '.rookie', `spec-scan-${projectId}.last`)
13
+ }
14
+ function throttled(projectId) {
15
+ try {
16
+ return Date.now() - statSync(markerPath(projectId)).mtimeMs < THROTTLE_MS
17
+ } catch {
18
+ return false
19
+ }
20
+ }
21
+ function touchMarker(projectId) {
22
+ try {
23
+ writeFileSync(markerPath(projectId), String(Date.now()))
24
+ } catch {
25
+ /* best-effort */
26
+ }
27
+ }
28
+ // repo 경계 가드 (codex P1): realpath 가 repoReal 내부인 경로만 허용.
29
+ // 절대경로·`../`·symlink 로 repo 밖 .md 를 서버로 유출하는 것을 차단(§10 민감정보 원칙).
30
+ function insideRepo(p, repoReal) {
31
+ try {
32
+ const real = realpathSync(p)
33
+ return real === repoReal || real.startsWith(repoReal + sep)
34
+ } catch {
35
+ return false
36
+ }
37
+ }
38
+ function walk(p, out, repoReal) {
39
+ let st
40
+ try {
41
+ st = statSync(p)
42
+ } catch {
43
+ return
44
+ }
45
+ if (st.isDirectory()) {
46
+ for (const e of readdirSync(p)) walk(join(p, e), out, repoReal)
47
+ } else if (/\.md$/.test(p) && insideRepo(p, repoReal)) {
48
+ out.add(p)
49
+ }
50
+ }
51
+ // spec_doc_paths(glob/디렉토리) → 실제 .md 파일 목록. 의존성 0 재귀 walk. repo 내부만.
52
+ function expandSpecFiles(repoPath, patterns) {
53
+ const out = new Set()
54
+ let repoReal
55
+ try {
56
+ repoReal = realpathSync(repoPath)
57
+ } catch {
58
+ return []
59
+ }
60
+ for (const pat of patterns || []) {
61
+ const abs = isAbsolute(pat) ? pat : join(repoPath, pat)
62
+ const base = abs.replace(/\/\*\*.*$/, '').replace(/\/\*.*$/, '')
63
+ // base 자체가 repo 밖이면 walk 안 함(realpath 기준). 안쪽이면 walk 중 파일별로 한 번 더 검증.
64
+ if (!insideRepo(base, repoReal)) continue
65
+ walk(base, out, repoReal)
66
+ }
67
+ return [...out]
68
+ }
69
+
70
+ export async function runScanSpecs(flags) {
71
+ const cfg = loadConfig()
72
+ if (!cfg) {
73
+ console.error('✗ 먼저 `rookie init` 을 실행하세요.')
74
+ process.exit(1)
75
+ }
76
+ const repoPath = process.cwd()
77
+ let repoUrl = ''
78
+ try {
79
+ repoUrl = execSync('git remote get-url origin', { stdio: ['ignore', 'pipe', 'ignore'] })
80
+ .toString()
81
+ .trim()
82
+ } catch {
83
+ /* git repo 아님 — repo_url 빈 값 */
84
+ }
85
+
86
+ // 1) current_project.id
87
+ const q = `repo_url=${encodeURIComponent(repoUrl)}&repo_path=${encodeURIComponent(repoPath)}`
88
+ let ctx
89
+ try {
90
+ ctx = await apiGet(cfg.endpoint, cfg.token, `/api/rookie/context?${q}`)
91
+ } catch (e) {
92
+ console.error(`✗ context 조회 실패: ${e.message}`)
93
+ return
94
+ }
95
+ const project = ctx && ctx.current_project
96
+ if (!project || !project.id) {
97
+ console.log('이 프로젝트는 미link — scan 생략')
98
+ return
99
+ }
100
+
101
+ // 2) throttle (세션 자동: 하루 1회). --force 무시.
102
+ if (!flags.force && throttled(project.id)) return
103
+
104
+ // 3) spec_doc_paths
105
+ let paths = []
106
+ try {
107
+ const sr = await apiGet(
108
+ cfg.endpoint,
109
+ cfg.token,
110
+ `/api/rookie/projects/spec-source?project_id=${encodeURIComponent(project.id)}`
111
+ )
112
+ paths = (sr && sr.spec_doc_paths) || []
113
+ } catch {
114
+ /* 미설정이면 빈 배열 */
115
+ }
116
+ if (!paths.length) {
117
+ console.log('spec_doc_paths 없음 — scan 생략')
118
+ touchMarker(project.id)
119
+ return
120
+ }
121
+
122
+ // 4) 로컬 파싱 → active candidates (deferred 는 기본 제외)
123
+ const files = expandSpecFiles(repoPath, paths)
124
+ const candidates = []
125
+ for (const f of files) {
126
+ const rel = relative(repoPath, f)
127
+ let md
128
+ try {
129
+ md = readFileSync(f, 'utf8')
130
+ } catch {
131
+ continue
132
+ }
133
+ for (const c of parseFollowups(rel, md)) {
134
+ if (c.klass === 'active') candidates.push(c)
135
+ }
136
+ }
137
+
138
+ // 5) 전송
139
+ try {
140
+ const r = await apiPost(cfg.endpoint, cfg.token, '/api/rookie/spec-followups/scan', {
141
+ project_id: project.id,
142
+ candidates,
143
+ })
144
+ touchMarker(project.id)
145
+ if (r && r.ok) {
146
+ console.log(
147
+ `[scan-specs] 후속 후보 ${candidates.length} 전송 — created ${r.created}, bumped ${r.bumped}, updated ${r.updated}, dup ${r.existing_task_dup ?? 0}, stale ${r.stale_swept}`
148
+ )
149
+ }
150
+ } catch (e) {
151
+ console.error(`✗ scan 전송 실패: ${e.message}`)
152
+ }
153
+ }
@@ -0,0 +1,44 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { execSync } from 'node:child_process'
5
+ import { resolveConfig, getHostname } from '../config.js'
6
+ import { apiGet } from '../api.js'
7
+ import { accountLine, accountWarning } from '../lib/account-ui.js'
8
+
9
+ export async function runStatus() {
10
+ console.log('== Rookie Status ==')
11
+ console.log(`hostname: ${getHostname()}`)
12
+ // B6-1: env > 로컬(상향 탐색) > 전역 해석 결과를 스코프와 함께 표시.
13
+ const resolved = resolveConfig()
14
+ const cfgLabel = resolved.scope === 'env' ? '(env 주입)' : resolved.path
15
+ console.log(`config: ${cfgLabel} ${resolved.config ? `✓ [${resolved.scope}]` : '(없음)'}`)
16
+
17
+ const cfg = resolved.config
18
+ // B1-2 (spec 2026-07-11 §B1): 계정 요약 1줄을 최상단 근처에 — "누구의 기억인가" 상시 노출.
19
+ if (cfg) {
20
+ try {
21
+ const res = await apiGet(cfg.endpoint, cfg.token, '/api/rookie/status')
22
+ const line = accountLine(res?.account)
23
+ if (line) console.log(line)
24
+ const warn = accountWarning(res?.account)
25
+ if (warn) console.log(warn)
26
+ console.log(`앱: ${res.version} / owner=${res.owner?.name ?? 'none'}`)
27
+ } catch (e) {
28
+ console.log(`앱: ✗ ${e.message}`)
29
+ }
30
+ }
31
+
32
+ const globalSkill = join(homedir(), '.claude', 'skills', 'rookie', 'SKILL.md')
33
+ const localSkill = join(process.cwd(), '.claude', 'skills', 'rookie', 'SKILL.md')
34
+ console.log(`글로벌 Skill: ${existsSync(globalSkill) ? '✓' : '✗'} ${globalSkill}`)
35
+ console.log(`로컬 Skill: ${existsSync(localSkill) ? '✓' : '✗'} ${localSkill}`)
36
+
37
+ let repoUrl = null
38
+ try {
39
+ repoUrl = execSync('git remote get-url origin', { stdio: ['ignore', 'pipe', 'ignore'] })
40
+ .toString().trim()
41
+ } catch {}
42
+ console.log(`현재 폴더: ${process.cwd()}`)
43
+ console.log(`repo_url: ${repoUrl ?? '(none)'}`)
44
+ }
@@ -0,0 +1,33 @@
1
+ import { sweepAll as _sweepAll, sweepDueToday as _sweepDueToday, markSweptToday as _markSweptToday } from '../lib/sweep-core.mjs'
2
+ import { resolveConfig as _resolveConfig } from '../config.js'
3
+
4
+ const defaultDeps = {
5
+ sweepAll: _sweepAll,
6
+ sweepDueToday: _sweepDueToday,
7
+ markSweptToday: _markSweptToday,
8
+ resolveConfig: _resolveConfig,
9
+ }
10
+
11
+ export async function runSweepRecovery(flags = {}, deps = defaultDeps) {
12
+ const resolved = deps.resolveConfig()
13
+ const cfg = resolved.config
14
+ if (!cfg || !cfg.endpoint || !cfg.token) return
15
+
16
+ if (!flags.force && !deps.sweepDueToday({})) return
17
+
18
+ try {
19
+ // B6-4 (spec 2026-07-11 §B6): 실행 config 경로를 partition 기준으로 전달 — 후보 transcript
20
+ // 의 cwd 가 다른 config(로컬 .rookie/) 소속이면 sweep-core 가 skip. env 주입은 'env'
21
+ // (파일 config 와 절대 일치하지 않아 전 후보 skip — 판별 불가 시 보수적 보류).
22
+ const res = await deps.sweepAll({
23
+ endpoint: cfg.endpoint,
24
+ token: cfg.token,
25
+ userId: cfg.user_id || '',
26
+ opts: { activeConfigPath: resolved.path ?? 'env' },
27
+ })
28
+ deps.markSweptToday({})
29
+ console.log(`[sweep] 복구 ${res.total}건 (Claude ${res.claude} / Codex ${res.codex})`)
30
+ } catch {
31
+ // opportunistic sweep: 실패는 조용히 무시(다음 기회에 marker 기반 재시도)
32
+ }
33
+ }
@@ -0,0 +1,105 @@
1
+ import { resolveConfig, saveConfig, configPath, getHostname } from '../config.js'
2
+ import { apiGet, apiPost, apiPostNoAuth } from '../api.js'
3
+ import { ask, askPassword } from '../prompt.js'
4
+
5
+ function requireResolvedConfig() {
6
+ const resolved = resolveConfig()
7
+ if (!resolved.config || !resolved.config.endpoint || !resolved.config.token) {
8
+ console.error(`config 없음 — 먼저 'rookie init'을 실행하세요. (${configPath()})`)
9
+ process.exit(2)
10
+ }
11
+ return resolved
12
+ }
13
+
14
+ function requireConfig() {
15
+ return requireResolvedConfig().config
16
+ }
17
+
18
+ function fmtDate(s) {
19
+ if (!s) return '-'
20
+ try { return new Date(s).toISOString().replace('T', ' ').slice(0, 19) } catch { return s }
21
+ }
22
+
23
+ export async function runTokenList() {
24
+ const cfg = requireConfig()
25
+ const { tokens } = await apiGet(cfg.endpoint, cfg.token, '/api/rookie/tokens')
26
+ if (!tokens.length) {
27
+ console.log('(발급된 토큰 없음)')
28
+ return
29
+ }
30
+ const header = ['id', 'label', 'created', 'last_used', 'revoked']
31
+ console.log(header.join('\t'))
32
+ for (const t of tokens) {
33
+ console.log([
34
+ t.id,
35
+ t.label ?? '-',
36
+ fmtDate(t.created_at),
37
+ fmtDate(t.last_used_at),
38
+ t.revoked_at ? fmtDate(t.revoked_at) : '-',
39
+ ].join('\t'))
40
+ }
41
+ }
42
+
43
+ export async function runTokenRevoke(flags) {
44
+ const cfg = requireConfig()
45
+ const id = flags.positional[0]
46
+ if (!id) {
47
+ console.error('사용법: rookie token revoke <id>')
48
+ process.exit(2)
49
+ }
50
+ await apiPost(cfg.endpoint, cfg.token, '/api/rookie/tokens/revoke', { id })
51
+ console.log(`✓ revoked: ${id}`)
52
+ }
53
+
54
+ // rotate: 새 PAT 발급(비밀번호 필요) → 기존 config 토큰은 별도 revoke.
55
+ export async function runTokenRotate() {
56
+ const resolved = requireResolvedConfig()
57
+ const cfg = resolved.config
58
+ const email = await ask('이메일')
59
+ const password = await askPassword('비밀번호:')
60
+ const label = await ask('새 토큰 라벨', { defaultValue: `${getHostname()}-rotated` })
61
+ const issued = await apiPostNoAuth(cfg.endpoint, '/api/rookie/tokens/bootstrap', {
62
+ email,
63
+ password,
64
+ label,
65
+ })
66
+ const oldToken = cfg.token
67
+ const next = { ...cfg, token: issued.token, user_id: issued.user_id, label: issued.label }
68
+ // B6-1: rotate 는 로드된 그 config 파일에 되쓴다 — 로컬 config 를 쓰던 디렉토리에서 rotate 가
69
+ // 전역을 덮어쓰지 않게. env 주입 세션(파일 없음)은 저장 불가 — 안내만.
70
+ if (resolved.path) {
71
+ saveConfig(next, { path: resolved.path })
72
+ console.log(`✓ 새 PAT 저장 (token_id=${issued.id}, ${resolved.scope === 'local' ? `로컬 ${resolved.path}` : '전역'})`)
73
+ } else {
74
+ console.log(`⚠ env(ROOKIE_TOKEN) 주입 세션 — 새 PAT 를 파일에 저장하지 않았습니다 (token_id=${issued.id}). 필요 시 rookie login 으로 저장하세요.`)
75
+ }
76
+ // 기존 PAT도 revoke — 새 PAT로 self-list 해서 자기 id 찾아야 하지만 bootstrap 응답엔 이전 토큰 id가 없다.
77
+ // self-list로 old token_id 찾아 revoke.
78
+ try {
79
+ const { tokens } = await apiGet(next.endpoint, next.token, '/api/rookie/tokens')
80
+ const oldHashPrefix = null // 평문 비교 불가 (서버는 해시만 저장).
81
+ // label/last_used 기준으로도 명확 식별 어렵다 → 방금 발급된 신규 토큰 외 모든 active 토큰을 revoke 할지 사용자 결정.
82
+ const activeOthers = tokens.filter((t) => !t.revoked_at && t.id !== issued.id)
83
+ if (activeOthers.length > 0) {
84
+ console.log(`남아 있는 활성 토큰 ${activeOthers.length}개:`)
85
+ for (const t of activeOthers) console.log(` - ${t.id} (${t.label ?? '-'}, created=${fmtDate(t.created_at)})`)
86
+ console.log('→ 기존 토큰 정리는 `rookie token revoke <id>`로 수동 실행.')
87
+ }
88
+ void oldToken; void oldHashPrefix
89
+ } catch (err) {
90
+ console.warn(`(self-list 실패: ${err.message})`)
91
+ }
92
+ }
93
+
94
+ export async function runToken(flags) {
95
+ const sub = flags.positional[0]
96
+ const rest = { ...flags, positional: flags.positional.slice(1) }
97
+ switch (sub) {
98
+ case 'list': return runTokenList()
99
+ case 'revoke': return runTokenRevoke(rest)
100
+ case 'rotate': return runTokenRotate()
101
+ default:
102
+ console.error('사용법: rookie token <list|revoke|rotate>')
103
+ process.exit(2)
104
+ }
105
+ }
@@ -0,0 +1,40 @@
1
+ import { rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { isRookieEntry } from '../lib/merge-hooks.js'
5
+
6
+ export async function runUnlink(flags) {
7
+ const scope = flags.local ? 'local' : flags.global ? 'global' : null
8
+ if (!scope) {
9
+ console.error('--local 또는 --global 중 하나를 지정하세요.')
10
+ process.exit(2)
11
+ }
12
+
13
+ const base = scope === 'global' ? join(homedir(), '.claude') : join(process.cwd(), '.claude')
14
+ const skillDir = join(base, 'skills', 'rookie')
15
+ if (existsSync(skillDir)) {
16
+ rmSync(skillDir, { recursive: true, force: true })
17
+ console.log(`✓ 삭제: ${skillDir}`)
18
+ } else {
19
+ console.log(`(없음) ${skillDir}`)
20
+ }
21
+
22
+ const settingsPath = join(base, 'settings.json')
23
+ if (existsSync(settingsPath)) {
24
+ try {
25
+ const current = JSON.parse(readFileSync(settingsPath, 'utf8'))
26
+ if (current.hooks) {
27
+ for (const ev of Object.keys(current.hooks)) {
28
+ // spec 2026-07-10 — Claude Code 가 __rookie_managed 마커를 제거할 수 있어
29
+ // 경로 서명 판정(isRookieEntry)으로 제거. 마커 소실 항목도 정리된다.
30
+ current.hooks[ev] = current.hooks[ev].filter((entry) => !isRookieEntry(entry))
31
+ if (current.hooks[ev].length === 0) delete current.hooks[ev]
32
+ }
33
+ }
34
+ writeFileSync(settingsPath, JSON.stringify(current, null, 2))
35
+ console.log(`✓ settings.json에서 rookie hook 제거: ${settingsPath}`)
36
+ } catch (e) {
37
+ console.warn(`settings.json 처리 실패: ${e.message}`)
38
+ }
39
+ }
40
+ }