@wooojin/forgen 0.5.5 → 0.5.7
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +129 -0
- package/README.ja.md +5 -1
- package/README.ko.md +6 -2
- package/README.md +6 -2
- package/README.zh.md +5 -1
- package/assets/claude/skills/verify/SKILL.md +66 -0
- package/assets/shared/hook-registry.json +10 -3
- package/dist/cli.js +7 -3
- package/dist/core/doctor.js +9 -1
- package/dist/core/health-cli.js +11 -12
- package/dist/core/paths.d.ts +1 -1
- package/dist/core/settings-lock.d.ts +7 -1
- package/dist/core/settings-lock.js +19 -4
- package/dist/core/status-cli.d.ts +1 -1
- package/dist/core/trust-layer-intent.d.ts +2 -2
- package/dist/core/uninstall.d.ts +12 -0
- package/dist/core/uninstall.js +83 -4
- package/dist/engine/solution-fixup.js +2 -2
- package/dist/engine/solution-format.js +4 -4
- package/dist/engine/solution-quarantine.js +2 -2
- package/dist/hooks/context-guard.d.ts +3 -0
- package/dist/hooks/context-guard.js +9 -5
- package/dist/hooks/hook-registry.d.ts +11 -0
- package/dist/hooks/hooks-generator.d.ts +2 -0
- package/dist/hooks/hooks-generator.js +5 -1
- package/dist/hooks/session-end.d.ts +10 -3
- package/dist/hooks/session-end.js +17 -4
- package/dist/host/codex-adapter.js +5 -0
- package/dist/host/codex-hook-alive.d.ts +23 -0
- package/dist/host/codex-hook-alive.js +51 -0
- package/dist/host/codex-notify.d.ts +55 -0
- package/dist/host/codex-notify.js +153 -0
- package/dist/host/codex-rollout.d.ts +21 -0
- package/dist/host/codex-rollout.js +82 -0
- package/dist/host/exec-host.js +5 -1
- package/dist/host/install-claude.d.ts +14 -0
- package/dist/host/install-claude.js +52 -0
- package/dist/host/install-codex.d.ts +100 -7
- package/dist/host/install-codex.js +391 -63
- package/dist/host/install-orchestrator.d.ts +4 -0
- package/dist/host/install-orchestrator.js +23 -3
- package/dist/host/managed-marker.d.ts +23 -0
- package/dist/host/managed-marker.js +78 -0
- package/dist/host/opencode/plugin/forgen.d.ts +1 -1
- package/dist/host/uninstall-codex.d.ts +58 -0
- package/dist/host/uninstall-codex.js +246 -0
- package/package.json +5 -6
- package/plugin.json +1 -1
|
@@ -97,10 +97,10 @@ export async function runInstall(opts) {
|
|
|
97
97
|
const dryRun = opts.dryRun ?? false;
|
|
98
98
|
const registerMcp = opts.registerMcp ?? true;
|
|
99
99
|
if (target === 'claude' || target === 'both') {
|
|
100
|
-
result.claude = planClaudeInstall({ pkgRoot: opts.pkgRoot, dryRun, registerMcp });
|
|
100
|
+
result.claude = planClaudeInstall({ pkgRoot: opts.pkgRoot, dryRun, registerMcp, installVerifySkill: opts.installVerifySkill });
|
|
101
101
|
}
|
|
102
102
|
if (target === 'codex' || target === 'both') {
|
|
103
|
-
result.codex = planCodexInstall({ pkgRoot: opts.pkgRoot, dryRun, registerMcp });
|
|
103
|
+
result.codex = planCodexInstall({ pkgRoot: opts.pkgRoot, dryRun, registerMcp, registerNotify: opts.registerNotify });
|
|
104
104
|
}
|
|
105
105
|
// W3-3: opencode 는 명시 타겟일 때만 설치('both' 는 claude+codex primary-pair 유지).
|
|
106
106
|
if (target === 'opencode') {
|
|
@@ -120,6 +120,12 @@ export function renderResult(result, dryRun) {
|
|
|
120
120
|
lines.push(` settings.json hooks: ${result.claude.hooksInjected}`);
|
|
121
121
|
lines.push(` MCP: ${result.claude.mcpAlreadyPresent ? 'already present' : (result.claude.mcpRegistered ? 'registered' : 'skipped')}`);
|
|
122
122
|
lines.push(` skills: ${result.claude.skillsInstalled ?? 0} installed → ${result.claude.skillsPath ?? ''}`);
|
|
123
|
+
const verifyLine = {
|
|
124
|
+
installed: 'installed → ~/.claude/skills/verify (Claude runs it right before non-docs commits)',
|
|
125
|
+
'user-owned': 'skipped — you already have your own `verify` skill (kept as is)',
|
|
126
|
+
skipped: 'skipped (--no-verify-skill)',
|
|
127
|
+
};
|
|
128
|
+
lines.push(` verify skill: ${verifyLine[result.claude.verifySkill]}`);
|
|
123
129
|
}
|
|
124
130
|
if (result.codex) {
|
|
125
131
|
lines.push('');
|
|
@@ -135,8 +141,22 @@ export function renderResult(result, dryRun) {
|
|
|
135
141
|
lines.push(` hook trust: ${t.trusted}/${t.total} trusted${t.ignoredByCodex.length ? ` (+${t.ignoredByCodex.length} Claude-only event ignored by Codex)` : ''}`);
|
|
136
142
|
}
|
|
137
143
|
else {
|
|
138
|
-
|
|
144
|
+
const pending = [...t.modified.map((k) => `${k} (modified)`), ...t.untrusted.map((k) => `${k} (new)`), ...t.disabled.map((k) => `${k} (disabled)`)];
|
|
145
|
+
lines.push(` hook trust: ${t.trusted}/${t.total} trusted — ${pending.length} hook(s) need review: ${pending.slice(0, 6).join(', ')}${pending.length > 6 ? ', …' : ''}`);
|
|
146
|
+
lines.push(' Codex skips these until approved/enabled: run `/hooks` inside codex and trust the forgen entries.');
|
|
147
|
+
if (t.modified.some((k) => k.startsWith('session_start:'))) {
|
|
148
|
+
lines.push(' ⚠ session_start is pending — the <forgen-rules> block is NOT injected into Codex sessions until you approve it.');
|
|
149
|
+
}
|
|
139
150
|
}
|
|
151
|
+
const notifyLine = {
|
|
152
|
+
installed: 'registered (turn-complete fallback — runs even while hooks are untrusted)',
|
|
153
|
+
'already-present': 'already present',
|
|
154
|
+
'user-defined': 'skipped — your config.toml already defines `notify` (kept as is). To chain forgen: notify = ["node", "<pkgRoot>/dist/host/codex-notify.js", "--", <your argv…>]',
|
|
155
|
+
'custom-block': 'left as is — the forgen notify block in config.toml was hand-edited into a form forgen will not rewrite (keep it on one line as a JSON array to let forgen manage it)',
|
|
156
|
+
skipped: 'skipped (--no-notify)',
|
|
157
|
+
removed: 'removed (--no-notify)',
|
|
158
|
+
};
|
|
159
|
+
lines.push(` notify: ${notifyLine[result.codex.notify]}`);
|
|
140
160
|
}
|
|
141
161
|
if (result.opencode) {
|
|
142
162
|
lines.push('');
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* forgen-managed 소유 판정 — install/uninstall 공용.
|
|
3
|
+
*
|
|
4
|
+
* "이 파일을 forgen 이 썼는가" 를 잘못 판정하면 사용자 파일을 덮어쓰거나 지운다. 그래서 마커가 *정해진
|
|
5
|
+
* 위치* 에 있을 때만 forgen 소유로 본다 (본문에 마커 문자열을 인용한 사용자 파일과 구분).
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* SKILL.md: frontmatter(`---` … 첫 번째 닫는 `---`) **바로 뒤** 에 마커가 있어야 한다.
|
|
9
|
+
* (이전의 lazy 정규식은 본문의 `---` 구분선 뒤에 나오는 마커까지 매치했다 — critic 2026-10-02.)
|
|
10
|
+
*/
|
|
11
|
+
export declare function hasManagedSkillMarker(content: string): boolean;
|
|
12
|
+
/** Codex agent TOML: 첫 줄이 정확히 마커여야 한다 (`# forgen-managed-by-me` 같은 접두 일치는 사용자 파일). */
|
|
13
|
+
export declare function isManagedAgentToml(content: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* 패키지가 실제로 제공하는 dev-guide 스킬 이름 (`forgen-<stack>-<skill>`).
|
|
16
|
+
* uninstall 은 이름 패턴이 아니라 이 목록으로 소유를 판정한다 — 사용자가 만든 `forgen-react-mine` 을 지우지 않게.
|
|
17
|
+
*/
|
|
18
|
+
export declare function listDevGuideSkillNames(pkgRoot: string): Set<string>;
|
|
19
|
+
/**
|
|
20
|
+
* `<skillsDir>/<name>/SKILL.md` 한 개를 제거하고 디렉토리가 비면 디렉토리도 지운다.
|
|
21
|
+
* 사용자가 그 디렉토리에 다른 파일을 넣어 뒀으면 디렉토리는 남는다. 실제로 SKILL.md 를 지웠을 때만 true.
|
|
22
|
+
*/
|
|
23
|
+
export declare function removeSkillFile(skillsDir: string, name: string, dryRun: boolean): boolean;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* forgen-managed 소유 판정 — install/uninstall 공용.
|
|
3
|
+
*
|
|
4
|
+
* "이 파일을 forgen 이 썼는가" 를 잘못 판정하면 사용자 파일을 덮어쓰거나 지운다. 그래서 마커가 *정해진
|
|
5
|
+
* 위치* 에 있을 때만 forgen 소유로 본다 (본문에 마커 문자열을 인용한 사용자 파일과 구분).
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
const SKILL_MARKER = '<!-- forgen-managed -->';
|
|
10
|
+
const AGENT_TOML_MARKER = '# forgen-managed';
|
|
11
|
+
/**
|
|
12
|
+
* SKILL.md: frontmatter(`---` … 첫 번째 닫는 `---`) **바로 뒤** 에 마커가 있어야 한다.
|
|
13
|
+
* (이전의 lazy 정규식은 본문의 `---` 구분선 뒤에 나오는 마커까지 매치했다 — critic 2026-10-02.)
|
|
14
|
+
*/
|
|
15
|
+
export function hasManagedSkillMarker(content) {
|
|
16
|
+
const text = content.replace(/\r\n/g, '\n');
|
|
17
|
+
if (!text.startsWith('---\n'))
|
|
18
|
+
return false;
|
|
19
|
+
const close = text.indexOf('\n---\n', 3);
|
|
20
|
+
if (close === -1)
|
|
21
|
+
return false;
|
|
22
|
+
return text.slice(close + 5).trimStart().startsWith(SKILL_MARKER);
|
|
23
|
+
}
|
|
24
|
+
/** Codex agent TOML: 첫 줄이 정확히 마커여야 한다 (`# forgen-managed-by-me` 같은 접두 일치는 사용자 파일). */
|
|
25
|
+
export function isManagedAgentToml(content) {
|
|
26
|
+
return content.split('\n', 1)[0].replace(/\r$/, '') === AGENT_TOML_MARKER;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 패키지가 실제로 제공하는 dev-guide 스킬 이름 (`forgen-<stack>-<skill>`).
|
|
30
|
+
* uninstall 은 이름 패턴이 아니라 이 목록으로 소유를 판정한다 — 사용자가 만든 `forgen-react-mine` 을 지우지 않게.
|
|
31
|
+
*/
|
|
32
|
+
export function listDevGuideSkillNames(pkgRoot) {
|
|
33
|
+
const names = new Set();
|
|
34
|
+
const root = path.join(pkgRoot, 'assets', 'dev-guide');
|
|
35
|
+
try {
|
|
36
|
+
for (const tier of fs.readdirSync(root)) {
|
|
37
|
+
const skillsBase = path.join(root, tier, 'skills');
|
|
38
|
+
if (!fs.existsSync(skillsBase))
|
|
39
|
+
continue;
|
|
40
|
+
for (const stack of fs.readdirSync(skillsBase)) {
|
|
41
|
+
const stackDir = path.join(skillsBase, stack);
|
|
42
|
+
if (!fs.statSync(stackDir).isDirectory())
|
|
43
|
+
continue;
|
|
44
|
+
for (const skill of fs.readdirSync(stackDir)) {
|
|
45
|
+
if (fs.existsSync(path.join(stackDir, skill, 'SKILL.md')))
|
|
46
|
+
names.add(`forgen-${stack}-${skill}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch { /* 자산 없음 */ }
|
|
52
|
+
return names;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* `<skillsDir>/<name>/SKILL.md` 한 개를 제거하고 디렉토리가 비면 디렉토리도 지운다.
|
|
56
|
+
* 사용자가 그 디렉토리에 다른 파일을 넣어 뒀으면 디렉토리는 남는다. 실제로 SKILL.md 를 지웠을 때만 true.
|
|
57
|
+
*/
|
|
58
|
+
export function removeSkillFile(skillsDir, name, dryRun) {
|
|
59
|
+
const dir = path.join(skillsDir, name);
|
|
60
|
+
const file = path.join(dir, 'SKILL.md');
|
|
61
|
+
try {
|
|
62
|
+
if (fs.lstatSync(dir).isSymbolicLink() || !fs.statSync(dir).isDirectory())
|
|
63
|
+
return false;
|
|
64
|
+
fs.lstatSync(file); // 없으면 throw
|
|
65
|
+
if (dryRun)
|
|
66
|
+
return true;
|
|
67
|
+
fs.unlinkSync(file);
|
|
68
|
+
try {
|
|
69
|
+
if (fs.readdirSync(dir).length === 0)
|
|
70
|
+
fs.rmdirSync(dir);
|
|
71
|
+
}
|
|
72
|
+
catch { /* ignore */ }
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { type ClaudePreToolInput, type OpencodeToolDecision } from '../translate.js';
|
|
16
16
|
/** PreToolUse 가드 순서: pre-tool-use(룰+위험명령 디스패처) → db-guard(rm -rf/위험 SQL). */
|
|
17
|
-
export declare const PRE_TOOL_GUARDS: readonly [
|
|
17
|
+
export declare const PRE_TOOL_GUARDS: readonly ['pre-tool-use.js', 'db-guard.js'];
|
|
18
18
|
export interface GuardRunOptions {
|
|
19
19
|
/** forgen hook 바이너리 디렉터리. guard-cli 가 forgen dist/hooks 를 주입. */
|
|
20
20
|
hookDir?: string;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex uninstall — ADR-016 D4
|
|
3
|
+
*
|
|
4
|
+
* `forgen install codex` 가 쓴 것을 되돌린다: hooks.json 의 forgen 훅, config.toml 의 MCP/notify 블록,
|
|
5
|
+
* `skills/`, `agents/ch-*.toml`, cwd 의 AGENTS.md 블록. 사용자 소유물(마커 없는 파일, 심링크, 다른 도구의
|
|
6
|
+
* 훅)은 보존한다.
|
|
7
|
+
*
|
|
8
|
+
* 훅 신뢰 보존: Codex 의 trust 키는 `<event>:<groupIdx>:<hookIdx>` 다. forgen 그룹을 지워 뒤따르는
|
|
9
|
+
* 사용자 그룹의 인덱스가 당겨지면 **다른 도구의 훅이 재승인 전까지 조용히 skip** 된다. 그래서 뒤에 사용자
|
|
10
|
+
* 그룹이 남는 위치의 forgen 그룹은 빈 그룹(`{"hooks": []}`)으로 남겨 인덱스를 유지한다 (Codex 0.153.4 는
|
|
11
|
+
* 빈 그룹을 경고 없이 받아들인다 — `hooks/list` 로 확인). forgen 은 `[hooks.state]` 를 고쳐 쓰지 않는다.
|
|
12
|
+
*/
|
|
13
|
+
import { type HooksFile } from './install-codex.js';
|
|
14
|
+
export interface CodexUninstallOptions {
|
|
15
|
+
/** forgen package root — 훅 command 소유 판정에 쓴다 (스크립트 시그니처 fallback 이 있어 정확도 비의존). */
|
|
16
|
+
pkgRoot: string;
|
|
17
|
+
/** codex home (default: $CODEX_HOME ?? ~/.codex). */
|
|
18
|
+
codexHome?: string;
|
|
19
|
+
/** AGENTS.md 위치 override (default: cwd 의 git root). 격리 테스트용. */
|
|
20
|
+
agentsMdPath?: string;
|
|
21
|
+
dryRun?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export interface CodexUninstallResult {
|
|
24
|
+
codexHome: string;
|
|
25
|
+
/** $CODEX_HOME 자체가 없으면 false — 나머지 필드는 전부 0/false */
|
|
26
|
+
present: boolean;
|
|
27
|
+
/** 제거한 forgen 훅 핸들러 수 */
|
|
28
|
+
hooksRemoved: number;
|
|
29
|
+
/** 보존한 사용자(다른 도구) 훅 핸들러 수 */
|
|
30
|
+
userHooksPreserved: number;
|
|
31
|
+
/** 뒤따르는 사용자 그룹의 trust 인덱스를 지키려고 남긴 빈 그룹 수 */
|
|
32
|
+
placeholderGroups: number;
|
|
33
|
+
/** 같은 그룹 안에서 forgen 핸들러가 빠져 인덱스가 바뀐 사용자 훅 (`<event>:<group>:<old>→<new>`) — `/hooks` 재승인 필요 */
|
|
34
|
+
userHooksReindexed: string[];
|
|
35
|
+
hooksFileDeleted: boolean;
|
|
36
|
+
mcpRemoved: boolean;
|
|
37
|
+
notifyRemoved: boolean;
|
|
38
|
+
/** forgen notify 블록이 손편집돼(여러 줄 배열 등) 안전하게 지울 수 없어 그대로 둠 */
|
|
39
|
+
notifyCustomLeft: boolean;
|
|
40
|
+
/** 블록에 체인돼 있던 사용자 notifier argv — 그것만으로 `notify` 를 되돌려 놓았다 */
|
|
41
|
+
notifyChainRestored: string[];
|
|
42
|
+
skillsRemoved: number;
|
|
43
|
+
agentsRemoved: number;
|
|
44
|
+
agentsMdCleaned: boolean;
|
|
45
|
+
/** 단계별 실패 (한 단계가 실패해도 나머지는 진행한다) */
|
|
46
|
+
errors: string[];
|
|
47
|
+
}
|
|
48
|
+
/** hooks.json 에서 forgen 핸들러 제거 (순수 함수 — 파일을 쓰지 않는다). */
|
|
49
|
+
export declare function stripForgenHooks(file: HooksFile, pkgRoot: string): {
|
|
50
|
+
next: HooksFile | null;
|
|
51
|
+
removed: number;
|
|
52
|
+
preserved: number;
|
|
53
|
+
placeholders: number;
|
|
54
|
+
reindexed: string[];
|
|
55
|
+
};
|
|
56
|
+
export declare function planCodexUninstall(opts: CodexUninstallOptions): CodexUninstallResult;
|
|
57
|
+
/** CLI 출력 — 무엇을 지웠고 무엇을 남겼는지. */
|
|
58
|
+
export declare function renderCodexUninstall(r: CodexUninstallResult): string[];
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex uninstall — ADR-016 D4
|
|
3
|
+
*
|
|
4
|
+
* `forgen install codex` 가 쓴 것을 되돌린다: hooks.json 의 forgen 훅, config.toml 의 MCP/notify 블록,
|
|
5
|
+
* `skills/`, `agents/ch-*.toml`, cwd 의 AGENTS.md 블록. 사용자 소유물(마커 없는 파일, 심링크, 다른 도구의
|
|
6
|
+
* 훅)은 보존한다.
|
|
7
|
+
*
|
|
8
|
+
* 훅 신뢰 보존: Codex 의 trust 키는 `<event>:<groupIdx>:<hookIdx>` 다. forgen 그룹을 지워 뒤따르는
|
|
9
|
+
* 사용자 그룹의 인덱스가 당겨지면 **다른 도구의 훅이 재승인 전까지 조용히 skip** 된다. 그래서 뒤에 사용자
|
|
10
|
+
* 그룹이 남는 위치의 forgen 그룹은 빈 그룹(`{"hooks": []}`)으로 남겨 인덱스를 유지한다 (Codex 0.153.4 는
|
|
11
|
+
* 빈 그룹을 경고 없이 받아들인다 — `hooks/list` 로 확인). forgen 은 `[hooks.state]` 를 고쳐 쓰지 않는다.
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from 'node:fs';
|
|
14
|
+
import * as os from 'node:os';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
import { AGENT_NAME_PREFIX, codexHookEventKey, isForgenHookCommand, removeForgenRulesFromAgentsMd, removeMcpBlock, removeNotifyBlock, resolveAgentsMdPath, } from './install-codex.js';
|
|
17
|
+
import { hasManagedSkillMarker, isManagedAgentToml, listDevGuideSkillNames, removeSkillFile } from './managed-marker.js';
|
|
18
|
+
const FORGEN_HOOKS_DESCRIPTION_RE = /^forgen Codex hooks \(managed/;
|
|
19
|
+
function isEmptyGroup(g) {
|
|
20
|
+
const hooks = g?.hooks;
|
|
21
|
+
return Array.isArray(hooks) && hooks.length === 0;
|
|
22
|
+
}
|
|
23
|
+
/** hooks.json 에서 forgen 핸들러 제거 (순수 함수 — 파일을 쓰지 않는다). */
|
|
24
|
+
export function stripForgenHooks(file, pkgRoot) {
|
|
25
|
+
let removed = 0;
|
|
26
|
+
let preserved = 0;
|
|
27
|
+
let placeholders = 0;
|
|
28
|
+
const reindexed = [];
|
|
29
|
+
const nextHooks = {};
|
|
30
|
+
for (const [event, rawGroups] of Object.entries(file.hooks ?? {})) {
|
|
31
|
+
if (!Array.isArray(rawGroups)) {
|
|
32
|
+
nextHooks[event] = rawGroups;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const groups = rawGroups.map((group, gi) => {
|
|
36
|
+
const g = group;
|
|
37
|
+
if (!Array.isArray(g?.hooks))
|
|
38
|
+
return group; // 모르는 형태 — 건드리지 않는다
|
|
39
|
+
const kept = [];
|
|
40
|
+
g.hooks.forEach((h, hi) => {
|
|
41
|
+
if (isForgenHookCommand(h?.command, pkgRoot)) {
|
|
42
|
+
removed += 1;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (kept.length !== hi)
|
|
46
|
+
reindexed.push(`${codexHookEventKey(event)}:${gi}:${hi}→${kept.length}`);
|
|
47
|
+
kept.push(h);
|
|
48
|
+
preserved += 1;
|
|
49
|
+
});
|
|
50
|
+
if (kept.length === g.hooks.length)
|
|
51
|
+
return group; // forgen 핸들러 없음 — 원본 그대로
|
|
52
|
+
// forgen 전용 그룹은 자리표시용 빈 그룹으로 (matcher 는 의미가 없어 뺀다)
|
|
53
|
+
return kept.length === 0 ? { hooks: [] } : { ...g, hooks: kept };
|
|
54
|
+
});
|
|
55
|
+
// 뒤에서부터 빈 그룹을 걷어낸다 — 뒤따르는 그룹이 없으면 인덱스를 지킬 이유가 없다.
|
|
56
|
+
while (groups.length > 0 && isEmptyGroup(groups[groups.length - 1]))
|
|
57
|
+
groups.pop();
|
|
58
|
+
if (groups.length === 0)
|
|
59
|
+
continue;
|
|
60
|
+
placeholders += groups.filter(isEmptyGroup).length;
|
|
61
|
+
nextHooks[event] = groups;
|
|
62
|
+
}
|
|
63
|
+
const { description, hooks: _hooks, ...rest } = file;
|
|
64
|
+
const keepDescription = typeof description === 'string' && !FORGEN_HOOKS_DESCRIPTION_RE.test(description);
|
|
65
|
+
const nothingLeft = Object.keys(nextHooks).length === 0 && Object.keys(rest).length === 0 && !keepDescription;
|
|
66
|
+
if (nothingLeft)
|
|
67
|
+
return { next: null, removed, preserved, placeholders, reindexed };
|
|
68
|
+
const next = { ...(keepDescription ? { description } : {}), ...rest, hooks: nextHooks };
|
|
69
|
+
return { next, removed, preserved, placeholders, reindexed };
|
|
70
|
+
}
|
|
71
|
+
function removeManagedSkills(skillsDir, pkgRoot, dryRun) {
|
|
72
|
+
let removed = 0;
|
|
73
|
+
let entries;
|
|
74
|
+
try {
|
|
75
|
+
entries = fs.readdirSync(skillsDir);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
// dev-guide 스킬은 이름 패턴이 아니라 패키지가 실제로 제공하는 이름으로 소유를 판정한다.
|
|
81
|
+
const devGuideNames = listDevGuideSkillNames(pkgRoot);
|
|
82
|
+
for (const name of entries) {
|
|
83
|
+
const skillFile = path.join(skillsDir, name, 'SKILL.md');
|
|
84
|
+
let owned = devGuideNames.has(name);
|
|
85
|
+
if (!owned) {
|
|
86
|
+
try {
|
|
87
|
+
owned = !fs.lstatSync(skillFile).isSymbolicLink() && hasManagedSkillMarker(fs.readFileSync(skillFile, 'utf-8'));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
owned = false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (owned && removeSkillFile(skillsDir, name, dryRun))
|
|
94
|
+
removed += 1;
|
|
95
|
+
}
|
|
96
|
+
return removed;
|
|
97
|
+
}
|
|
98
|
+
function removeManagedAgents(agentsDir, dryRun) {
|
|
99
|
+
let removed = 0;
|
|
100
|
+
let entries;
|
|
101
|
+
try {
|
|
102
|
+
entries = fs.readdirSync(agentsDir);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
if (!entry.startsWith(AGENT_NAME_PREFIX) || !entry.endsWith('.toml'))
|
|
109
|
+
continue;
|
|
110
|
+
const p = path.join(agentsDir, entry);
|
|
111
|
+
try {
|
|
112
|
+
if (fs.lstatSync(p).isSymbolicLink())
|
|
113
|
+
continue;
|
|
114
|
+
if (!isManagedAgentToml(fs.readFileSync(p, 'utf-8')))
|
|
115
|
+
continue;
|
|
116
|
+
if (!dryRun)
|
|
117
|
+
fs.unlinkSync(p);
|
|
118
|
+
removed += 1;
|
|
119
|
+
}
|
|
120
|
+
catch { /* best-effort */ }
|
|
121
|
+
}
|
|
122
|
+
return removed;
|
|
123
|
+
}
|
|
124
|
+
export function planCodexUninstall(opts) {
|
|
125
|
+
const codexHome = opts.codexHome ?? process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex');
|
|
126
|
+
const dryRun = opts.dryRun ?? false;
|
|
127
|
+
const result = {
|
|
128
|
+
codexHome,
|
|
129
|
+
present: fs.existsSync(codexHome),
|
|
130
|
+
hooksRemoved: 0,
|
|
131
|
+
userHooksPreserved: 0,
|
|
132
|
+
placeholderGroups: 0,
|
|
133
|
+
userHooksReindexed: [],
|
|
134
|
+
hooksFileDeleted: false,
|
|
135
|
+
mcpRemoved: false,
|
|
136
|
+
notifyRemoved: false,
|
|
137
|
+
notifyCustomLeft: false,
|
|
138
|
+
notifyChainRestored: [],
|
|
139
|
+
skillsRemoved: 0,
|
|
140
|
+
agentsRemoved: 0,
|
|
141
|
+
agentsMdCleaned: false,
|
|
142
|
+
errors: [],
|
|
143
|
+
};
|
|
144
|
+
if (!result.present)
|
|
145
|
+
return result;
|
|
146
|
+
// 각 단계는 독립이다 — 하나가 실패(읽기 전용 파일 등)해도 나머지 정리는 계속한다.
|
|
147
|
+
const step = (label, fn) => {
|
|
148
|
+
try {
|
|
149
|
+
fn();
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
result.errors.push(`${label}: ${e instanceof Error ? e.message : String(e)}`);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
// 1) hooks.json
|
|
156
|
+
step('hooks.json', () => {
|
|
157
|
+
const hooksPath = path.join(codexHome, 'hooks.json');
|
|
158
|
+
if (!fs.existsSync(hooksPath))
|
|
159
|
+
return;
|
|
160
|
+
let hooksFile;
|
|
161
|
+
try {
|
|
162
|
+
hooksFile = JSON.parse(fs.readFileSync(hooksPath, 'utf-8'));
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
throw new Error('not valid JSON — left untouched (remove forgen entries by hand)');
|
|
166
|
+
}
|
|
167
|
+
const hooks = hooksFile?.hooks;
|
|
168
|
+
if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks))
|
|
169
|
+
return;
|
|
170
|
+
const stripped = stripForgenHooks(hooksFile, opts.pkgRoot);
|
|
171
|
+
result.hooksRemoved = stripped.removed;
|
|
172
|
+
result.userHooksPreserved = stripped.preserved;
|
|
173
|
+
result.placeholderGroups = stripped.placeholders;
|
|
174
|
+
result.userHooksReindexed = stripped.reindexed;
|
|
175
|
+
if (stripped.removed === 0)
|
|
176
|
+
return;
|
|
177
|
+
result.hooksFileDeleted = stripped.next === null;
|
|
178
|
+
if (dryRun)
|
|
179
|
+
return;
|
|
180
|
+
if (stripped.next === null)
|
|
181
|
+
fs.unlinkSync(hooksPath);
|
|
182
|
+
else
|
|
183
|
+
fs.writeFileSync(hooksPath, `${JSON.stringify(stripped.next, null, 2)}\n`, 'utf-8');
|
|
184
|
+
});
|
|
185
|
+
// 2) config.toml — MCP / notify 블록
|
|
186
|
+
step('config.toml', () => {
|
|
187
|
+
const configTomlPath = path.join(codexHome, 'config.toml');
|
|
188
|
+
if (!fs.existsSync(configTomlPath))
|
|
189
|
+
return;
|
|
190
|
+
const current = fs.readFileSync(configTomlPath, 'utf-8');
|
|
191
|
+
const mcp = removeMcpBlock(current);
|
|
192
|
+
const notify = removeNotifyBlock(mcp.content);
|
|
193
|
+
result.mcpRemoved = mcp.removed;
|
|
194
|
+
result.notifyRemoved = notify.removed;
|
|
195
|
+
result.notifyCustomLeft = notify.custom;
|
|
196
|
+
result.notifyChainRestored = notify.restoredChain;
|
|
197
|
+
if (!dryRun && notify.content !== current)
|
|
198
|
+
fs.writeFileSync(configTomlPath, notify.content, 'utf-8');
|
|
199
|
+
});
|
|
200
|
+
// 3) skills / agents
|
|
201
|
+
step('skills', () => { result.skillsRemoved = removeManagedSkills(path.join(codexHome, 'skills'), opts.pkgRoot, dryRun); });
|
|
202
|
+
step('agents', () => { result.agentsRemoved = removeManagedAgents(path.join(codexHome, 'agents'), dryRun); });
|
|
203
|
+
// 4) AGENTS.md (cwd 의 git root)
|
|
204
|
+
step('AGENTS.md', () => {
|
|
205
|
+
const agentsMdPath = opts.agentsMdPath ?? resolveAgentsMdPath(opts.pkgRoot);
|
|
206
|
+
result.agentsMdCleaned = removeForgenRulesFromAgentsMd({ agentsMdPath, dryRun }).removed;
|
|
207
|
+
});
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
/** CLI 출력 — 무엇을 지웠고 무엇을 남겼는지. */
|
|
211
|
+
export function renderCodexUninstall(r) {
|
|
212
|
+
if (!r.present)
|
|
213
|
+
return [];
|
|
214
|
+
const lines = [];
|
|
215
|
+
if (r.hooksRemoved > 0) {
|
|
216
|
+
lines.push(` ✓ Removed ${r.hooksRemoved} forgen hook(s) from Codex hooks.json${r.hooksFileDeleted ? ' (file deleted — nothing else was in it)' : ` (kept ${r.userHooksPreserved} hook(s) from other tools)`}`);
|
|
217
|
+
if (r.placeholderGroups > 0) {
|
|
218
|
+
lines.push(` ↳ left ${r.placeholderGroups} empty group(s) so the hooks after them keep their Codex trust (deleting them means re-approving those hooks in /hooks)`);
|
|
219
|
+
}
|
|
220
|
+
if (r.userHooksReindexed.length > 0) {
|
|
221
|
+
lines.push(` ⚠ ${r.userHooksReindexed.length} hook(s) shared a group with forgen and moved (${r.userHooksReindexed.slice(0, 3).join(', ')}) — re-approve them in codex /hooks`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (r.mcpRemoved)
|
|
225
|
+
lines.push(' ✓ Removed forgen-compound MCP block from Codex config.toml');
|
|
226
|
+
if (r.notifyRemoved) {
|
|
227
|
+
lines.push(r.notifyChainRestored.length > 0
|
|
228
|
+
? ` ✓ Removed forgen notify wrapper from Codex config.toml — your chained notifier is kept: notify = ${JSON.stringify(r.notifyChainRestored)}`
|
|
229
|
+
: ' ✓ Removed forgen notify block from Codex config.toml');
|
|
230
|
+
}
|
|
231
|
+
if (r.notifyCustomLeft) {
|
|
232
|
+
lines.push(' ⚠ The forgen notify block in Codex config.toml was hand-edited (multi-line) — left in place. Remove the lines between the forgen-managed-notify markers yourself.');
|
|
233
|
+
}
|
|
234
|
+
if (r.skillsRemoved > 0)
|
|
235
|
+
lines.push(` ✓ Removed ${r.skillsRemoved} forgen skill(s) from ${path.join(r.codexHome, 'skills')}`);
|
|
236
|
+
if (r.agentsRemoved > 0)
|
|
237
|
+
lines.push(` ✓ Removed ${r.agentsRemoved} ch-*.toml agent(s) from ${path.join(r.codexHome, 'agents')}`);
|
|
238
|
+
if (r.agentsMdCleaned)
|
|
239
|
+
lines.push(' ✓ Removed forgen block from AGENTS.md');
|
|
240
|
+
for (const e of r.errors)
|
|
241
|
+
lines.push(` ✗ Codex cleanup — ${e}`);
|
|
242
|
+
if (lines.length > 0) {
|
|
243
|
+
lines.push(' note: AGENTS.md blocks in other projects where you ran `forgen install codex` are not touched — run `forgen uninstall` there or delete the forgen-managed-rules block.');
|
|
244
|
+
}
|
|
245
|
+
return lines;
|
|
246
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wooojin/forgen",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
4
4
|
"preferGlobal": true,
|
|
5
5
|
"main": "dist/lib.js",
|
|
6
6
|
"types": "./dist/lib.d.ts",
|
|
@@ -78,15 +78,14 @@
|
|
|
78
78
|
],
|
|
79
79
|
"devDependencies": {
|
|
80
80
|
"@biomejs/biome": "^2.4.16",
|
|
81
|
-
"@types/js-yaml": "^4.0.9",
|
|
82
81
|
"@types/node": "^26.1.1",
|
|
83
|
-
"@vitest/coverage-v8": "^
|
|
84
|
-
"typescript": "^
|
|
85
|
-
"vitest": "^
|
|
82
|
+
"@vitest/coverage-v8": "^5.0.2",
|
|
83
|
+
"typescript": "^7.0.2",
|
|
84
|
+
"vitest": "^5.0.2"
|
|
86
85
|
},
|
|
87
86
|
"dependencies": {
|
|
88
87
|
"@modelcontextprotocol/sdk": "^1.28.0",
|
|
89
|
-
"js-yaml": "^4.
|
|
88
|
+
"js-yaml": "^5.4.2",
|
|
90
89
|
"jsonc-parser": "^3.3.1",
|
|
91
90
|
"zod": "^4.4.3"
|
|
92
91
|
},
|
package/plugin.json
CHANGED