@wooojin/forgen 0.5.8 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +39 -0
- package/dist/core/uninstall.js +10 -1
- package/dist/host/install-claude.js +7 -33
- package/dist/host/install-codex.d.ts +23 -1
- package/dist/host/install-codex.js +128 -49
- package/dist/host/install-opencode.d.ts +5 -0
- package/dist/host/install-opencode.js +9 -6
- package/dist/host/managed-marker.d.ts +22 -0
- package/dist/host/managed-marker.js +79 -0
- package/dist/host/uninstall-codex.d.ts +2 -0
- package/dist/host/uninstall-codex.js +12 -9
- package/dist/host/uninstall-opencode.d.ts +36 -0
- package/dist/host/uninstall-opencode.js +141 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/postinstall.js +76 -42
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.9] — 2026-10-02 — 남겨 둔 한계 정리: 사용자 스킬 보존 · AGENTS.md 전체 정리 · OpenCode uninstall · claude-mem 13.28
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **`forgen install` / npm postinstall 이 사용자가 만든 `forgen-*` 스킬을 지우던 것.** dev-guide 스킬의 stale 정리가 이름만 보고
|
|
14
|
+
디렉토리를 재귀 삭제했다 (`install claude` 는 `forgen-*` 전부, `install codex`/postinstall 은 `forgen-(react|vue|node|go)-*`).
|
|
15
|
+
이제 forgen 소유만 정리한다: 패키지가 현재 제공하는 이름, 또는 `SKILL.md` 가 `…/assets/dev-guide/…` 를 가리키는 심링크(이전
|
|
16
|
+
버전이 설치한 것). 삭제도 `SKILL.md` 한 파일과 빈 디렉토리만. 설치는 이미 있는 것을 덮어쓰지 않는다 — 심링크된 스킬
|
|
17
|
+
디렉토리(dotfiles 등)를 따라 들어가 쓰지 않고, 한 스킬 자리가 막혀 있어도 나머지 설치는 계속한다.
|
|
18
|
+
- AGENTS.md 블록 처리: 마커가 한 줄을 통째로 차지할 때만 블록 경계로 본다. 본문에 마커 문자열을 인용한 문서에서 인용 줄부터
|
|
19
|
+
실제 블록 끝까지를 덮어쓰거나 지울 수 있었다.
|
|
20
|
+
- **forgen-eval**: `workerStatus()` 가 "Worker is not running" 을 실행 중으로 판정하던 것. claude-mem recall 이 `sqlite3` CLI 에만
|
|
21
|
+
의존해 CLI 가 없는 머신에서는 조용히 비어 있던 것(forgen+mem arm 이 forgen-only 와 같아짐) — `node:sqlite` 우선 + CLI 폴백,
|
|
22
|
+
둘 다 없으면 run 경고. `CLAUDE_MEM_TESTED_VERSION` 이 12.4.8 에 머물러 매 실행 version-mismatch 경고가 나던 것.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- **`forgen uninstall` 이 다른 프로젝트의 AGENTS.md 블록도 정리한다.** `forgen install codex|opencode` 가 블록을 쓴 경로를 host
|
|
26
|
+
설정 디렉토리의 `forgen-agents-md.json` 에 기록하고, uninstall 이 기록된 경로 전부 + 실행한 cwd 를 정리한 뒤 기록을 지운다.
|
|
27
|
+
(0.5.9 이전 설치분은 기록이 없어 여전히 cwd 만 정리된다.)
|
|
28
|
+
- **OpenCode uninstall.** forgen-managed plugin 제거(설치 때 백업한 사용자 plugin 이 있으면 되돌림), config(JSONC) 의
|
|
29
|
+
`mcp.forgen-compound` 를 forgen 시그니처일 때만 제거(다른 키·서버의 값은 보존, 인접 주석/포맷은 바뀔 수 있음; 파싱 불가면
|
|
30
|
+
건드리지 않음), AGENTS.md 블록. plugin 소유는 첫 줄의 `// forgen-managed` 로만 판정한다.
|
|
31
|
+
- uninstall 이 쓰지 못한 AGENTS.md(읽기 전용 등)는 오류로 알리고 기록에 남겨 다음 실행에서 다시 시도한다.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
- **claude-mem 13.12.4 → 13.28.0** (forgen-eval 테스트베드 핀, Dependabot #136). 0.5.7 에서 "재측정 필요" 로 보류했으나 근거가
|
|
35
|
+
약했다 — 저장소의 측정 리포트는 claude-mem 버전에 묶여 있지 않고 forgen-eval 이 의존하는 것은 CLI 계약이다. 격리 HOME 에 두
|
|
36
|
+
버전을 설치해 계약이 동일함을 실측: `version`/`start`/`status`/`stop`, `search` 출력(JSON + `| #N |` 표), DB 컬럼, 비매칭
|
|
37
|
+
쿼리 동작.
|
|
38
|
+
|
|
39
|
+
### Verified
|
|
40
|
+
- fresh-context critic 리뷰: MAJOR 2 · MINOR 10 → 반영 또는 한계로 명시(ADR-016). 리뷰어의 재현 환경(심링크된 스킬 디렉토리)을
|
|
41
|
+
CLI `install both` 와 postinstall 에 다시 돌려 사용자 파일이 그대로임을 확인.
|
|
42
|
+
- vitest 3242 통과 + forgen-eval 72 통과 (신규: 실 SQLite DB 로 recall 계약, worker 판정, 핀 일치 강제; 사용자 스킬 보존;
|
|
43
|
+
AGENTS.md 기록/정리; OpenCode uninstall).
|
|
44
|
+
- 격리 HOME 실행: 실 claude-mem 13.28.0 워커에 대해 빌드된 recall 경로가 시드한 observation 본문을 돌려주고, 워커 on/off 판정이
|
|
45
|
+
맞음. CLI `install both` + `install opencode` 를 두 프로젝트에서 실행한 뒤 다른 디렉토리에서 `uninstall --force` —
|
|
46
|
+
두 AGENTS.md 블록·OpenCode plugin/MCP·Codex 등록분 제거, 사용자 스킬과 사용자 AGENTS.md 내용 보존.
|
|
47
|
+
|
|
48
|
+
|
|
10
49
|
## [0.5.8] — 2026-10-02 — Codex 가 재배치한 마커에서도 설치·제거가 정확하게 (Codex 0.160 확인)
|
|
11
50
|
|
|
12
51
|
### Fixed
|
package/dist/core/uninstall.js
CHANGED
|
@@ -364,7 +364,8 @@ export async function handleUninstall(cwd, options) {
|
|
|
364
364
|
console.log(' 4. Remove forgen block from CLAUDE.md');
|
|
365
365
|
console.log(' 5. Remove slash commands (~/.claude/commands/forgen/) and the forgen-managed verify skill (~/.claude/skills/verify/)');
|
|
366
366
|
console.log(' 6. Remove plugin artifacts (cache, installed_plugins.json, plugin directory), dev-guide skills, and the forgen-compound MCP entry in ~/.claude.json');
|
|
367
|
-
console.log(' 7. Codex (if $CODEX_HOME or ~/.codex exists): forgen hooks in hooks.json, the MCP/notify blocks in config.toml, forgen skills, ch-*.toml agents, and the AGENTS.md
|
|
367
|
+
console.log(' 7. Codex (if $CODEX_HOME or ~/.codex exists): forgen hooks in hooks.json, the MCP/notify blocks in config.toml, forgen skills, ch-*.toml agents, and the AGENTS.md blocks (every project recorded at install time) — hooks and files from other tools are kept');
|
|
368
|
+
console.log(' OpenCode (if its config dir exists): the forgen plugin, the forgen-compound MCP entry, AGENTS.md blocks');
|
|
368
369
|
if (options.purge) {
|
|
369
370
|
console.log(' 8. --purge: Delete ~/.forgen/ entirely (rules, me/, state/, solutions/, behavior/)');
|
|
370
371
|
console.log(' WARNING: this erases all accumulated corrections, rules, drift, and lifecycle history.');
|
|
@@ -409,6 +410,14 @@ export async function handleUninstall(cwd, options) {
|
|
|
409
410
|
catch (e) {
|
|
410
411
|
console.error(' ✗ Codex cleanup failed:', e instanceof Error ? e.message : String(e));
|
|
411
412
|
}
|
|
413
|
+
try {
|
|
414
|
+
const { planOpencodeUninstall, renderOpencodeUninstall } = await import('../host/uninstall-opencode.js');
|
|
415
|
+
for (const line of renderOpencodeUninstall(planOpencodeUninstall({ pkgRoot })))
|
|
416
|
+
console.log(line);
|
|
417
|
+
}
|
|
418
|
+
catch (e) {
|
|
419
|
+
console.error(' ✗ OpenCode cleanup failed:', e instanceof Error ? e.message : String(e));
|
|
420
|
+
}
|
|
412
421
|
cleanPluginArtifacts();
|
|
413
422
|
if (options.purge) {
|
|
414
423
|
try {
|
|
@@ -18,7 +18,7 @@ import * as fs from 'node:fs';
|
|
|
18
18
|
import * as os from 'node:os';
|
|
19
19
|
import * as path from 'node:path';
|
|
20
20
|
import { generateHooksJson } from '../hooks/hooks-generator.js';
|
|
21
|
-
import { hasManagedSkillMarker } from './managed-marker.js';
|
|
21
|
+
import { hasManagedSkillMarker, installSkillFile, removeOwnedDevGuideSkills } from './managed-marker.js';
|
|
22
22
|
const PLUGIN_KEY = 'forgen@forgen-local';
|
|
23
23
|
const FORGEN_MANAGED_MARKER = '<!-- forgen-managed -->';
|
|
24
24
|
function readPkgVersion(pkgRoot) {
|
|
@@ -266,40 +266,14 @@ function installDevGuideSkills(opts) {
|
|
|
266
266
|
return { skillsPath: skillsDir, skillsInstalled: entries.length, skillsRemoved: 0 };
|
|
267
267
|
}
|
|
268
268
|
fs.mkdirSync(skillsDir, { recursive: true });
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
continue;
|
|
274
|
-
const fullPath = path.join(skillsDir, entry);
|
|
275
|
-
if (fs.statSync(fullPath).isDirectory()) {
|
|
276
|
-
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
277
|
-
removed += 1;
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
// Install each skill via symlink → cpSync fallback (mirrors plugin cache pattern)
|
|
269
|
+
// stale 정리 — forgen 이 설치한 dev-guide 스킬만 (패키지가 제공하는 이름 / dev-guide 를 가리키는 심링크).
|
|
270
|
+
// 이전엔 `forgen-*` 디렉토리를 이름만 보고 재귀 삭제해 사용자가 만든 스킬까지 지웠다 (ADR-016 0.5.9).
|
|
271
|
+
const removed = removeOwnedDevGuideSkills(skillsDir, pkgRoot);
|
|
272
|
+
// 설치 — 이미 있는 것(사용자 심링크 디렉토리, 남아 있는 SKILL.md)은 덮어쓰지 않는다.
|
|
281
273
|
let installed = 0;
|
|
282
274
|
for (const { name, src } of entries) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const targetFile = path.join(targetDir, 'SKILL.md');
|
|
286
|
-
let linked = false;
|
|
287
|
-
let symlinkErr = null;
|
|
288
|
-
try {
|
|
289
|
-
fs.symlinkSync(src, targetFile, 'file');
|
|
290
|
-
linked = true;
|
|
291
|
-
}
|
|
292
|
-
catch (e) {
|
|
293
|
-
symlinkErr = e;
|
|
294
|
-
}
|
|
295
|
-
if (!linked && symlinkErr) {
|
|
296
|
-
const code = symlinkErr.code ?? 'UNKNOWN';
|
|
297
|
-
process.stderr.write(`[forgen] symlink ${src} → ${targetFile} failed (${code}); falling back to copyFile.\n`);
|
|
298
|
-
}
|
|
299
|
-
if (!linked) {
|
|
300
|
-
fs.copyFileSync(src, targetFile);
|
|
301
|
-
}
|
|
302
|
-
installed += 1;
|
|
275
|
+
if (installSkillFile(src, skillsDir, name))
|
|
276
|
+
installed += 1;
|
|
303
277
|
}
|
|
304
278
|
return { skillsPath: skillsDir, skillsInstalled: installed, skillsRemoved: removed };
|
|
305
279
|
}
|
|
@@ -199,7 +199,29 @@ export declare function upsertForgenRulesInAgentsMd(opts: {
|
|
|
199
199
|
}): {
|
|
200
200
|
injected: boolean;
|
|
201
201
|
};
|
|
202
|
-
|
|
202
|
+
export declare function readAgentsMdInstalls(hostDir: string): string[];
|
|
203
|
+
export declare function recordAgentsMdInstall(hostDir: string, agentsMdPath: string): void;
|
|
204
|
+
export declare function clearAgentsMdInstalls(hostDir: string): void;
|
|
205
|
+
/**
|
|
206
|
+
* 기록된 경로 + 지금의 cwd 경로에서 forgen 블록을 걷어낸다.
|
|
207
|
+
* 쓰지 못한 경로(읽기 전용 등)는 `failed` 로 돌려주고 **기록에 남긴다** — 다음 uninstall 이 다시 시도할 수 있게.
|
|
208
|
+
*/
|
|
209
|
+
export declare function removeForgenRulesEverywhere(opts: {
|
|
210
|
+
hostDir: string;
|
|
211
|
+
cwdAgentsMdPath: string;
|
|
212
|
+
dryRun: boolean;
|
|
213
|
+
}): {
|
|
214
|
+
cleaned: string[];
|
|
215
|
+
failed: Array<{
|
|
216
|
+
path: string;
|
|
217
|
+
error: string;
|
|
218
|
+
}>;
|
|
219
|
+
};
|
|
220
|
+
/**
|
|
221
|
+
* AGENTS.md 의 forgen 블록 제거 (uninstall). 블록뿐이던 파일은 삭제한다.
|
|
222
|
+
* BEGIN/END 마커가 각각 한 줄로, 순서대로 있을 때만 동작한다 (블록이 여러 개면 전부 제거).
|
|
223
|
+
* 읽을 수 없으면 no-op, 쓸 수 없으면 throw — 호출부가 보고한다.
|
|
224
|
+
*/
|
|
203
225
|
export declare function removeForgenRulesFromAgentsMd(opts: {
|
|
204
226
|
agentsMdPath: string;
|
|
205
227
|
dryRun: boolean;
|
|
@@ -17,7 +17,7 @@ import * as os from 'node:os';
|
|
|
17
17
|
import * as path from 'node:path';
|
|
18
18
|
import { generateHooksJson } from '../hooks/hooks-generator.js';
|
|
19
19
|
import { HOOK_REGISTRY } from '../hooks/hook-registry.js';
|
|
20
|
-
import { hasManagedSkillMarker, isManagedAgentToml } from './managed-marker.js';
|
|
20
|
+
import { hasManagedSkillMarker, installSkillFile, isManagedAgentToml, removeOwnedDevGuideSkills } from './managed-marker.js';
|
|
21
21
|
const MCP_MARKER_BEGIN = '# >>> forgen-managed-mcp';
|
|
22
22
|
const MCP_MARKER_END = '# <<< forgen-managed-mcp';
|
|
23
23
|
const NOTIFY_MARKER_BEGIN = '# >>> forgen-managed-notify';
|
|
@@ -553,6 +553,8 @@ export function planCodexInstall(opts) {
|
|
|
553
553
|
// pkgRoot 의 git repo root 의 AGENTS.md, 또는 explicit override.
|
|
554
554
|
const agentsMdPath = opts.agentsMdPath ?? resolveAgentsMdPath(opts.pkgRoot);
|
|
555
555
|
const agentsResult = upsertForgenRulesInAgentsMd({ agentsMdPath, pkgRoot: opts.pkgRoot, dryRun: opts.dryRun ?? false });
|
|
556
|
+
if (!opts.dryRun)
|
|
557
|
+
recordAgentsMdInstall(codexHome, agentsMdPath);
|
|
556
558
|
// 8) v0.4.9: dev-guide skills → ~/.codex/skills/forgen-<stack>-<skill>/SKILL.md
|
|
557
559
|
const devGuideResult = installDevGuideSkillsToCodex({
|
|
558
560
|
pkgRoot: opts.pkgRoot,
|
|
@@ -947,33 +949,13 @@ function installDevGuideSkillsToCodex(opts) {
|
|
|
947
949
|
return { devGuideSkillsPath: codexSkillsDir, devGuideSkillsInstalled: entries.length, devGuideSkillsRemoved: 0 };
|
|
948
950
|
}
|
|
949
951
|
fs.mkdirSync(codexSkillsDir, { recursive: true });
|
|
950
|
-
//
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
if (DEV_GUIDE_SKILL_PATTERN.test(entry)) {
|
|
954
|
-
try {
|
|
955
|
-
fs.rmSync(path.join(codexSkillsDir, entry), { recursive: true, force: true });
|
|
956
|
-
removed++;
|
|
957
|
-
}
|
|
958
|
-
catch { /* best-effort */ }
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
// Install via symlink → copyFileSync fallback
|
|
952
|
+
// stale 정리 — forgen 이 설치한 dev-guide 스킬만 (사용자가 만든 `forgen-<stack>-*` 스킬은 보존, ADR-016 0.5.9)
|
|
953
|
+
const removed = removeOwnedDevGuideSkills(codexSkillsDir, opts.pkgRoot);
|
|
954
|
+
// 설치 — 이미 있는 것(사용자 심링크 디렉토리, 남아 있는 SKILL.md)은 덮어쓰지 않는다.
|
|
962
955
|
let installed = 0;
|
|
963
956
|
for (const { name, src } of entries) {
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
const dst = path.join(dstDir, 'SKILL.md');
|
|
967
|
-
let linked = false;
|
|
968
|
-
try {
|
|
969
|
-
fs.symlinkSync(src, dst, 'file');
|
|
970
|
-
linked = true;
|
|
971
|
-
}
|
|
972
|
-
catch { /* fallback */ }
|
|
973
|
-
if (!linked) {
|
|
974
|
-
fs.copyFileSync(src, dst);
|
|
975
|
-
}
|
|
976
|
-
installed++;
|
|
957
|
+
if (installSkillFile(src, codexSkillsDir, name))
|
|
958
|
+
installed++;
|
|
977
959
|
}
|
|
978
960
|
return { devGuideSkillsPath: codexSkillsDir, devGuideSkillsInstalled: installed, devGuideSkillsRemoved: removed };
|
|
979
961
|
}
|
|
@@ -1081,26 +1063,36 @@ export function upsertForgenRulesInAgentsMd(opts) {
|
|
|
1081
1063
|
if (fs.existsSync(agentsMdPath)) {
|
|
1082
1064
|
current = fs.readFileSync(agentsMdPath, 'utf-8');
|
|
1083
1065
|
}
|
|
1084
|
-
//
|
|
1085
|
-
|
|
1086
|
-
const
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
// begin 부터 파일 끝까지를 *전부* 새 block 으로 교체.
|
|
1066
|
+
// 블록 경계는 마커가 한 줄을 통째로 차지할 때만 인정한다 (본문에 마커 문자열을 인용한 줄과 구분 —
|
|
1067
|
+
// 이전의 문자열 정규식은 인용된 BEGIN 부터 실제 END 까지를 블록으로 보고 그 사이의 사용자 글을 덮어썼다).
|
|
1068
|
+
const lines = current.split('\n');
|
|
1069
|
+
const blocks = findAgentsBlocks(lines);
|
|
1070
|
+
const blockLines = block.split('\n');
|
|
1090
1071
|
let newContent;
|
|
1091
|
-
if (
|
|
1092
|
-
|
|
1072
|
+
if (blocks.length > 0) {
|
|
1073
|
+
// 첫 블록 자리에 새 블록, 나머지 중복 블록은 제거
|
|
1074
|
+
const drop = new Set();
|
|
1075
|
+
for (const b of blocks)
|
|
1076
|
+
for (let i = b.begin; i <= b.end; i += 1)
|
|
1077
|
+
drop.add(i);
|
|
1078
|
+
const out = [];
|
|
1079
|
+
lines.forEach((l, i) => {
|
|
1080
|
+
if (i === blocks[0].begin)
|
|
1081
|
+
out.push(...blockLines);
|
|
1082
|
+
if (!drop.has(i))
|
|
1083
|
+
out.push(l);
|
|
1084
|
+
});
|
|
1085
|
+
newContent = out.join('\n');
|
|
1093
1086
|
}
|
|
1094
1087
|
else {
|
|
1095
|
-
|
|
1096
|
-
const
|
|
1097
|
-
if (beginIdx !== -1
|
|
1098
|
-
|
|
1099
|
-
newContent = `${current.slice(0, beginIdx).replace(/\s+$/, '')}\n\n${block}\n`;
|
|
1088
|
+
// self-heal: BEGIN 줄만 있고 END 가 없으면(손상) BEGIN 부터 파일 끝까지를 새 블록으로 교체 — 누적 방지.
|
|
1089
|
+
const beginIdx = lines.findIndex((l) => l.trim() === AGENTS_MD_BEGIN);
|
|
1090
|
+
if (beginIdx !== -1) {
|
|
1091
|
+
newContent = `${lines.slice(0, beginIdx).join('\n').replace(/\s+$/, '')}\n\n${block}\n`.replace(/^\n+/, '');
|
|
1100
1092
|
}
|
|
1101
1093
|
else {
|
|
1102
|
-
// 깨끗한 신규
|
|
1103
|
-
newContent = `${current.replace(/\s+$/, '')}${current.length > 0 ? '\n\n' : ''}${block}\n`;
|
|
1094
|
+
// 깨끗한 신규 → 끝에 append
|
|
1095
|
+
newContent = `${current.replace(/\s+$/, '')}${current.trim().length > 0 ? '\n\n' : ''}${block}\n`;
|
|
1104
1096
|
}
|
|
1105
1097
|
}
|
|
1106
1098
|
if (dryRun)
|
|
@@ -1109,7 +1101,89 @@ export function upsertForgenRulesInAgentsMd(opts) {
|
|
|
1109
1101
|
fs.writeFileSync(agentsMdPath, newContent, 'utf-8');
|
|
1110
1102
|
return { injected: newContent !== current };
|
|
1111
1103
|
}
|
|
1112
|
-
|
|
1104
|
+
// ── AGENTS.md 설치 위치 기록 (ADR-016 0.5.9) ────────────────────────────
|
|
1105
|
+
//
|
|
1106
|
+
// `forgen install codex|opencode` 는 실행한 cwd 의 AGENTS.md 에 블록을 쓴다. 어디에 썼는지 남기지 않으면
|
|
1107
|
+
// uninstall 은 그때의 cwd 것만 지울 수 있다. host 설정 디렉토리에 경로 목록을 둔다 (전역 forgen state 가
|
|
1108
|
+
// 아니라 host 디렉토리 — 그 host 설치의 흔적이고, 격리 설치끼리 기록이 섞이지 않는다).
|
|
1109
|
+
const AGENTS_MD_REGISTRY = 'forgen-agents-md.json';
|
|
1110
|
+
export function readAgentsMdInstalls(hostDir) {
|
|
1111
|
+
try {
|
|
1112
|
+
const data = JSON.parse(fs.readFileSync(path.join(hostDir, AGENTS_MD_REGISTRY), 'utf-8'));
|
|
1113
|
+
return Array.isArray(data.paths) ? data.paths.filter((p) => typeof p === 'string') : [];
|
|
1114
|
+
}
|
|
1115
|
+
catch {
|
|
1116
|
+
return [];
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
export function recordAgentsMdInstall(hostDir, agentsMdPath) {
|
|
1120
|
+
try {
|
|
1121
|
+
const abs = path.resolve(agentsMdPath);
|
|
1122
|
+
const paths = readAgentsMdInstalls(hostDir);
|
|
1123
|
+
if (paths.includes(abs))
|
|
1124
|
+
return;
|
|
1125
|
+
fs.mkdirSync(hostDir, { recursive: true });
|
|
1126
|
+
fs.writeFileSync(path.join(hostDir, AGENTS_MD_REGISTRY), `${JSON.stringify({ paths: [...paths, abs] }, null, 2)}\n`, 'utf-8');
|
|
1127
|
+
}
|
|
1128
|
+
catch { /* 기록 실패는 설치를 막지 않는다 */ }
|
|
1129
|
+
}
|
|
1130
|
+
export function clearAgentsMdInstalls(hostDir) {
|
|
1131
|
+
try {
|
|
1132
|
+
fs.unlinkSync(path.join(hostDir, AGENTS_MD_REGISTRY));
|
|
1133
|
+
}
|
|
1134
|
+
catch { /* 없음 */ }
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* 기록된 경로 + 지금의 cwd 경로에서 forgen 블록을 걷어낸다.
|
|
1138
|
+
* 쓰지 못한 경로(읽기 전용 등)는 `failed` 로 돌려주고 **기록에 남긴다** — 다음 uninstall 이 다시 시도할 수 있게.
|
|
1139
|
+
*/
|
|
1140
|
+
export function removeForgenRulesEverywhere(opts) {
|
|
1141
|
+
const recorded = readAgentsMdInstalls(opts.hostDir);
|
|
1142
|
+
const candidates = [...new Set([...recorded, path.resolve(opts.cwdAgentsMdPath)])];
|
|
1143
|
+
const cleaned = [];
|
|
1144
|
+
const failed = [];
|
|
1145
|
+
for (const p of candidates) {
|
|
1146
|
+
try {
|
|
1147
|
+
if (removeForgenRulesFromAgentsMd({ agentsMdPath: p, dryRun: opts.dryRun }).removed)
|
|
1148
|
+
cleaned.push(p);
|
|
1149
|
+
}
|
|
1150
|
+
catch (e) {
|
|
1151
|
+
failed.push({ path: p, error: e instanceof Error ? e.message : String(e) });
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
if (!opts.dryRun) {
|
|
1155
|
+
const keep = recorded.filter((p) => failed.some((f) => f.path === p));
|
|
1156
|
+
if (keep.length === 0)
|
|
1157
|
+
clearAgentsMdInstalls(opts.hostDir);
|
|
1158
|
+
else {
|
|
1159
|
+
try {
|
|
1160
|
+
fs.writeFileSync(path.join(opts.hostDir, AGENTS_MD_REGISTRY), `${JSON.stringify({ paths: keep }, null, 2)}\n`, 'utf-8');
|
|
1161
|
+
}
|
|
1162
|
+
catch { /* best-effort */ }
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
return { cleaned, failed };
|
|
1166
|
+
}
|
|
1167
|
+
/** 마커가 *한 줄을 통째로* 차지할 때만 블록 경계로 본다 — 본문에 마커 문자열을 인용한 줄과 구분. */
|
|
1168
|
+
function findAgentsBlocks(lines) {
|
|
1169
|
+
const blocks = [];
|
|
1170
|
+
let begin = -1;
|
|
1171
|
+
lines.forEach((l, i) => {
|
|
1172
|
+
const t = l.trim();
|
|
1173
|
+
if (t === AGENTS_MD_BEGIN && begin === -1)
|
|
1174
|
+
begin = i;
|
|
1175
|
+
else if (t === AGENTS_MD_END && begin !== -1) {
|
|
1176
|
+
blocks.push({ begin, end: i });
|
|
1177
|
+
begin = -1;
|
|
1178
|
+
}
|
|
1179
|
+
});
|
|
1180
|
+
return blocks;
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* AGENTS.md 의 forgen 블록 제거 (uninstall). 블록뿐이던 파일은 삭제한다.
|
|
1184
|
+
* BEGIN/END 마커가 각각 한 줄로, 순서대로 있을 때만 동작한다 (블록이 여러 개면 전부 제거).
|
|
1185
|
+
* 읽을 수 없으면 no-op, 쓸 수 없으면 throw — 호출부가 보고한다.
|
|
1186
|
+
*/
|
|
1113
1187
|
export function removeForgenRulesFromAgentsMd(opts) {
|
|
1114
1188
|
let current;
|
|
1115
1189
|
try {
|
|
@@ -1118,10 +1192,18 @@ export function removeForgenRulesFromAgentsMd(opts) {
|
|
|
1118
1192
|
catch {
|
|
1119
1193
|
return { removed: false, fileDeleted: false };
|
|
1120
1194
|
}
|
|
1121
|
-
const
|
|
1122
|
-
|
|
1195
|
+
const lines = current.split('\n');
|
|
1196
|
+
const blocks = findAgentsBlocks(lines);
|
|
1197
|
+
if (blocks.length === 0)
|
|
1123
1198
|
return { removed: false, fileDeleted: false };
|
|
1124
|
-
const
|
|
1199
|
+
const drop = new Set();
|
|
1200
|
+
for (const b of blocks)
|
|
1201
|
+
for (let i = b.begin; i <= b.end; i += 1)
|
|
1202
|
+
drop.add(i);
|
|
1203
|
+
const kept = trimEofBlankLines(deleteLines(lines, drop));
|
|
1204
|
+
while (kept.length > 1 && kept[0].trim() === '')
|
|
1205
|
+
kept.shift();
|
|
1206
|
+
const rest = kept.join('\n');
|
|
1125
1207
|
const empty = rest.trim().length === 0;
|
|
1126
1208
|
// 심링크면 링크를 지우지 않고 대상 파일에 써 넣는다 (링크만 지우면 대상에 블록이 남는다).
|
|
1127
1209
|
const isLink = fs.lstatSync(opts.agentsMdPath).isSymbolicLink();
|
|
@@ -1130,10 +1212,7 @@ export function removeForgenRulesFromAgentsMd(opts) {
|
|
|
1130
1212
|
if (deleteFile)
|
|
1131
1213
|
fs.unlinkSync(opts.agentsMdPath);
|
|
1132
1214
|
else
|
|
1133
|
-
fs.writeFileSync(opts.agentsMdPath, empty ? '' :
|
|
1215
|
+
fs.writeFileSync(opts.agentsMdPath, empty ? '' : rest, 'utf-8');
|
|
1134
1216
|
}
|
|
1135
1217
|
return { removed: true, fileDeleted: deleteFile };
|
|
1136
1218
|
}
|
|
1137
|
-
function escapeRegex(s) {
|
|
1138
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1139
|
-
}
|
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
204
|
+
// 4) AGENTS.md — 설치 때 기록된 프로젝트 전부 + 지금의 cwd (0.5.9 이전 설치분은 기록이 없어 cwd 만)
|
|
204
205
|
step('AGENTS.md', () => {
|
|
205
|
-
const
|
|
206
|
-
|
|
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(
|
|
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
package/plugin.json
CHANGED
package/scripts/postinstall.js
CHANGED
|
@@ -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.
|
|
766
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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.
|
|
828
|
-
|
|
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
|
-
|
|
848
|
-
|
|
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
|
}
|