@wooojin/forgen 0.5.7 → 0.5.9

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.
@@ -9,6 +9,9 @@
9
9
  * (리뷰 HIGH: JSON.parse 가 공식 JSONC 를 clobber 하던 데이터 손실 수정).
10
10
  * 3. AGENTS.md: cwd 의 forgen rules block (Codex 헬퍼 재사용).
11
11
  */
12
+ export declare const PLUGIN_FILENAME = "forgen.ts";
13
+ export declare const PLUGIN_MARKER = "forgen-managed";
14
+ export declare const MCP_SERVER_NAME = "forgen-compound";
12
15
  export interface OpencodeInstallOptions {
13
16
  pkgRoot: string;
14
17
  dryRun?: boolean;
@@ -35,4 +38,6 @@ export interface OpencodeInstallResult {
35
38
  agentsMdInjected: boolean;
36
39
  }
37
40
  export declare function resolveOpencodeConfigDir(opts: OpencodeInstallOptions): string;
41
+ /** 기존 config 파일 경로 감지 — opencode.jsonc 우선, 없으면 opencode.json (둘 다 없으면 .json 신규). */
42
+ export declare function resolveConfigFilePath(configDir: string): string;
38
43
  export declare function planOpencodeInstall(opts: OpencodeInstallOptions): OpencodeInstallResult;
@@ -14,11 +14,11 @@ import * as os from 'node:os';
14
14
  import * as path from 'node:path';
15
15
  import { applyEdits, modify, parse as parseJsonc } from 'jsonc-parser';
16
16
  import { createLogger } from '../core/logger.js';
17
- import { resolveAgentsMdPath, upsertForgenRulesInAgentsMd } from './install-codex.js';
17
+ import { recordAgentsMdInstall, resolveAgentsMdPath, upsertForgenRulesInAgentsMd } from './install-codex.js';
18
18
  const log = createLogger('install-opencode');
19
- const PLUGIN_FILENAME = 'forgen.ts';
20
- const PLUGIN_MARKER = 'forgen-managed';
21
- const MCP_SERVER_NAME = 'forgen-compound';
19
+ export const PLUGIN_FILENAME = 'forgen.ts';
20
+ export const PLUGIN_MARKER = 'forgen-managed';
21
+ export const MCP_SERVER_NAME = 'forgen-compound';
22
22
  export function resolveOpencodeConfigDir(opts) {
23
23
  if (opts.opencodeConfigDir)
24
24
  return opts.opencodeConfigDir;
@@ -29,7 +29,7 @@ export function resolveOpencodeConfigDir(opts) {
29
29
  return path.join(base, 'opencode');
30
30
  }
31
31
  /** 기존 config 파일 경로 감지 — opencode.jsonc 우선, 없으면 opencode.json (둘 다 없으면 .json 신규). */
32
- function resolveConfigFilePath(configDir) {
32
+ export function resolveConfigFilePath(configDir) {
33
33
  const jsonc = path.join(configDir, 'opencode.jsonc');
34
34
  if (fs.existsSync(jsonc))
35
35
  return jsonc;
@@ -104,7 +104,8 @@ export function planOpencodeInstall(opts) {
104
104
  // 기존 plugin 이 forgen-managed 가 아니면(사용자 소유) 백업.
105
105
  if (!dryRun && fs.existsSync(pluginPath)) {
106
106
  const existing = fs.readFileSync(pluginPath, 'utf-8');
107
- if (!existing.includes(PLUGIN_MARKER)) {
107
+ // 첫 줄이 `// forgen-managed` 인 것만 forgen 소유 (본문에 문자열이 있다는 것만으로는 아님) — 그 외는 백업.
108
+ if (!existing.split('\n', 1)[0].trimStart().startsWith(`// ${PLUGIN_MARKER}`)) {
108
109
  const bak = `${pluginPath}.bak`;
109
110
  fs.copyFileSync(pluginPath, bak);
110
111
  result.pluginBackupPath = bak;
@@ -144,5 +145,7 @@ export function planOpencodeInstall(opts) {
144
145
  const agentsMdPath = opts.agentsMdPath ?? resolveAgentsMdPath(opts.pkgRoot);
145
146
  const agentsResult = upsertForgenRulesInAgentsMd({ agentsMdPath, pkgRoot: opts.pkgRoot, dryRun });
146
147
  result.agentsMdInjected = agentsResult.injected;
148
+ if (!dryRun)
149
+ recordAgentsMdInstall(configDir, agentsMdPath);
147
150
  return result;
148
151
  }
@@ -21,3 +21,25 @@ export declare function listDevGuideSkillNames(pkgRoot: string): Set<string>;
21
21
  * 사용자가 그 디렉토리에 다른 파일을 넣어 뒀으면 디렉토리는 남는다. 실제로 SKILL.md 를 지웠을 때만 true.
22
22
  */
23
23
  export declare function removeSkillFile(skillsDir: string, name: string, dryRun: boolean): boolean;
24
+ /**
25
+ * `<skillsDir>` 아래에서 forgen 이 설치한 dev-guide 스킬 디렉토리 이름을 고른다 (ADR-016 0.5.9).
26
+ *
27
+ * 이전의 stale 정리는 이름 패턴(`forgen-*`)만 보고 디렉토리를 재귀 삭제해 사용자가 만든 `forgen-notes`,
28
+ * `forgen-react-mine` 까지 지웠다. 소유 근거는 둘 중 하나여야 한다:
29
+ * (a) 패키지가 현재 제공하는 이름, 또는
30
+ * (b) SKILL.md 가 `…/assets/dev-guide/…` 를 가리키는 심링크 — 이전 버전이 설치한 것 (대상이 사라진
31
+ * dangling 링크여도 링크 문자열로 판정).
32
+ */
33
+ export declare function listOwnedDevGuideSkillDirs(skillsDir: string, pkgRoot: string): string[];
34
+ /** install 의 stale 정리: forgen 소유 dev-guide 스킬의 SKILL.md 만 지운다. 반환: 지운 수. */
35
+ export declare function removeOwnedDevGuideSkills(skillsDir: string, pkgRoot: string, dryRun?: boolean): number;
36
+ /**
37
+ * dev-guide 스킬 한 개를 `<skillsDir>/<name>/SKILL.md` 로 설치 (심링크 → 복사 폴백).
38
+ *
39
+ * **이미 있는 것은 절대 덮어쓰지 않는다.** stale 정리가 지나간 뒤에도 남아 있는 것은 forgen 소유가 아니다:
40
+ * - `<name>` 디렉토리가 심링크면(사용자 dotfiles 등) 그 안으로 따라 들어가 쓰지 않는다.
41
+ * - `SKILL.md` 가 남아 있으면(지울 수 없던 것, 디렉토리 등) 건드리지 않는다 — 특히 `copyFileSync` 는
42
+ * 심링크를 따라가 스킬 디렉토리 *밖* 의 사용자 파일까지 덮어쓴다 (0.5.9 critic M1).
43
+ * 한 스킬의 실패가 나머지 설치를 막지 않는다. 반환: 실제로 설치했는가.
44
+ */
45
+ export declare function installSkillFile(src: string, skillsDir: string, name: string): boolean;
@@ -76,3 +76,82 @@ export function removeSkillFile(skillsDir, name, dryRun) {
76
76
  return false;
77
77
  }
78
78
  }
79
+ /**
80
+ * `<skillsDir>` 아래에서 forgen 이 설치한 dev-guide 스킬 디렉토리 이름을 고른다 (ADR-016 0.5.9).
81
+ *
82
+ * 이전의 stale 정리는 이름 패턴(`forgen-*`)만 보고 디렉토리를 재귀 삭제해 사용자가 만든 `forgen-notes`,
83
+ * `forgen-react-mine` 까지 지웠다. 소유 근거는 둘 중 하나여야 한다:
84
+ * (a) 패키지가 현재 제공하는 이름, 또는
85
+ * (b) SKILL.md 가 `…/assets/dev-guide/…` 를 가리키는 심링크 — 이전 버전이 설치한 것 (대상이 사라진
86
+ * dangling 링크여도 링크 문자열로 판정).
87
+ */
88
+ export function listOwnedDevGuideSkillDirs(skillsDir, pkgRoot) {
89
+ const known = listDevGuideSkillNames(pkgRoot);
90
+ let entries;
91
+ try {
92
+ entries = fs.readdirSync(skillsDir);
93
+ }
94
+ catch {
95
+ return [];
96
+ }
97
+ return entries.filter((name) => {
98
+ if (!name.startsWith('forgen-'))
99
+ return false;
100
+ if (known.has(name))
101
+ return true;
102
+ try {
103
+ const target = fs.readlinkSync(path.join(skillsDir, name, 'SKILL.md'));
104
+ return /[\\/]assets[\\/]dev-guide[\\/]/.test(target);
105
+ }
106
+ catch {
107
+ return false; // 심링크가 아님 = 사용자가 쓴 파일이거나 없음
108
+ }
109
+ });
110
+ }
111
+ /** install 의 stale 정리: forgen 소유 dev-guide 스킬의 SKILL.md 만 지운다. 반환: 지운 수. */
112
+ export function removeOwnedDevGuideSkills(skillsDir, pkgRoot, dryRun = false) {
113
+ let removed = 0;
114
+ for (const name of listOwnedDevGuideSkillDirs(skillsDir, pkgRoot)) {
115
+ if (removeSkillFile(skillsDir, name, dryRun))
116
+ removed += 1;
117
+ }
118
+ return removed;
119
+ }
120
+ /**
121
+ * dev-guide 스킬 한 개를 `<skillsDir>/<name>/SKILL.md` 로 설치 (심링크 → 복사 폴백).
122
+ *
123
+ * **이미 있는 것은 절대 덮어쓰지 않는다.** stale 정리가 지나간 뒤에도 남아 있는 것은 forgen 소유가 아니다:
124
+ * - `<name>` 디렉토리가 심링크면(사용자 dotfiles 등) 그 안으로 따라 들어가 쓰지 않는다.
125
+ * - `SKILL.md` 가 남아 있으면(지울 수 없던 것, 디렉토리 등) 건드리지 않는다 — 특히 `copyFileSync` 는
126
+ * 심링크를 따라가 스킬 디렉토리 *밖* 의 사용자 파일까지 덮어쓴다 (0.5.9 critic M1).
127
+ * 한 스킬의 실패가 나머지 설치를 막지 않는다. 반환: 실제로 설치했는가.
128
+ */
129
+ export function installSkillFile(src, skillsDir, name) {
130
+ const dir = path.join(skillsDir, name);
131
+ const file = path.join(dir, 'SKILL.md');
132
+ try {
133
+ try {
134
+ if (fs.lstatSync(dir).isSymbolicLink())
135
+ return false;
136
+ }
137
+ catch { /* 없음 — 새로 만든다 */ }
138
+ fs.mkdirSync(dir, { recursive: true });
139
+ try {
140
+ fs.lstatSync(file);
141
+ return false; // 남아 있음 — 덮어쓰지 않는다
142
+ }
143
+ catch { /* 없음 — 설치 */ }
144
+ try {
145
+ fs.symlinkSync(src, file, 'file');
146
+ }
147
+ catch (e) {
148
+ const code = e.code ?? 'UNKNOWN';
149
+ process.stderr.write(`[forgen] symlink ${src} → ${file} failed (${code}); falling back to copyFile.\n`);
150
+ fs.copyFileSync(src, file, fs.constants.COPYFILE_EXCL);
151
+ }
152
+ return true;
153
+ }
154
+ catch {
155
+ return false;
156
+ }
157
+ }
@@ -42,6 +42,8 @@ export interface CodexUninstallResult {
42
42
  skillsRemoved: number;
43
43
  agentsRemoved: number;
44
44
  agentsMdCleaned: boolean;
45
+ /** forgen 블록을 걷어낸 AGENTS.md 경로 (설치 시 기록된 프로젝트들 + 지금의 cwd) */
46
+ agentsMdCleanedPaths: string[];
45
47
  /** 단계별 실패 (한 단계가 실패해도 나머지는 진행한다) */
46
48
  errors: string[];
47
49
  }
@@ -13,7 +13,7 @@
13
13
  import * as fs from 'node:fs';
14
14
  import * as os from 'node:os';
15
15
  import * as path from 'node:path';
16
- import { AGENT_NAME_PREFIX, codexHookEventKey, isForgenHookCommand, removeForgenRulesFromAgentsMd, removeMcpBlock, removeNotifyBlock, resolveAgentsMdPath, } from './install-codex.js';
16
+ import { AGENT_NAME_PREFIX, codexHookEventKey, isForgenHookCommand, removeForgenRulesEverywhere, removeMcpBlock, removeNotifyBlock, resolveAgentsMdPath, } from './install-codex.js';
17
17
  import { hasManagedSkillMarker, isManagedAgentToml, listDevGuideSkillNames, removeSkillFile } from './managed-marker.js';
18
18
  const FORGEN_HOOKS_DESCRIPTION_RE = /^forgen Codex hooks \(managed/;
19
19
  function isEmptyGroup(g) {
@@ -139,6 +139,7 @@ export function planCodexUninstall(opts) {
139
139
  skillsRemoved: 0,
140
140
  agentsRemoved: 0,
141
141
  agentsMdCleaned: false,
142
+ agentsMdCleanedPaths: [],
142
143
  errors: [],
143
144
  };
144
145
  if (!result.present)
@@ -200,10 +201,14 @@ export function planCodexUninstall(opts) {
200
201
  // 3) skills / agents
201
202
  step('skills', () => { result.skillsRemoved = removeManagedSkills(path.join(codexHome, 'skills'), opts.pkgRoot, dryRun); });
202
203
  step('agents', () => { result.agentsRemoved = removeManagedAgents(path.join(codexHome, 'agents'), dryRun); });
203
- // 4) AGENTS.md (cwd 의 git root)
204
+ // 4) AGENTS.md — 설치 때 기록된 프로젝트 전부 + 지금의 cwd (0.5.9 이전 설치분은 기록이 없어 cwd 만)
204
205
  step('AGENTS.md', () => {
205
- const agentsMdPath = opts.agentsMdPath ?? resolveAgentsMdPath(opts.pkgRoot);
206
- result.agentsMdCleaned = removeForgenRulesFromAgentsMd({ agentsMdPath, dryRun }).removed;
206
+ const cwdAgentsMdPath = opts.agentsMdPath ?? resolveAgentsMdPath(opts.pkgRoot);
207
+ const r = removeForgenRulesEverywhere({ hostDir: codexHome, cwdAgentsMdPath, dryRun });
208
+ result.agentsMdCleanedPaths = r.cleaned;
209
+ result.agentsMdCleaned = r.cleaned.length > 0;
210
+ for (const f of r.failed)
211
+ result.errors.push(`AGENTS.md ${f.path}: ${f.error} (kept in the install record — fix and re-run uninstall)`);
207
212
  });
208
213
  return result;
209
214
  }
@@ -235,12 +240,10 @@ export function renderCodexUninstall(r) {
235
240
  lines.push(` ✓ Removed ${r.skillsRemoved} forgen skill(s) from ${path.join(r.codexHome, 'skills')}`);
236
241
  if (r.agentsRemoved > 0)
237
242
  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');
243
+ if (r.agentsMdCleaned) {
244
+ lines.push(` ✓ Removed forgen block from ${r.agentsMdCleanedPaths.length} AGENTS.md file(s): ${r.agentsMdCleanedPaths.slice(0, 4).join(', ')}${r.agentsMdCleanedPaths.length > 4 ? ', …' : ''}`);
245
+ }
240
246
  for (const e of r.errors)
241
247
  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
248
  return lines;
246
249
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * OpenCode uninstall — ADR-016 0.5.9
3
+ *
4
+ * `forgen install opencode` 가 쓴 것을 되돌린다: `plugins/forgen.ts`, config(JSONC) 의 `mcp.forgen-compound`,
5
+ * AGENTS.md 블록. 사용자 소유물(마커 없는 plugin, 같은 이름의 다른 MCP 서버, 파싱 불가 config)은 건드리지 않는다.
6
+ * MCP 제거는 다른 키와 값을 보존하지만, forgen 항목에 붙어 있던 주석과 주변 포맷은 바뀔 수 있다.
7
+ */
8
+ export interface OpencodeUninstallOptions {
9
+ pkgRoot: string;
10
+ /** ~/.config/opencode override (격리 테스트용). */
11
+ opencodeConfigDir?: string;
12
+ /** AGENTS.md 위치 override (격리 테스트용). */
13
+ agentsMdPath?: string;
14
+ dryRun?: boolean;
15
+ }
16
+ export interface OpencodeUninstallResult {
17
+ configDir: string;
18
+ /** config 디렉토리가 없으면 false — 나머지는 전부 false/빈 값 */
19
+ present: boolean;
20
+ pluginRemoved: boolean;
21
+ /** 설치 때 백업해 둔 사용자 plugin(`forgen.ts.bak`)을 되돌렸는가 */
22
+ pluginRestoredFromBackup: boolean;
23
+ mcpRemoved: boolean;
24
+ /** config 가 유효한 JSONC 가 아니라 건드리지 않음 */
25
+ mcpSkippedUnparseable: boolean;
26
+ agentsMdCleanedPaths: string[];
27
+ errors: string[];
28
+ }
29
+ /** config 텍스트에서 `mcp.forgen-compound` 를 제거. 다른 키·서버는 보존, `mcp` 가 비면 그 키도 제거. */
30
+ export declare function removeOpencodeMcp(currentText: string): {
31
+ content: string;
32
+ removed: boolean;
33
+ unparseable: boolean;
34
+ };
35
+ export declare function planOpencodeUninstall(opts: OpencodeUninstallOptions): OpencodeUninstallResult;
36
+ export declare function renderOpencodeUninstall(r: OpencodeUninstallResult): string[];
@@ -0,0 +1,141 @@
1
+ /**
2
+ * OpenCode uninstall — ADR-016 0.5.9
3
+ *
4
+ * `forgen install opencode` 가 쓴 것을 되돌린다: `plugins/forgen.ts`, config(JSONC) 의 `mcp.forgen-compound`,
5
+ * AGENTS.md 블록. 사용자 소유물(마커 없는 plugin, 같은 이름의 다른 MCP 서버, 파싱 불가 config)은 건드리지 않는다.
6
+ * MCP 제거는 다른 키와 값을 보존하지만, forgen 항목에 붙어 있던 주석과 주변 포맷은 바뀔 수 있다.
7
+ */
8
+ import * as fs from 'node:fs';
9
+ import * as path from 'node:path';
10
+ import { applyEdits, modify, parse as parseJsonc } from 'jsonc-parser';
11
+ import { removeForgenRulesEverywhere, resolveAgentsMdPath } from './install-codex.js';
12
+ import { MCP_SERVER_NAME, PLUGIN_FILENAME, PLUGIN_MARKER, resolveOpencodeConfigDir } from './install-opencode.js';
13
+ /** forgen 이 등록한 MCP 항목인가: command 에 `…/dist/mcp/server.js` 와 `--host=opencode` 가 있다. */
14
+ function isForgenMcpEntry(entry) {
15
+ const command = entry?.command;
16
+ if (!Array.isArray(command))
17
+ return false;
18
+ return command.some((a) => typeof a === 'string' && /[\\/]dist[\\/]mcp[\\/]server\.js$/.test(a))
19
+ && command.includes('--host=opencode');
20
+ }
21
+ /** config 텍스트에서 `mcp.forgen-compound` 를 제거. 다른 키·서버는 보존, `mcp` 가 비면 그 키도 제거. */
22
+ export function removeOpencodeMcp(currentText) {
23
+ if (currentText.trim().length === 0)
24
+ return { content: currentText, removed: false, unparseable: false };
25
+ const errors = [];
26
+ const parsed = parseJsonc(currentText, errors, { allowTrailingComma: true });
27
+ if (errors.length > 0 || parsed === undefined || parsed === null || typeof parsed !== 'object') {
28
+ return { content: currentText, removed: false, unparseable: true };
29
+ }
30
+ if (!isForgenMcpEntry(parsed.mcp?.[MCP_SERVER_NAME]))
31
+ return { content: currentText, removed: false, unparseable: false };
32
+ const opts = { formattingOptions: { insertSpaces: true, tabSize: 2 } };
33
+ let out = applyEdits(currentText, modify(currentText, ['mcp', MCP_SERVER_NAME], undefined, opts));
34
+ if (Object.keys(parsed.mcp ?? {}).length === 1)
35
+ out = applyEdits(out, modify(out, ['mcp'], undefined, opts));
36
+ // 쓰기 전 검증: forgen 항목만 사라지고 다른 키/서버는 그대로여야 한다. (중복 `mcp` 키 같은 퇴화 입력에서
37
+ // 편집기가 다른 객체를 건드릴 수 있다 — 그러면 아무것도 쓰지 않는다.)
38
+ const after = parseJsonc(out, [], { allowTrailingComma: true });
39
+ const expected = JSON.parse(JSON.stringify(parsed));
40
+ if (expected.mcp) {
41
+ delete expected.mcp[MCP_SERVER_NAME];
42
+ if (Object.keys(expected.mcp).length === 0)
43
+ delete expected.mcp;
44
+ }
45
+ if (JSON.stringify(after) !== JSON.stringify(expected))
46
+ return { content: currentText, removed: false, unparseable: true };
47
+ return { content: out, removed: true, unparseable: false };
48
+ }
49
+ /**
50
+ * plugin 파일이 forgen 것인가: **첫 줄**이 `// forgen-managed` 로 시작해야 한다 (배포 자산의 형태).
51
+ * 본문 어딘가에 문자열이 있다는 것만으로는 소유로 보지 않는다 (`// NOT forgen-managed: my fork` 같은 사용자 파일 보호).
52
+ */
53
+ function isManagedPlugin(content) {
54
+ return content.split('\n', 1)[0].trimStart().startsWith(`// ${PLUGIN_MARKER}`);
55
+ }
56
+ export function planOpencodeUninstall(opts) {
57
+ const configDir = resolveOpencodeConfigDir({ pkgRoot: opts.pkgRoot, opencodeConfigDir: opts.opencodeConfigDir });
58
+ const dryRun = opts.dryRun ?? false;
59
+ const result = {
60
+ configDir,
61
+ present: fs.existsSync(configDir),
62
+ pluginRemoved: false,
63
+ pluginRestoredFromBackup: false,
64
+ mcpRemoved: false,
65
+ mcpSkippedUnparseable: false,
66
+ agentsMdCleanedPaths: [],
67
+ errors: [],
68
+ };
69
+ if (!result.present)
70
+ return result;
71
+ const step = (label, fn) => {
72
+ try {
73
+ fn();
74
+ }
75
+ catch (e) {
76
+ result.errors.push(`${label}: ${e instanceof Error ? e.message : String(e)}`);
77
+ }
78
+ };
79
+ // 1) plugin — forgen-managed 마커가 있는 것만. 설치가 백업한 사용자 plugin 이 있으면 되돌린다.
80
+ step('plugin', () => {
81
+ const pluginPath = path.join(configDir, 'plugins', PLUGIN_FILENAME);
82
+ if (!fs.existsSync(pluginPath))
83
+ return;
84
+ if (fs.lstatSync(pluginPath).isSymbolicLink() || !isManagedPlugin(fs.readFileSync(pluginPath, 'utf-8')))
85
+ return;
86
+ const bak = `${pluginPath}.bak`;
87
+ result.pluginRemoved = true;
88
+ // `.bak` 은 설치가 사용자 plugin 을 백업한 것일 때만 되돌린다 — forgen-managed 내용이면 사용자 원본이 아니다.
89
+ result.pluginRestoredFromBackup = fs.existsSync(bak) && !isManagedPlugin(fs.readFileSync(bak, 'utf-8'));
90
+ if (dryRun)
91
+ return;
92
+ if (result.pluginRestoredFromBackup)
93
+ fs.renameSync(bak, pluginPath);
94
+ else
95
+ fs.unlinkSync(pluginPath);
96
+ });
97
+ // 2) MCP — opencode.jsonc 와 opencode.json 둘 다 본다 (설치 후에 .jsonc 가 생겼을 수 있다)
98
+ for (const name of ['opencode.jsonc', 'opencode.json']) {
99
+ step(name, () => {
100
+ const configPath = path.join(configDir, name);
101
+ if (!fs.existsSync(configPath))
102
+ return;
103
+ const current = fs.readFileSync(configPath, 'utf-8');
104
+ const r = removeOpencodeMcp(current);
105
+ if (r.removed)
106
+ result.mcpRemoved = true;
107
+ if (r.unparseable)
108
+ result.mcpSkippedUnparseable = true;
109
+ if (!dryRun && r.removed)
110
+ fs.writeFileSync(configPath, r.content, 'utf-8');
111
+ });
112
+ }
113
+ // 3) AGENTS.md — 설치 때 기록된 프로젝트 전부 + 지금의 cwd
114
+ step('AGENTS.md', () => {
115
+ const cwdAgentsMdPath = opts.agentsMdPath ?? resolveAgentsMdPath(opts.pkgRoot);
116
+ const r = removeForgenRulesEverywhere({ hostDir: configDir, cwdAgentsMdPath, dryRun });
117
+ result.agentsMdCleanedPaths = r.cleaned;
118
+ for (const f of r.failed)
119
+ result.errors.push(`AGENTS.md ${f.path}: ${f.error} (kept in the install record — fix and re-run uninstall)`);
120
+ });
121
+ return result;
122
+ }
123
+ export function renderOpencodeUninstall(r) {
124
+ if (!r.present)
125
+ return [];
126
+ const lines = [];
127
+ if (r.pluginRemoved) {
128
+ lines.push(r.pluginRestoredFromBackup
129
+ ? ' ✓ Removed forgen OpenCode plugin and restored your original plugins/forgen.ts from the install-time backup'
130
+ : ' ✓ Removed forgen OpenCode plugin (plugins/forgen.ts)');
131
+ }
132
+ if (r.mcpRemoved)
133
+ lines.push(' ✓ Removed forgen-compound MCP entry from the OpenCode config');
134
+ if (r.mcpSkippedUnparseable)
135
+ lines.push(' ⚠ OpenCode config is not valid JSONC — left untouched (remove mcp.forgen-compound by hand)');
136
+ if (r.agentsMdCleanedPaths.length > 0)
137
+ lines.push(` ✓ Removed forgen block from ${r.agentsMdCleanedPaths.length} AGENTS.md file(s) (OpenCode installs)`);
138
+ for (const e of r.errors)
139
+ lines.push(` ✗ OpenCode cleanup — ${e}`);
140
+ return lines;
141
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wooojin/forgen",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
4
4
  "preferGlobal": true,
5
5
  "main": "dist/lib.js",
6
6
  "types": "./dist/lib.d.ts",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://claude.ai/schemas/claude-plugin.json",
3
3
  "name": "forgen",
4
- "version": "0.5.7",
4
+ "version": "0.5.9",
5
5
  "description": "Claude Code harness — the more you use Claude, the better it gets",
6
6
  "author": {
7
7
  "name": "jang-ujin",
@@ -15,7 +15,7 @@
15
15
  * - 실패해도 npm install을 깨뜨리지 않음 (silent failure)
16
16
  */
17
17
 
18
- import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync, rmSync, symlinkSync, cpSync, lstatSync, statSync, copyFileSync, renameSync } from 'node:fs';
18
+ import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync, rmSync, symlinkSync, cpSync, lstatSync, statSync, copyFileSync, renameSync, readlinkSync, unlinkSync, rmdirSync } from 'node:fs';
19
19
  import { join, dirname } from 'node:path';
20
20
  import { homedir, platform } from 'node:os';
21
21
  import { execFileSync } from 'node:child_process';
@@ -752,6 +752,72 @@ function cleanLegacyMcpFromSettings(settings) {
752
752
  *
753
753
  * forgen- prefix 로 사용자 own skills 와 격리. idempotent 재실행 시 forgen-* 만 정리 후 재설치.
754
754
  */
755
+ /**
756
+ * forgen 이 설치한 dev-guide 스킬만 정리한다 (src/host/managed-marker.ts 의 removeOwnedDevGuideSkills 와 같은 규칙).
757
+ *
758
+ * 이전엔 `forgen-*` 디렉토리를 이름만 보고 재귀 삭제해, 사용자가 만든 `forgen-notes` / `forgen-react-mine`
759
+ * 같은 스킬이 npm install 때마다 사라졌다. 소유 근거: (a) 패키지가 현재 제공하는 이름, 또는
760
+ * (b) SKILL.md 가 `…/assets/dev-guide/…` 를 가리키는 심링크(이전 버전이 설치한 것).
761
+ * SKILL.md 한 파일과 빈 디렉토리만 지운다.
762
+ */
763
+ function removeOwnedDevGuideSkills(skillsDir, devGuideRoot) {
764
+ const known = new Set();
765
+ try {
766
+ for (const tier of readdirSync(devGuideRoot)) {
767
+ const base = join(devGuideRoot, tier, 'skills');
768
+ if (!existsSync(base)) continue;
769
+ for (const stack of readdirSync(base)) {
770
+ const stackDir = join(base, stack);
771
+ if (!statSync(stackDir).isDirectory()) continue;
772
+ for (const skill of readdirSync(stackDir)) {
773
+ if (existsSync(join(stackDir, skill, 'SKILL.md'))) known.add(`forgen-${stack}-${skill}`);
774
+ }
775
+ }
776
+ }
777
+ } catch { /* 자산 없음 */ }
778
+
779
+ let removed = 0;
780
+ let entries = [];
781
+ try { entries = readdirSync(skillsDir); } catch { return 0; }
782
+ for (const name of entries) {
783
+ if (!name.startsWith('forgen-')) continue;
784
+ const dir = join(skillsDir, name);
785
+ const file = join(dir, 'SKILL.md');
786
+ let owned = known.has(name);
787
+ if (!owned) {
788
+ try { owned = /[\\/]assets[\\/]dev-guide[\\/]/.test(readlinkSync(file)); } catch { owned = false; }
789
+ }
790
+ if (!owned) continue;
791
+ try {
792
+ if (lstatSync(dir).isSymbolicLink()) continue;
793
+ lstatSync(file); // 없으면 throw
794
+ unlinkSync(file);
795
+ try { if (readdirSync(dir).length === 0) rmdirSync(dir); } catch { /* ignore */ }
796
+ removed++;
797
+ } catch { /* best-effort */ }
798
+ }
799
+ return removed;
800
+ }
801
+
802
+ /**
803
+ * dev-guide 스킬 한 개 설치 (src/host/managed-marker.ts installSkillFile 과 같은 규칙).
804
+ * 이미 있는 것은 덮어쓰지 않는다: 심링크된 스킬 디렉토리는 따라 들어가지 않고, 남아 있는 SKILL.md 는 건드리지 않는다
805
+ * (rmSync/cpSync 는 심링크를 관통해 사용자 dotfiles 의 파일을 지우거나 덮어쓴다). 반환: 설치했는가.
806
+ */
807
+ function installSkillFile(src, skillsDir, name) {
808
+ const dir = join(skillsDir, name);
809
+ const file = join(dir, 'SKILL.md');
810
+ try {
811
+ try { if (lstatSync(dir).isSymbolicLink()) return false; } catch { /* 없음 */ }
812
+ mkdirSync(dir, { recursive: true });
813
+ try { lstatSync(file); return false; } catch { /* 없음 — 설치 */ }
814
+ try { symlinkSync(src, file, 'file'); } catch { copyFileSync(src, file); }
815
+ return true;
816
+ } catch {
817
+ return false;
818
+ }
819
+ }
820
+
755
821
  function installDevGuideSkills(home) {
756
822
  const devGuideRoot = join(PKG_ROOT, 'assets', 'dev-guide');
757
823
  if (!existsSync(devGuideRoot)) {
@@ -762,13 +828,8 @@ function installDevGuideSkills(home) {
762
828
  const userSkillsDir = join(home, '.claude', 'skills');
763
829
  mkdirSync(userSkillsDir, { recursive: true });
764
830
 
765
- // 1. 기존 forgen-* 디렉토리 정리 (사용자 own 보존)
766
- let removed = 0;
767
- for (const entry of readdirSync(userSkillsDir)) {
768
- if (entry.startsWith('forgen-')) {
769
- try { rmSync(join(userSkillsDir, entry), { recursive: true, force: true }); removed++; } catch { /* best-effort */ }
770
- }
771
- }
831
+ // 1. forgen 이 설치한 dev-guide 스킬 정리 (사용자가 만든 forgen-* 스킬은 보존)
832
+ const removed = removeOwnedDevGuideSkills(userSkillsDir, devGuideRoot);
772
833
 
773
834
  // 2. assets/dev-guide/{side}/skills/{stack}/{skill}/SKILL.md 수집
774
835
  let installed = 0;
@@ -783,19 +844,8 @@ function installDevGuideSkills(home) {
783
844
  const skillFile = join(skillSrc, 'SKILL.md');
784
845
  if (!existsSync(skillFile)) continue;
785
846
 
786
- // 대상: ~/.claude/skills/forgen-<stack>-<skill>/SKILL.md
787
- const dstDir = join(userSkillsDir, `forgen-${stack}-${skill}`);
788
- mkdirSync(dstDir, { recursive: true });
789
- const dst = join(dstDir, 'SKILL.md');
790
-
791
- // symlink → fallback cpSync
792
- try {
793
- if (existsSync(dst)) rmSync(dst);
794
- symlinkSync(skillFile, dst, 'file');
795
- } catch {
796
- cpSync(skillFile, dst);
797
- }
798
- installed++;
847
+ // 대상: ~/.claude/skills/forgen-<stack>-<skill>/SKILL.md (이미 있는 사용자 것은 덮어쓰지 않는다)
848
+ if (installSkillFile(skillFile, userSkillsDir, `forgen-${stack}-${skill}`)) installed++;
799
849
  }
800
850
  }
801
851
  }
@@ -810,9 +860,8 @@ function installDevGuideSkills(home) {
810
860
  /**
811
861
  * v0.4.9: dev-guide 14 skills → ~/.codex/skills/forgen-<stack>-<skill>/SKILL.md
812
862
  * forgen 자체 commands(forgen-compound 등) 와 prefix 겹치지 않도록
813
- * DEV_GUIDE_SKILL_PATTERN(/^forgen-(react|vue|node|go)-/) 으로 stale 정리.
863
+ * stale 정리는 removeOwnedDevGuideSkills (forgen 소유만).
814
864
  */
815
- const DEV_GUIDE_SKILL_PATTERN = /^forgen-(react|vue|node|go)-/;
816
865
 
817
866
  function installDevGuideSkillsToCodex(home) {
818
867
  const devGuideRoot = join(PKG_ROOT, 'assets', 'dev-guide');
@@ -824,13 +873,8 @@ function installDevGuideSkillsToCodex(home) {
824
873
  const codexSkillsDir = join(home, '.codex', 'skills');
825
874
  mkdirSync(codexSkillsDir, { recursive: true });
826
875
 
827
- // 1. stale forgen-<stack>-<skill> 정리 (forgen 자체 commands 보존)
828
- let removed = 0;
829
- for (const entry of readdirSync(codexSkillsDir)) {
830
- if (DEV_GUIDE_SKILL_PATTERN.test(entry)) {
831
- try { rmSync(join(codexSkillsDir, entry), { recursive: true, force: true }); removed++; } catch { /* best-effort */ }
832
- }
833
- }
876
+ // 1. forgen 이 설치한 dev-guide 스킬 정리 (forgen 자체 commands 와 사용자 스킬은 보존)
877
+ const removed = removeOwnedDevGuideSkills(codexSkillsDir, devGuideRoot);
834
878
 
835
879
  // 2. assets/dev-guide/{tier}/skills/{stack}/{skill}/SKILL.md 수집
836
880
  let installed = 0;
@@ -844,18 +888,8 @@ function installDevGuideSkillsToCodex(home) {
844
888
  const skillFile = join(stackDir, skill, 'SKILL.md');
845
889
  if (!existsSync(skillFile)) continue;
846
890
 
847
- const dstDir = join(codexSkillsDir, `forgen-${stack}-${skill}`);
848
- mkdirSync(dstDir, { recursive: true });
849
- const dst = join(dstDir, 'SKILL.md');
850
-
851
- // symlink → fallback cpSync
852
- try {
853
- if (existsSync(dst)) rmSync(dst);
854
- symlinkSync(skillFile, dst, 'file');
855
- } catch {
856
- cpSync(skillFile, dst);
857
- }
858
- installed++;
891
+ // 이미 있는 사용자 것은 덮어쓰지 않는다
892
+ if (installSkillFile(skillFile, codexSkillsDir, `forgen-${stack}-${skill}`)) installed++;
859
893
  }
860
894
  }
861
895
  }