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.
- package/LICENSE +21 -0
- package/README.md +63 -0
- package/bin/rookie.js +6 -0
- package/package.json +40 -0
- package/src/api.js +67 -0
- package/src/cli.js +139 -0
- package/src/commands/cluster-todos.js +74 -0
- package/src/commands/codex-setup.js +36 -0
- package/src/commands/context-diet.js +80 -0
- package/src/commands/doctor.js +710 -0
- package/src/commands/done-scan.js +162 -0
- package/src/commands/init.js +175 -0
- package/src/commands/link.js +53 -0
- package/src/commands/login.js +160 -0
- package/src/commands/orphan-extract.js +44 -0
- package/src/commands/save.js +91 -0
- package/src/commands/scan-specs.js +153 -0
- package/src/commands/status.js +44 -0
- package/src/commands/sweep-recovery.js +33 -0
- package/src/commands/token.js +105 -0
- package/src/commands/unlink.js +40 -0
- package/src/commands/update.js +220 -0
- package/src/config.js +65 -0
- package/src/detect.js +109 -0
- package/src/lib/account-ui.js +28 -0
- package/src/lib/apply-tool-setup.js +89 -0
- package/src/lib/codex-config.js +46 -0
- package/src/lib/connect-ui.js +59 -0
- package/src/lib/context-diet.js +628 -0
- package/src/lib/dead-letter.js +73 -0
- package/src/lib/deploy-artifacts.js +185 -0
- package/src/lib/hook-command.js +31 -0
- package/src/lib/manifest.js +87 -0
- package/src/lib/merge-hooks.js +31 -0
- package/src/lib/orphan-extract-core.js +113 -0
- package/src/lib/orphan-pending.js +49 -0
- package/src/lib/parse-hook-cwd.js +11 -0
- package/src/lib/poll-state.js +14 -0
- package/src/lib/redact-lite.js +55 -0
- package/src/lib/resolve-endpoint.js +8 -0
- package/src/lib/self-update.js +79 -0
- package/src/lib/skill-installer.js +55 -0
- package/src/lib/spec-scanner.js +85 -0
- package/src/lib/sweep-core.mjs +423 -0
- package/src/lib/task-path-match.js +122 -0
- package/src/prompt.js +60 -0
- package/templates/hook/bin/posttool-recall-nudge.sh +96 -0
- package/templates/hook/bin/pretool-md-read.sh +107 -0
- package/templates/hook/flush-session.sh +314 -0
- package/templates/hook/guard-destructive-db.sh +24 -0
- package/templates/hook/lib/config-resolve.sh +106 -0
- package/templates/hook/lib/cross-runtime-sweep.sh +202 -0
- package/templates/hook/lib/dispatcher.sh +423 -0
- package/templates/hook/lib/orphan-recovery.sh +491 -0
- package/templates/hook/lib/queue.sh +314 -0
- package/templates/hook/lib/skill-gate.sh +28 -0
- package/templates/hook/lib/transcript.sh +166 -0
- package/templates/hook/refresh-context.sh +163 -0
- package/templates/hook/session-start.sh +417 -0
- package/templates/hook/settings.partial.json +55 -0
- package/templates/hook/sweep-recovery.sh +43 -0
- package/templates/hook/user-prompt-skill-gate.sh +90 -0
- package/templates/launchd/com.rollingmango.rookie-sweep.plist +38 -0
- package/templates/skill/SKILL.md +214 -0
- 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
|
+
}
|