@wooojin/forgen 0.4.1 → 0.4.4
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 +5 -5
- package/CHANGELOG.md +267 -15
- package/CONTRIBUTING.md +2 -2
- package/README.ja.md +17 -9
- package/README.ko.md +34 -12
- package/README.md +65 -12
- package/README.zh.md +17 -9
- package/assets/README.md +86 -0
- package/assets/architecture.svg +100 -0
- package/assets/banner.png +0 -0
- package/assets/banner.svg +53 -0
- package/{commands → assets/claude/commands}/calibrate.md +4 -3
- package/{commands → assets/claude/commands}/retro.md +2 -2
- package/assets/demo/01-install.gif +0 -0
- package/assets/demo/01-install.tape +54 -0
- package/assets/demo/02-compound-learning.gif +0 -0
- package/assets/demo/02-compound-learning.tape +50 -0
- package/assets/demo/03-forge-personalization.gif +0 -0
- package/assets/demo/03-forge-personalization.tape +64 -0
- package/assets/demo/before-after.gif +0 -0
- package/assets/demo/before-after.tape +98 -0
- package/assets/demo-preview.svg +96 -0
- package/assets/icon.png +0 -0
- package/{hooks → assets/shared}/hook-registry.json +2 -1
- package/dist/checks/_shared/text-sanitizer.d.ts +21 -0
- package/dist/checks/_shared/text-sanitizer.js +60 -0
- package/dist/checks/dangerous-response-pattern.d.ts +32 -0
- package/dist/checks/dangerous-response-pattern.js +65 -0
- package/dist/checks/fact-vs-agreement.js +25 -1
- package/dist/cli.js +78 -6
- package/dist/core/auto-compound-runner.js +90 -39
- package/dist/core/behavior-classifier.d.ts +28 -0
- package/dist/core/behavior-classifier.js +46 -0
- package/dist/core/dashboard.d.ts +7 -0
- package/dist/core/dashboard.js +32 -0
- package/dist/core/doctor.js +92 -0
- package/dist/core/git-stats.d.ts +36 -0
- package/dist/core/git-stats.js +79 -0
- package/dist/core/harness.d.ts +1 -1
- package/dist/core/harness.js +27 -20
- package/dist/core/host-detect.d.ts +42 -0
- package/dist/core/host-detect.js +68 -0
- package/dist/core/installer.js +2 -2
- package/dist/core/migrate-cli.d.ts +1 -0
- package/dist/core/migrate-cli.js +19 -0
- package/dist/core/migrate-evidence-host.d.ts +36 -0
- package/dist/core/migrate-evidence-host.js +49 -0
- package/dist/core/settings-injector.js +4 -2
- package/dist/core/spawn.d.ts +1 -1
- package/dist/core/spawn.js +4 -11
- package/dist/core/stats-cli.js +12 -0
- package/dist/core/trust-layer-intent.d.ts +35 -0
- package/dist/core/trust-layer-intent.js +30 -0
- package/dist/core/types.d.ts +1 -1
- package/dist/engine/compound-extractor.js +7 -9
- package/dist/engine/learn-cli.js +4 -2
- package/dist/engine/lifecycle/bypass-detector.d.ts +6 -1
- package/dist/engine/lifecycle/bypass-detector.js +57 -5
- package/dist/fgx.js +2 -1
- package/dist/forge/evidence-processor.js +12 -0
- package/dist/forge/onboarding.d.ts +3 -2
- package/dist/forge/onboarding.js +3 -2
- package/dist/hooks/db-guard.js +3 -3
- package/dist/hooks/forge-loop-progress.d.ts +9 -0
- package/dist/hooks/forge-loop-progress.js +38 -0
- package/dist/hooks/hook-registry.js +1 -1
- package/dist/hooks/hooks-generator.d.ts +15 -1
- package/dist/hooks/hooks-generator.js +18 -16
- package/dist/hooks/keyword-detector.js +1 -1
- package/dist/hooks/post-tool-use.d.ts +1 -1
- package/dist/hooks/post-tool-use.js +13 -4
- package/dist/hooks/pre-compact.js +1 -1
- package/dist/hooks/pre-tool-use.js +4 -4
- package/dist/hooks/rate-limiter.js +2 -2
- package/dist/hooks/session-recovery.js +11 -0
- package/dist/hooks/shared/blocking-allowlist.d.ts +28 -0
- package/dist/hooks/shared/blocking-allowlist.js +38 -0
- package/dist/hooks/shared/forge-loop-state.d.ts +36 -0
- package/dist/hooks/shared/forge-loop-state.js +116 -0
- package/dist/hooks/shared/hook-response.d.ts +18 -0
- package/dist/hooks/shared/hook-response.js +31 -0
- package/dist/hooks/skill-injector.js +1 -1
- package/dist/hooks/stop-guard.js +57 -25
- package/dist/host/capabilities-claude.d.ts +8 -0
- package/dist/host/capabilities-claude.js +46 -0
- package/dist/host/capabilities-codex.d.ts +11 -0
- package/dist/host/capabilities-codex.js +50 -0
- package/dist/host/capabilities-registry.d.ts +11 -0
- package/dist/host/capabilities-registry.js +30 -0
- package/dist/host/codex-adapter.d.ts +8 -5
- package/dist/host/codex-adapter.js +10 -82
- package/dist/host/codex-output-parser.d.ts +39 -0
- package/dist/host/codex-output-parser.js +75 -0
- package/dist/host/exec-host.d.ts +54 -0
- package/dist/host/exec-host.js +92 -0
- package/dist/host/host-runtime.d.ts +37 -0
- package/dist/host/host-runtime.js +51 -0
- package/dist/host/install-claude.d.ts +35 -0
- package/dist/host/install-claude.js +238 -0
- package/dist/host/install-codex.d.ts +44 -0
- package/dist/host/install-codex.js +276 -0
- package/dist/host/install-orchestrator.d.ts +34 -0
- package/dist/host/install-orchestrator.js +126 -0
- package/dist/host/invoke-agent.d.ts +27 -0
- package/dist/host/invoke-agent.js +115 -0
- package/dist/host/parity-harness.d.ts +62 -0
- package/dist/host/parity-harness.js +283 -0
- package/dist/host/projection.d.ts +35 -0
- package/dist/host/projection.js +126 -0
- package/dist/mcp/server.js +11 -0
- package/dist/mcp/tools.js +51 -0
- package/dist/renderer/rule-renderer.d.ts +1 -1
- package/dist/renderer/rule-renderer.js +73 -1
- package/dist/services/session.d.ts +6 -3
- package/dist/services/session.js +33 -4
- package/dist/store/compound-usage-store.d.ts +28 -0
- package/dist/store/compound-usage-store.js +59 -0
- package/dist/store/evidence-store.d.ts +1 -0
- package/dist/store/evidence-store.js +34 -3
- package/dist/store/host-mismatch.d.ts +42 -0
- package/dist/store/host-mismatch.js +65 -0
- package/dist/store/profile-store.d.ts +29 -0
- package/dist/store/profile-store.js +53 -0
- package/dist/store/types.d.ts +13 -0
- package/hooks/hooks.json +6 -1
- package/package.json +6 -4
- package/plugin.json +4 -4
- package/scripts/postinstall.js +100 -25
- package/skills/calibrate/SKILL.md +4 -3
- package/skills/retro/SKILL.md +2 -2
- /package/{agents → assets/claude/agents}/analyst.md +0 -0
- /package/{agents → assets/claude/agents}/architect.md +0 -0
- /package/{agents → assets/claude/agents}/code-reviewer.md +0 -0
- /package/{agents → assets/claude/agents}/critic.md +0 -0
- /package/{agents → assets/claude/agents}/debugger.md +0 -0
- /package/{agents → assets/claude/agents}/designer.md +0 -0
- /package/{agents → assets/claude/agents}/executor.md +0 -0
- /package/{agents → assets/claude/agents}/explore.md +0 -0
- /package/{agents → assets/claude/agents}/git-master.md +0 -0
- /package/{agents → assets/claude/agents}/planner.md +0 -0
- /package/{agents → assets/claude/agents}/solution-evolver.md +0 -0
- /package/{agents → assets/claude/agents}/test-engineer.md +0 -0
- /package/{agents → assets/claude/agents}/verifier.md +0 -0
- /package/{commands → assets/claude/commands}/architecture-decision.md +0 -0
- /package/{commands → assets/claude/commands}/code-review.md +0 -0
- /package/{commands → assets/claude/commands}/compound.md +0 -0
- /package/{commands → assets/claude/commands}/deep-interview.md +0 -0
- /package/{commands → assets/claude/commands}/docker.md +0 -0
- /package/{commands → assets/claude/commands}/forge-loop.md +0 -0
- /package/{commands → assets/claude/commands}/learn.md +0 -0
- /package/{commands → assets/claude/commands}/ship.md +0 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-aware exec — feat/codex-support Phase 2 (P2-2/P2-3 공통)
|
|
3
|
+
*
|
|
4
|
+
* compound-extractor + auto-compound-runner 가 *어느 host CLI 로 LLM 호출* 할지
|
|
5
|
+
* 결정. profile.default_host 우선 + override 가능.
|
|
6
|
+
*
|
|
7
|
+
* 출력은 단일 string (agent message) 으로 통일 — caller 가 stdout 파싱 안 해도 됨.
|
|
8
|
+
*
|
|
9
|
+
* 호환성: 기존 'claude -p prompt --model haiku' 호출은 default_host 가 'claude' 인
|
|
10
|
+
* 경우 동일 동작. Codex 메인 사용자는 자동으로 codex exec --json 호출.
|
|
11
|
+
*/
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import { resolveDefaultHost } from '../store/profile-store.js';
|
|
14
|
+
import { parseCodexJsonlOutput } from './codex-output-parser.js';
|
|
15
|
+
/**
|
|
16
|
+
* Host 별 기본 timeout. Phase 3 deferred fix:
|
|
17
|
+
* - claude -p 는 보통 1~5s 응답이라 30s 충분.
|
|
18
|
+
* - codex exec --json 은 cold start + reasoning 모드로 60~90s 정상이라
|
|
19
|
+
* 30s default 가 false-positive ETIMEDOUT 발생. 90s 마진 필요.
|
|
20
|
+
* - 명시 timeout 옵션은 그대로 우선.
|
|
21
|
+
*/
|
|
22
|
+
export const DEFAULT_TIMEOUT_BY_HOST = {
|
|
23
|
+
claude: 30_000,
|
|
24
|
+
codex: 90_000,
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* 실 host CLI 를 호출하여 prompt 응답 받기.
|
|
28
|
+
* - claude: `claude -p <prompt> --model <model>`
|
|
29
|
+
* - codex: `codex exec --json -s read-only -c approval_policy="never" --ephemeral --skip-git-repo-check <prompt>`
|
|
30
|
+
*
|
|
31
|
+
* Codex 호출은 sandbox read-only + approval never + ephemeral 로 *자동 추출 안전성*
|
|
32
|
+
* 보장 (사용자 환경 미오염). compound-extractor / auto-compound-runner 같은
|
|
33
|
+
* 백그라운드 학습 호출에 적합.
|
|
34
|
+
*/
|
|
35
|
+
export function execHost(opts) {
|
|
36
|
+
const resolved = resolveDefaultHost(opts.host);
|
|
37
|
+
// 'ask' 는 자동 호출 컨텍스트라 명시 fallback. 그러나 Codex-only 사용자가 'ask'
|
|
38
|
+
// 설정 후 claude 가 PATH 에 없으면 ENOENT 발생 → 명시 안내. (Phase 2 critic fix)
|
|
39
|
+
const host = resolved === 'codex' ? 'codex' : 'claude';
|
|
40
|
+
if (resolved === 'ask' && opts.host === undefined) {
|
|
41
|
+
// 자동 호출에서 'ask' 도달 — caller 가 명시 host 안 줬으므로 default fallback 안내.
|
|
42
|
+
process.stderr.write('[forgen exec-host] default_host="ask" — auto-call falling back to claude. ' +
|
|
43
|
+
'If claude CLI is missing, set: forgen config default-host {claude|codex}\n');
|
|
44
|
+
}
|
|
45
|
+
const timeout = opts.timeout ?? DEFAULT_TIMEOUT_BY_HOST[host];
|
|
46
|
+
const baseOpts = {
|
|
47
|
+
encoding: 'utf-8',
|
|
48
|
+
timeout,
|
|
49
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
50
|
+
cwd: opts.cwd,
|
|
51
|
+
env: { ...process.env, ...(opts.env ?? {}) },
|
|
52
|
+
};
|
|
53
|
+
if (host === 'claude') {
|
|
54
|
+
const args = ['-p', opts.prompt];
|
|
55
|
+
if (opts.model)
|
|
56
|
+
args.push('--model', opts.model);
|
|
57
|
+
const stdout = execFileSync('claude', args, baseOpts);
|
|
58
|
+
return { message: stdout.toString().trim(), host: 'claude', usage: null };
|
|
59
|
+
}
|
|
60
|
+
// host === 'codex'
|
|
61
|
+
// Phase 2 critic fix: -c approval_policy="never" 의 인용부호는 shell 처리 없이
|
|
62
|
+
// execFileSync 인자라 codex 가 literal `"never"` 로 받을 위험. quote 제거 + 실측 검증.
|
|
63
|
+
const args = [
|
|
64
|
+
'exec',
|
|
65
|
+
'--json',
|
|
66
|
+
'-s', 'read-only',
|
|
67
|
+
'-c', 'approval_policy=never',
|
|
68
|
+
'--ephemeral',
|
|
69
|
+
'--skip-git-repo-check',
|
|
70
|
+
opts.prompt,
|
|
71
|
+
];
|
|
72
|
+
const stdout = execFileSync('codex', args, baseOpts);
|
|
73
|
+
const parsed = parseCodexJsonlOutput(stdout.toString());
|
|
74
|
+
return {
|
|
75
|
+
message: parsed.message,
|
|
76
|
+
host: 'codex',
|
|
77
|
+
usage: parsed.usage ? { input_tokens: parsed.usage.input_tokens, output_tokens: parsed.usage.output_tokens } : null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** 1회 retry — transient 에러(ETIMEDOUT 등) 대응. */
|
|
81
|
+
export function execHostRetry(opts) {
|
|
82
|
+
try {
|
|
83
|
+
return execHost(opts);
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
const code = e?.code;
|
|
87
|
+
if (code === 'ETIMEDOUT' || code === 'ECONNRESET') {
|
|
88
|
+
return execHost(opts);
|
|
89
|
+
}
|
|
90
|
+
throw e;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HostRuntime — Multi-Host Core Design Phase 2
|
|
3
|
+
*
|
|
4
|
+
* `runtime === 'codex'` 분기를 core 에서 제거하기 위한 host-specific 표면 모듈.
|
|
5
|
+
* spec §3.3 / §5.3 의 비대칭 경계: core 는 Claude semantics 알아도 됨, Codex 표면만 모름.
|
|
6
|
+
*
|
|
7
|
+
* 본 모듈이 노출하는 host-specific 표면:
|
|
8
|
+
* - launcher binary 이름 (codex / claude)
|
|
9
|
+
* - 사용자 표시 라벨 (Codex / Claude)
|
|
10
|
+
* - hook command wrapping (Codex 는 codex-adapter 경유)
|
|
11
|
+
* - 미설치 시 에러 메시지 (host 별 안내)
|
|
12
|
+
*
|
|
13
|
+
* core 측 코드는 본 모듈의 `getHostRuntime(runtime)` 만 호출하여 동작 분기를 위임.
|
|
14
|
+
*/
|
|
15
|
+
import type { RuntimeHost } from '../core/types.js';
|
|
16
|
+
export interface HostRuntime {
|
|
17
|
+
readonly id: RuntimeHost;
|
|
18
|
+
/** 사용자에게 노출되는 표시명 (UI 라벨, 로그). */
|
|
19
|
+
readonly displayName: string;
|
|
20
|
+
/** 실 실행 binary 이름 또는 절대경로. PATH 에서 찾으면 됨. */
|
|
21
|
+
readonly launcher: string;
|
|
22
|
+
/** 미설치 ENOENT 시 사용자에게 노출할 안내. */
|
|
23
|
+
readonly missingInstallMessage: string;
|
|
24
|
+
/**
|
|
25
|
+
* Hook command 래핑.
|
|
26
|
+
* Claude: `node "${pluginRoot}/${script}" ${args}`
|
|
27
|
+
* Codex: `node "${pluginRoot}/host/codex-adapter.js" "${pluginRoot}/${script}" ${args}` (sandbox 호환 + projection)
|
|
28
|
+
*/
|
|
29
|
+
wrapHookCommand(pluginRoot: string, scriptPath: string, args: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* settings hook injection strategy.
|
|
32
|
+
* - 'generate': generateHooksJson({runtime}) 호출 (Codex 등, host-aware wrapping 필요)
|
|
33
|
+
* - 'pre-baked-file': pkgRoot/hooks/hooks.json 읽고 ${CLAUDE_PLUGIN_ROOT} 치환 (Claude — 빌드 산출물 재사용)
|
|
34
|
+
*/
|
|
35
|
+
readonly hookInjectionStrategy: 'generate' | 'pre-baked-file';
|
|
36
|
+
}
|
|
37
|
+
export declare function getHostRuntime(runtime: RuntimeHost): HostRuntime;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HostRuntime — Multi-Host Core Design Phase 2
|
|
3
|
+
*
|
|
4
|
+
* `runtime === 'codex'` 분기를 core 에서 제거하기 위한 host-specific 표면 모듈.
|
|
5
|
+
* spec §3.3 / §5.3 의 비대칭 경계: core 는 Claude semantics 알아도 됨, Codex 표면만 모름.
|
|
6
|
+
*
|
|
7
|
+
* 본 모듈이 노출하는 host-specific 표면:
|
|
8
|
+
* - launcher binary 이름 (codex / claude)
|
|
9
|
+
* - 사용자 표시 라벨 (Codex / Claude)
|
|
10
|
+
* - hook command wrapping (Codex 는 codex-adapter 경유)
|
|
11
|
+
* - 미설치 시 에러 메시지 (host 별 안내)
|
|
12
|
+
*
|
|
13
|
+
* core 측 코드는 본 모듈의 `getHostRuntime(runtime)` 만 호출하여 동작 분기를 위임.
|
|
14
|
+
*/
|
|
15
|
+
function quoteArg(raw) {
|
|
16
|
+
return `"${raw.replace(/"/g, '\\"')}"`;
|
|
17
|
+
}
|
|
18
|
+
const claudeRuntime = {
|
|
19
|
+
id: 'claude',
|
|
20
|
+
displayName: 'Claude',
|
|
21
|
+
launcher: 'claude',
|
|
22
|
+
missingInstallMessage: 'Claude Code is not installed. npm install -g @anthropic-ai/claude-code',
|
|
23
|
+
wrapHookCommand(pluginRoot, scriptPath, args) {
|
|
24
|
+
const fullScript = `${pluginRoot}/${scriptPath}`;
|
|
25
|
+
return args ? `node ${quoteArg(fullScript)} ${args}` : `node ${quoteArg(fullScript)}`;
|
|
26
|
+
},
|
|
27
|
+
hookInjectionStrategy: 'pre-baked-file',
|
|
28
|
+
};
|
|
29
|
+
const codexRuntime = {
|
|
30
|
+
id: 'codex',
|
|
31
|
+
displayName: 'Codex',
|
|
32
|
+
launcher: 'codex',
|
|
33
|
+
missingInstallMessage: 'Codex is not installed.',
|
|
34
|
+
wrapHookCommand(pluginRoot, scriptPath, args) {
|
|
35
|
+
const adapterPath = `${pluginRoot}/host/codex-adapter.js`;
|
|
36
|
+
const fullScript = `${pluginRoot}/${scriptPath}`;
|
|
37
|
+
const base = `node ${quoteArg(adapterPath)} ${quoteArg(fullScript)}`;
|
|
38
|
+
return args ? `${base} ${args}` : base;
|
|
39
|
+
},
|
|
40
|
+
hookInjectionStrategy: 'generate',
|
|
41
|
+
};
|
|
42
|
+
const RUNTIMES = {
|
|
43
|
+
claude: claudeRuntime,
|
|
44
|
+
codex: codexRuntime,
|
|
45
|
+
};
|
|
46
|
+
export function getHostRuntime(runtime) {
|
|
47
|
+
const r = RUNTIMES[runtime];
|
|
48
|
+
if (!r)
|
|
49
|
+
throw new Error(`Unknown runtime host: ${runtime}`);
|
|
50
|
+
return r;
|
|
51
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude InstallPlan — feat/codex-support Phase 1 (P1-2)
|
|
3
|
+
*
|
|
4
|
+
* `npm install` postinstall.js 의 *Claude 측* 4 작업을 module 로 분리.
|
|
5
|
+
* `forgen install claude` CLI 가 호출 + (P1-6 에서) postinstall.js 도 위임.
|
|
6
|
+
*
|
|
7
|
+
* 4 작업:
|
|
8
|
+
* 1. Plugin cache: ~/.claude/plugins/cache/forgen-local/forgen/<ver>/ 작성 + installed_plugins.json 등록
|
|
9
|
+
* 2. Slash commands: ~/.claude/commands/forgen/*.md 생성 (forgen-managed marker)
|
|
10
|
+
* 3. Settings hooks injection: ~/.claude/settings.json 의 hooks 머지 (forgen entry idempotent)
|
|
11
|
+
* 4. MCP register: ~/.claude.json 에 mcpServers.forgen-compound 추가
|
|
12
|
+
*
|
|
13
|
+
* 사용자 비-forgen 자산 보존 + 재실행 idempotent.
|
|
14
|
+
*/
|
|
15
|
+
export interface ClaudeInstallOptions {
|
|
16
|
+
pkgRoot: string;
|
|
17
|
+
/** Override home dir (default: os.homedir()). 격리 테스트용. */
|
|
18
|
+
homeDir?: string;
|
|
19
|
+
/** Dry-run: 파일 미작성, 결과만 반환. */
|
|
20
|
+
dryRun?: boolean;
|
|
21
|
+
/** MCP forgen-compound 등록 여부 (default true). */
|
|
22
|
+
registerMcp?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface ClaudeInstallResult {
|
|
25
|
+
homeDir: string;
|
|
26
|
+
pluginCachePath: string;
|
|
27
|
+
pluginCacheWritten: boolean;
|
|
28
|
+
slashCommandsPath: string;
|
|
29
|
+
slashCommandsCount: number;
|
|
30
|
+
settingsPath: string;
|
|
31
|
+
hooksInjected: number;
|
|
32
|
+
mcpRegistered: boolean;
|
|
33
|
+
mcpAlreadyPresent: boolean;
|
|
34
|
+
}
|
|
35
|
+
export declare function planClaudeInstall(opts: ClaudeInstallOptions): ClaudeInstallResult;
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude InstallPlan — feat/codex-support Phase 1 (P1-2)
|
|
3
|
+
*
|
|
4
|
+
* `npm install` postinstall.js 의 *Claude 측* 4 작업을 module 로 분리.
|
|
5
|
+
* `forgen install claude` CLI 가 호출 + (P1-6 에서) postinstall.js 도 위임.
|
|
6
|
+
*
|
|
7
|
+
* 4 작업:
|
|
8
|
+
* 1. Plugin cache: ~/.claude/plugins/cache/forgen-local/forgen/<ver>/ 작성 + installed_plugins.json 등록
|
|
9
|
+
* 2. Slash commands: ~/.claude/commands/forgen/*.md 생성 (forgen-managed marker)
|
|
10
|
+
* 3. Settings hooks injection: ~/.claude/settings.json 의 hooks 머지 (forgen entry idempotent)
|
|
11
|
+
* 4. MCP register: ~/.claude.json 에 mcpServers.forgen-compound 추가
|
|
12
|
+
*
|
|
13
|
+
* 사용자 비-forgen 자산 보존 + 재실행 idempotent.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from 'node:fs';
|
|
16
|
+
import * as os from 'node:os';
|
|
17
|
+
import * as path from 'node:path';
|
|
18
|
+
import { generateHooksJson } from '../hooks/hooks-generator.js';
|
|
19
|
+
const PLUGIN_KEY = 'forgen@forgen-local';
|
|
20
|
+
const FORGEN_MANAGED_MARKER = '<!-- forgen-managed -->';
|
|
21
|
+
function readPkgVersion(pkgRoot) {
|
|
22
|
+
try {
|
|
23
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf-8'));
|
|
24
|
+
return pkg.version ?? '0.0.0';
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return '0.0.0';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// ── 1. Plugin cache ────────────────────────────────────────────────────
|
|
31
|
+
function writePluginCache(opts) {
|
|
32
|
+
const { pkgRoot, cacheDir, pluginsDir, version, dryRun } = opts;
|
|
33
|
+
if (dryRun)
|
|
34
|
+
return false;
|
|
35
|
+
const cacheParent = path.dirname(cacheDir);
|
|
36
|
+
// 이전 잔재 제거 + 디렉토리 작성
|
|
37
|
+
try {
|
|
38
|
+
fs.rmSync(cacheParent, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
catch { /* ignore */ }
|
|
41
|
+
fs.mkdirSync(cacheParent, { recursive: true });
|
|
42
|
+
// 1차: symlink 시도 (개발 환경)
|
|
43
|
+
let linked = false;
|
|
44
|
+
try {
|
|
45
|
+
fs.symlinkSync(pkgRoot, cacheDir, 'dir');
|
|
46
|
+
linked = true;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// symlink 실패 → cp fallback
|
|
50
|
+
}
|
|
51
|
+
if (!linked) {
|
|
52
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
53
|
+
const copyDirs = ['.claude-plugin', 'hooks', 'skills', 'assets'];
|
|
54
|
+
for (const dir of copyDirs) {
|
|
55
|
+
const src = path.join(pkgRoot, dir);
|
|
56
|
+
if (fs.existsSync(src))
|
|
57
|
+
fs.cpSync(src, path.join(cacheDir, dir), { recursive: true });
|
|
58
|
+
}
|
|
59
|
+
if (fs.existsSync(path.join(pkgRoot, 'dist'))) {
|
|
60
|
+
fs.cpSync(path.join(pkgRoot, 'dist'), path.join(cacheDir, 'dist'), { recursive: true });
|
|
61
|
+
}
|
|
62
|
+
// core deps
|
|
63
|
+
const coreDeps = ['js-yaml', '@modelcontextprotocol', 'zod'];
|
|
64
|
+
fs.mkdirSync(path.join(cacheDir, 'node_modules'), { recursive: true });
|
|
65
|
+
for (const dep of coreDeps) {
|
|
66
|
+
const depSrc = path.join(pkgRoot, 'node_modules', dep);
|
|
67
|
+
if (fs.existsSync(depSrc)) {
|
|
68
|
+
fs.cpSync(depSrc, path.join(cacheDir, 'node_modules', dep), { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// installed_plugins.json 등록
|
|
73
|
+
const installedPath = path.join(pluginsDir, 'installed_plugins.json');
|
|
74
|
+
let installed = { version: 2, plugins: {} };
|
|
75
|
+
if (fs.existsSync(installedPath)) {
|
|
76
|
+
try {
|
|
77
|
+
installed = JSON.parse(fs.readFileSync(installedPath, 'utf-8'));
|
|
78
|
+
}
|
|
79
|
+
catch { /* ignore */ }
|
|
80
|
+
}
|
|
81
|
+
installed.plugins = installed.plugins ?? {};
|
|
82
|
+
installed.plugins[PLUGIN_KEY] = [{
|
|
83
|
+
scope: 'user',
|
|
84
|
+
installPath: cacheDir,
|
|
85
|
+
version,
|
|
86
|
+
installedAt: new Date().toISOString(),
|
|
87
|
+
lastUpdated: new Date().toISOString(),
|
|
88
|
+
}];
|
|
89
|
+
fs.mkdirSync(pluginsDir, { recursive: true });
|
|
90
|
+
fs.writeFileSync(installedPath, `${JSON.stringify(installed, null, 2)}\n`);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
// ── 2. Slash commands ──────────────────────────────────────────────────
|
|
94
|
+
function writeSlashCommands(opts) {
|
|
95
|
+
const { pkgRoot, targetDir, dryRun } = opts;
|
|
96
|
+
const sourceDir = path.join(pkgRoot, 'assets', 'claude', 'commands');
|
|
97
|
+
if (!fs.existsSync(sourceDir))
|
|
98
|
+
return 0;
|
|
99
|
+
if (dryRun) {
|
|
100
|
+
return fs.readdirSync(sourceDir).filter((f) => f.endsWith('.md')).length;
|
|
101
|
+
}
|
|
102
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
103
|
+
let count = 0;
|
|
104
|
+
for (const file of fs.readdirSync(sourceDir).filter((f) => f.endsWith('.md'))) {
|
|
105
|
+
const skillContent = fs.readFileSync(path.join(sourceDir, file), 'utf-8');
|
|
106
|
+
const descMatch = skillContent.match(/description:\s*(.+)/);
|
|
107
|
+
const desc = descMatch?.[1]?.trim() ?? file.replace(/\.md$/, '');
|
|
108
|
+
const skillName = file.replace(/\.md$/, '');
|
|
109
|
+
const out = `# ${desc}\n\n${FORGEN_MANAGED_MARKER}\n\nActivate Forgen "${skillName}" mode for the task: $ARGUMENTS\n\n${skillContent}`;
|
|
110
|
+
const target = path.join(targetDir, file);
|
|
111
|
+
if (fs.existsSync(target)) {
|
|
112
|
+
const existing = fs.readFileSync(target, 'utf-8');
|
|
113
|
+
if (!existing.includes(FORGEN_MANAGED_MARKER))
|
|
114
|
+
continue; // 사용자 작성 — skip
|
|
115
|
+
}
|
|
116
|
+
fs.writeFileSync(target, out);
|
|
117
|
+
count += 1;
|
|
118
|
+
}
|
|
119
|
+
return count;
|
|
120
|
+
}
|
|
121
|
+
// ── 3. Settings hooks injection ────────────────────────────────────────
|
|
122
|
+
function injectHooksIntoSettings(opts) {
|
|
123
|
+
const { pkgRoot, settingsPath, dryRun } = opts;
|
|
124
|
+
// settings.json 컨텍스트는 ${CLAUDE_PLUGIN_ROOT} 미해석 — 절대 경로 박제 (postinstall.js 와 동일 노하우)
|
|
125
|
+
const generated = generateHooksJson({
|
|
126
|
+
pluginRoot: path.join(pkgRoot, 'dist'),
|
|
127
|
+
runtime: 'claude',
|
|
128
|
+
releaseMode: true,
|
|
129
|
+
});
|
|
130
|
+
let count = 0;
|
|
131
|
+
for (const events of Object.values(generated.hooks)) {
|
|
132
|
+
for (const group of events) {
|
|
133
|
+
const g = group;
|
|
134
|
+
if (Array.isArray(g.hooks))
|
|
135
|
+
count += g.hooks.length;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (dryRun)
|
|
139
|
+
return count;
|
|
140
|
+
let settings = {};
|
|
141
|
+
if (fs.existsSync(settingsPath)) {
|
|
142
|
+
try {
|
|
143
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
144
|
+
}
|
|
145
|
+
catch { /* fallthrough */ }
|
|
146
|
+
}
|
|
147
|
+
const hooksConfig = settings.hooks ?? {};
|
|
148
|
+
// 기존 forgen hook 제거 (path 에 pkgRoot 또는 CLAUDE_PLUGIN_ROOT 포함된 entry)
|
|
149
|
+
for (const [event, entries] of Object.entries(hooksConfig)) {
|
|
150
|
+
if (!Array.isArray(entries))
|
|
151
|
+
continue;
|
|
152
|
+
const filtered = entries.filter((entry) => {
|
|
153
|
+
const e = entry;
|
|
154
|
+
if (!Array.isArray(e.hooks))
|
|
155
|
+
return true;
|
|
156
|
+
// forgen-managed entry 식별: pkgRoot 또는 CLAUDE_PLUGIN_ROOT 또는 'forgen' 포함
|
|
157
|
+
return !e.hooks.some((h) => typeof h.command === 'string' &&
|
|
158
|
+
(h.command.includes(pkgRoot) || h.command.includes('CLAUDE_PLUGIN_ROOT') || h.command.includes('/forgen-local/forgen/')));
|
|
159
|
+
});
|
|
160
|
+
if (filtered.length === 0)
|
|
161
|
+
delete hooksConfig[event];
|
|
162
|
+
else
|
|
163
|
+
hooksConfig[event] = filtered;
|
|
164
|
+
}
|
|
165
|
+
// forgen 측 entry 추가
|
|
166
|
+
for (const [event, entries] of Object.entries(generated.hooks)) {
|
|
167
|
+
if (!hooksConfig[event])
|
|
168
|
+
hooksConfig[event] = [];
|
|
169
|
+
hooksConfig[event].push(...entries);
|
|
170
|
+
}
|
|
171
|
+
settings.hooks = hooksConfig;
|
|
172
|
+
// enabledPlugins 등록
|
|
173
|
+
const enabled = settings.enabledPlugins ?? {};
|
|
174
|
+
enabled[PLUGIN_KEY] = true;
|
|
175
|
+
settings.enabledPlugins = enabled;
|
|
176
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
177
|
+
fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
178
|
+
return count;
|
|
179
|
+
}
|
|
180
|
+
function registerMcpInClaudeJson(opts) {
|
|
181
|
+
const { pkgRoot, claudeJsonPath, dryRun } = opts;
|
|
182
|
+
const serverPath = path.join(pkgRoot, 'dist', 'mcp', 'server.js');
|
|
183
|
+
const desired = { command: 'node', args: [serverPath] };
|
|
184
|
+
let claudeJson = {};
|
|
185
|
+
if (fs.existsSync(claudeJsonPath)) {
|
|
186
|
+
try {
|
|
187
|
+
claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf-8'));
|
|
188
|
+
}
|
|
189
|
+
catch { /* ignore */ }
|
|
190
|
+
}
|
|
191
|
+
const mcpServers = claudeJson.mcpServers ?? {};
|
|
192
|
+
const existing = mcpServers['forgen-compound'];
|
|
193
|
+
const alreadyPresent = existing !== undefined &&
|
|
194
|
+
existing.command === 'node' &&
|
|
195
|
+
Array.isArray(existing.args) &&
|
|
196
|
+
JSON.stringify(existing.args) === JSON.stringify(desired.args);
|
|
197
|
+
if (dryRun) {
|
|
198
|
+
return { registered: !alreadyPresent, alreadyPresent };
|
|
199
|
+
}
|
|
200
|
+
mcpServers['forgen-compound'] = desired;
|
|
201
|
+
claudeJson.mcpServers = mcpServers;
|
|
202
|
+
fs.mkdirSync(path.dirname(claudeJsonPath), { recursive: true });
|
|
203
|
+
fs.writeFileSync(claudeJsonPath, `${JSON.stringify(claudeJson, null, 2)}\n`);
|
|
204
|
+
return { registered: !alreadyPresent, alreadyPresent };
|
|
205
|
+
}
|
|
206
|
+
// ── public ─────────────────────────────────────────────────────────────
|
|
207
|
+
export function planClaudeInstall(opts) {
|
|
208
|
+
if (!opts.pkgRoot || !fs.existsSync(opts.pkgRoot)) {
|
|
209
|
+
throw new Error(`planClaudeInstall: invalid pkgRoot ${opts.pkgRoot}`);
|
|
210
|
+
}
|
|
211
|
+
const homeDir = opts.homeDir ?? os.homedir();
|
|
212
|
+
const dryRun = opts.dryRun ?? false;
|
|
213
|
+
const registerMcp = opts.registerMcp ?? true;
|
|
214
|
+
const version = readPkgVersion(opts.pkgRoot);
|
|
215
|
+
const claudeDir = path.join(homeDir, '.claude');
|
|
216
|
+
const pluginsDir = path.join(claudeDir, 'plugins');
|
|
217
|
+
const cacheDir = path.join(pluginsDir, 'cache', 'forgen-local', 'forgen', version);
|
|
218
|
+
const slashCommandsDir = path.join(claudeDir, 'commands', 'forgen');
|
|
219
|
+
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
220
|
+
const claudeJsonPath = path.join(homeDir, '.claude.json');
|
|
221
|
+
const pluginCacheWritten = writePluginCache({ pkgRoot: opts.pkgRoot, cacheDir, pluginsDir, version, dryRun });
|
|
222
|
+
const slashCommandsCount = writeSlashCommands({ pkgRoot: opts.pkgRoot, targetDir: slashCommandsDir, dryRun });
|
|
223
|
+
const hooksInjected = injectHooksIntoSettings({ pkgRoot: opts.pkgRoot, settingsPath, dryRun });
|
|
224
|
+
const mcp = registerMcp
|
|
225
|
+
? registerMcpInClaudeJson({ pkgRoot: opts.pkgRoot, claudeJsonPath, dryRun })
|
|
226
|
+
: { registered: false, alreadyPresent: false };
|
|
227
|
+
return {
|
|
228
|
+
homeDir,
|
|
229
|
+
pluginCachePath: cacheDir,
|
|
230
|
+
pluginCacheWritten,
|
|
231
|
+
slashCommandsPath: slashCommandsDir,
|
|
232
|
+
slashCommandsCount,
|
|
233
|
+
settingsPath,
|
|
234
|
+
hooksInjected,
|
|
235
|
+
mcpRegistered: mcp.registered,
|
|
236
|
+
mcpAlreadyPresent: mcp.alreadyPresent,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex InstallPlan — Multi-Host Core Design §10 우선순위 3
|
|
3
|
+
*
|
|
4
|
+
* `~/.codex/hooks.json` 에 forgen hook 등록(절대경로, idempotent), `~/.codex/config.toml`
|
|
5
|
+
* 에 forgen-compound MCP 등록(managed marker block). $CODEX_HOME 환경변수 존중.
|
|
6
|
+
*
|
|
7
|
+
* 동작 원칙:
|
|
8
|
+
* - hook 등록은 generateHooksJson({runtime:'codex', pluginRoot, releaseMode}) 결과를 그대로 사용
|
|
9
|
+
* — 이미 codex-adapter wrapper + 절대경로 적용됨 (spec §18.5 결정 옵션 1).
|
|
10
|
+
* - 사용자가 직접 작성한 비-forgen hook 은 보존 (`isForgenHookEntry` pattern).
|
|
11
|
+
* - MCP 등록은 TOML 라이브러리 없이 marker block 으로 idempotent 관리.
|
|
12
|
+
* - dryRun 시 파일을 쓰지 않고 결과만 반환 (테스트 + preview 용).
|
|
13
|
+
*/
|
|
14
|
+
export interface CodexInstallOptions {
|
|
15
|
+
/** forgen package root (build 산출물 dist/ 의 부모). 기본: 호출 시 process.cwd(). */
|
|
16
|
+
pkgRoot: string;
|
|
17
|
+
/** codex home (default: $CODEX_HOME ?? ~/.codex). */
|
|
18
|
+
codexHome?: string;
|
|
19
|
+
/** dry-run: 파일 미작성, 결과만 반환. */
|
|
20
|
+
dryRun?: boolean;
|
|
21
|
+
/** MCP 서버 등록 여부 (default true). */
|
|
22
|
+
registerMcp?: boolean;
|
|
23
|
+
/** hooks-generator releaseMode (default true: 환경 독립). */
|
|
24
|
+
releaseMode?: boolean;
|
|
25
|
+
/** AGENTS.md 위치 override (default: pkgRoot 기준 자동 resolve). 격리 테스트용. */
|
|
26
|
+
agentsMdPath?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface CodexInstallResult {
|
|
29
|
+
codexHome: string;
|
|
30
|
+
hooksPath: string;
|
|
31
|
+
hooksWritten: boolean;
|
|
32
|
+
hooksCount: number;
|
|
33
|
+
preservedUserHookCount: number;
|
|
34
|
+
configTomlPath: string;
|
|
35
|
+
mcpRegistered: boolean;
|
|
36
|
+
mcpAlreadyPresent: boolean;
|
|
37
|
+
/** P3-3 (US-013): Codex skills/ 에 install 된 forgen 명령 수 */
|
|
38
|
+
skillsInstalled: number;
|
|
39
|
+
skillsPath: string;
|
|
40
|
+
/** P3-3: AGENTS.md (cwd) 에 forgen rule block 인젝션 여부 */
|
|
41
|
+
agentsMdPath: string;
|
|
42
|
+
agentsMdInjected: boolean;
|
|
43
|
+
}
|
|
44
|
+
export declare function planCodexInstall(opts: CodexInstallOptions): CodexInstallResult;
|