triflux 9.7.0 → 9.7.1
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/bin/triflux.mjs +121 -1
- package/hooks/hook-manager.mjs +6 -5
- package/hooks/hook-orchestrator.mjs +3 -2
- package/hooks/hook-registry.json +3 -3
- package/hooks/lib/resolve-root.mjs +59 -0
- package/hooks/pipeline-stop.mjs +9 -14
- package/package.json +1 -1
- package/scripts/run.cjs +79 -62
- package/scripts/setup.mjs +248 -2
package/bin/triflux.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// triflux CLI — setup, doctor, version
|
|
3
3
|
import { copyFileSync, existsSync, readFileSync, readSync, writeFileSync, mkdirSync, chmodSync, readdirSync, unlinkSync, statSync, openSync, closeSync } from "fs";
|
|
4
|
-
import { join, dirname } from "path";
|
|
4
|
+
import { join, dirname, basename } from "path";
|
|
5
5
|
import { homedir } from "os";
|
|
6
6
|
import { execSync, execFileSync, spawn } from "child_process";
|
|
7
7
|
import { fileURLToPath } from "url";
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
SYNC_MAP, SKILL_ALIASES, REQUIRED_CODEX_PROFILES, LEGACY_CODEX_MODELS,
|
|
25
25
|
syncAliasedSkillDir, hasProfileSection, replaceProfileSection,
|
|
26
26
|
ensureCodexProfiles, getVersion, cleanupStaleSkills, DEPRECATED_SKILLS,
|
|
27
|
+
extractManagedHookFilename, getManagedRegistryHooks, ensureHooksInSettings,
|
|
27
28
|
} from "../scripts/setup.mjs";
|
|
28
29
|
|
|
29
30
|
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
@@ -941,6 +942,37 @@ function addDoctorCheck(report, entry) {
|
|
|
941
942
|
report.checks.push(entry);
|
|
942
943
|
}
|
|
943
944
|
|
|
945
|
+
function toHookCoverageName(fileName, fallbackId = "") {
|
|
946
|
+
if (typeof fileName === "string" && fileName.trim()) {
|
|
947
|
+
return basename(fileName).replace(/\.mjs$/i, "");
|
|
948
|
+
}
|
|
949
|
+
return String(fallbackId || "").replace(/^tfx-/, "");
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function computeHookCoverage(settings, managedHooks) {
|
|
953
|
+
const coverage = {
|
|
954
|
+
total: managedHooks.length,
|
|
955
|
+
registered: 0,
|
|
956
|
+
missing: [],
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
const hooksByEvent = settings?.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
|
|
960
|
+
for (const spec of managedHooks) {
|
|
961
|
+
const eventEntries = Array.isArray(hooksByEvent[spec.event]) ? hooksByEvent[spec.event] : [];
|
|
962
|
+
const found = eventEntries.some((entry) =>
|
|
963
|
+
Array.isArray(entry?.hooks) &&
|
|
964
|
+
entry.hooks.some((hook) => extractManagedHookFilename(hook?.command) === spec.fileName),
|
|
965
|
+
);
|
|
966
|
+
if (found) {
|
|
967
|
+
coverage.registered++;
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
coverage.missing.push(toHookCoverageName(spec.fileName, spec.id));
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
return coverage;
|
|
974
|
+
}
|
|
975
|
+
|
|
944
976
|
function formatPathForDisplay(filePath) {
|
|
945
977
|
const value = String(filePath || "").replace(/\\/g, "/");
|
|
946
978
|
const homePath = homedir().replace(/\\/g, "/");
|
|
@@ -1046,6 +1078,7 @@ async function cmdDoctor(options = {}) {
|
|
|
1046
1078
|
mode: reset ? "reset" : fix ? "fix" : "check",
|
|
1047
1079
|
checks: [],
|
|
1048
1080
|
actions: [],
|
|
1081
|
+
hook_coverage: { total: 0, registered: 0, missing: [] },
|
|
1049
1082
|
issue_count: 0,
|
|
1050
1083
|
};
|
|
1051
1084
|
|
|
@@ -2091,6 +2124,93 @@ async function cmdDoctor(options = {}) {
|
|
|
2091
2124
|
}
|
|
2092
2125
|
}
|
|
2093
2126
|
|
|
2127
|
+
// ── Hook Coverage (hook-registry vs settings.json) ──
|
|
2128
|
+
section("Hook Coverage");
|
|
2129
|
+
{
|
|
2130
|
+
const registryPath = join(PKG_ROOT, "hooks", "hook-registry.json");
|
|
2131
|
+
const settingsPath = join(CLAUDE_DIR, "settings.json");
|
|
2132
|
+
const managedHooks = getManagedRegistryHooks(registryPath);
|
|
2133
|
+
|
|
2134
|
+
if (managedHooks.length === 0) {
|
|
2135
|
+
addDoctorCheck(report, {
|
|
2136
|
+
name: "hook-coverage",
|
|
2137
|
+
status: "invalid",
|
|
2138
|
+
total: 0,
|
|
2139
|
+
registered: 0,
|
|
2140
|
+
missing: [],
|
|
2141
|
+
fix: "hook-registry.json을 확인하세요.",
|
|
2142
|
+
});
|
|
2143
|
+
warn("hook-registry.json에서 관리 대상 훅을 찾지 못했습니다.");
|
|
2144
|
+
issues++;
|
|
2145
|
+
} else {
|
|
2146
|
+
let settings = {};
|
|
2147
|
+
if (existsSync(settingsPath)) {
|
|
2148
|
+
try {
|
|
2149
|
+
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
2150
|
+
} catch (error) {
|
|
2151
|
+
const unreadableCoverage = {
|
|
2152
|
+
total: managedHooks.length,
|
|
2153
|
+
registered: 0,
|
|
2154
|
+
missing: managedHooks.map((spec) => toHookCoverageName(spec.fileName, spec.id)),
|
|
2155
|
+
};
|
|
2156
|
+
report.hook_coverage = unreadableCoverage;
|
|
2157
|
+
addDoctorCheck(report, {
|
|
2158
|
+
name: "hook-coverage",
|
|
2159
|
+
status: "invalid",
|
|
2160
|
+
total: unreadableCoverage.total,
|
|
2161
|
+
registered: unreadableCoverage.registered,
|
|
2162
|
+
missing: unreadableCoverage.missing,
|
|
2163
|
+
fix: "settings.json 문법을 수정하거나 tfx setup을 다시 실행하세요.",
|
|
2164
|
+
});
|
|
2165
|
+
fail(`settings.json 파싱 실패: ${error.message}`);
|
|
2166
|
+
issues++;
|
|
2167
|
+
settings = null;
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
if (settings) {
|
|
2172
|
+
let coverage = computeHookCoverage(settings, managedHooks);
|
|
2173
|
+
|
|
2174
|
+
if (coverage.missing.length > 0 && fix) {
|
|
2175
|
+
const hookFixResult = ensureHooksInSettings({ settingsPath, registryPath });
|
|
2176
|
+
if (hookFixResult.ok) {
|
|
2177
|
+
if (hookFixResult.changed) {
|
|
2178
|
+
ok(`누락 훅 ${hookFixResult.added.length}개 자동 등록됨`);
|
|
2179
|
+
} else {
|
|
2180
|
+
info("누락 훅 자동 등록: 변경 사항 없음");
|
|
2181
|
+
}
|
|
2182
|
+
try {
|
|
2183
|
+
const fixedSettings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
2184
|
+
coverage = computeHookCoverage(fixedSettings, managedHooks);
|
|
2185
|
+
} catch (error) {
|
|
2186
|
+
warn(`자동 등록 후 settings.json 재검증 실패: ${error.message}`);
|
|
2187
|
+
}
|
|
2188
|
+
} else {
|
|
2189
|
+
warn(`누락 훅 자동 등록 실패: ${hookFixResult.reason || "unknown_error"}`);
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
report.hook_coverage = coverage;
|
|
2194
|
+
const coverageStatus = coverage.missing.length === 0 ? "ok" : "issues";
|
|
2195
|
+
addDoctorCheck(report, {
|
|
2196
|
+
name: "hook-coverage",
|
|
2197
|
+
status: coverageStatus,
|
|
2198
|
+
total: coverage.total,
|
|
2199
|
+
registered: coverage.registered,
|
|
2200
|
+
missing: coverage.missing,
|
|
2201
|
+
...(coverage.missing.length > 0 ? { fix: "tfx doctor --fix 또는 tfx setup" } : {}),
|
|
2202
|
+
});
|
|
2203
|
+
|
|
2204
|
+
if (coverage.missing.length === 0) {
|
|
2205
|
+
ok(`Hook Coverage: ${coverage.registered}/${coverage.total} registered`);
|
|
2206
|
+
} else {
|
|
2207
|
+
fail(`Missing hooks: ${coverage.missing.join(", ")}`);
|
|
2208
|
+
issues += coverage.missing.length;
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2094
2214
|
// 결과
|
|
2095
2215
|
console.log(`\n ${LINE}`);
|
|
2096
2216
|
if (issues === 0) {
|
package/hooks/hook-manager.mjs
CHANGED
|
@@ -12,16 +12,16 @@
|
|
|
12
12
|
//
|
|
13
13
|
// Claude 대화에서 AskUserQuestion으로 UI를 제공하며 내부적으로 이 명령들을 호출합니다.
|
|
14
14
|
|
|
15
|
-
import { readFileSync, writeFileSync, existsSync
|
|
15
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
16
16
|
import { join, dirname } from "node:path";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { PLUGIN_ROOT } from "./lib/resolve-root.mjs";
|
|
18
19
|
|
|
19
20
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
21
|
const HOME = process.env.HOME || process.env.USERPROFILE || "";
|
|
21
22
|
const SETTINGS_PATH = join(HOME, ".claude", "settings.json");
|
|
22
23
|
const BACKUP_PATH = join(HOME, ".claude", "settings.hooks-backup.json");
|
|
23
24
|
const REGISTRY_PATH = join(__dirname, "hook-registry.json");
|
|
24
|
-
const ORCHESTRATOR_PATH = join(__dirname, "hook-orchestrator.mjs");
|
|
25
25
|
|
|
26
26
|
// ── 유틸리티 ────────────────────────────────────────────────
|
|
27
27
|
|
|
@@ -105,9 +105,9 @@ function normalizeCmd(cmd) {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
function resolveVars(cmd) {
|
|
108
|
-
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || __dirname.replace(/[\\/]hooks$/, "");
|
|
109
108
|
return cmd
|
|
110
|
-
.replace(/\$\{PLUGIN_ROOT\}/g,
|
|
109
|
+
.replace(/\$\{PLUGIN_ROOT\}/g, PLUGIN_ROOT)
|
|
110
|
+
.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, PLUGIN_ROOT)
|
|
111
111
|
.replace(/\$\{HOME\}/g, HOME)
|
|
112
112
|
.replace(/\$HOME\b/g, HOME);
|
|
113
113
|
}
|
|
@@ -171,7 +171,8 @@ function apply() {
|
|
|
171
171
|
|
|
172
172
|
// 오케스트레이터 명령 생성
|
|
173
173
|
const nodeExe = getNodeExe();
|
|
174
|
-
const
|
|
174
|
+
const orchestratorPath = "${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs";
|
|
175
|
+
const orchestratorCmd = `"${nodeExe}" "${orchestratorPath}"`;
|
|
175
176
|
|
|
176
177
|
// 모든 이벤트를 하나의 오케스트레이터로 통합
|
|
177
178
|
const newHooks = {};
|
|
@@ -23,6 +23,7 @@ import { readFileSync, existsSync } from "node:fs";
|
|
|
23
23
|
import { join, dirname } from "node:path";
|
|
24
24
|
import { fileURLToPath } from "node:url";
|
|
25
25
|
import { execFileSync } from "node:child_process";
|
|
26
|
+
import { PLUGIN_ROOT } from "./lib/resolve-root.mjs";
|
|
26
27
|
|
|
27
28
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
28
29
|
const REGISTRY_PATH =
|
|
@@ -50,10 +51,10 @@ function loadRegistry() {
|
|
|
50
51
|
// ── 경로 변수 치환 ──────────────────────────────────────────
|
|
51
52
|
function resolveCommand(cmd) {
|
|
52
53
|
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
53
|
-
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || __dirname.replace(/[\\/]hooks$/, "");
|
|
54
54
|
|
|
55
55
|
return cmd
|
|
56
|
-
.replace(/\$\{PLUGIN_ROOT\}/g,
|
|
56
|
+
.replace(/\$\{PLUGIN_ROOT\}/g, PLUGIN_ROOT)
|
|
57
|
+
.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, PLUGIN_ROOT)
|
|
57
58
|
.replace(/\$\{HOME\}/g, home)
|
|
58
59
|
.replace(/\$HOME\b/g, home);
|
|
59
60
|
}
|
package/hooks/hook-registry.json
CHANGED
|
@@ -155,7 +155,7 @@
|
|
|
155
155
|
"matcher": "*",
|
|
156
156
|
"command": "${HOME}/Desktop/Projects/cli/session-vault/scripts/start_hook.sh",
|
|
157
157
|
"priority": 100,
|
|
158
|
-
"enabled":
|
|
158
|
+
"enabled": false,
|
|
159
159
|
"timeout": 10,
|
|
160
160
|
"blocking": false,
|
|
161
161
|
"description": "세션 볼트 로깅 시작"
|
|
@@ -179,7 +179,7 @@
|
|
|
179
179
|
"matcher": "*",
|
|
180
180
|
"command": "${HOME}/Desktop/Projects/cli/session-vault/scripts/export_hook.sh",
|
|
181
181
|
"priority": 100,
|
|
182
|
-
"enabled":
|
|
182
|
+
"enabled": false,
|
|
183
183
|
"timeout": 30,
|
|
184
184
|
"blocking": false,
|
|
185
185
|
"description": "세션 트랜스크립트 내보내기"
|
|
@@ -190,7 +190,7 @@
|
|
|
190
190
|
"matcher": "*",
|
|
191
191
|
"command": "powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \"${HOME}/.claude/scripts/mcp-cleanup.ps1\"",
|
|
192
192
|
"priority": 100,
|
|
193
|
-
"enabled":
|
|
193
|
+
"enabled": false,
|
|
194
194
|
"timeout": 8,
|
|
195
195
|
"blocking": false,
|
|
196
196
|
"description": "MCP 고아 프로세스 정리"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const BREADCRUMB_PATH = join(homedir(), ".claude", "scripts", ".tfx-pkg-root");
|
|
7
|
+
|
|
8
|
+
function normalizeCandidate(candidate) {
|
|
9
|
+
if (typeof candidate !== "string") return null;
|
|
10
|
+
const value = candidate.trim();
|
|
11
|
+
if (!value) return null;
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isValidPluginRoot(candidate) {
|
|
16
|
+
const root = normalizeCandidate(candidate);
|
|
17
|
+
if (!root) return false;
|
|
18
|
+
return existsSync(join(root, "hooks", "hook-orchestrator.mjs"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function toPluginRootFromUrl(url) {
|
|
22
|
+
if (typeof url !== "string" || !url) return null;
|
|
23
|
+
try {
|
|
24
|
+
const sourceDir = dirname(fileURLToPath(url));
|
|
25
|
+
const hookScoped = sourceDir.match(/^(.*?)[\\/]hooks(?:[\\/].*)?$/);
|
|
26
|
+
if (hookScoped?.[1]) return hookScoped[1];
|
|
27
|
+
return resolve(sourceDir, "..");
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readBreadcrumbRoot() {
|
|
34
|
+
if (!existsSync(BREADCRUMB_PATH)) return null;
|
|
35
|
+
try {
|
|
36
|
+
return normalizeCandidate(readFileSync(BREADCRUMB_PATH, "utf8"));
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function resolvePluginRoot(callerUrl) {
|
|
43
|
+
const breadcrumbRoot = readBreadcrumbRoot();
|
|
44
|
+
if (isValidPluginRoot(breadcrumbRoot)) return breadcrumbRoot;
|
|
45
|
+
|
|
46
|
+
const envRoot = normalizeCandidate(process.env.CLAUDE_PLUGIN_ROOT);
|
|
47
|
+
if (isValidPluginRoot(envRoot)) return envRoot;
|
|
48
|
+
|
|
49
|
+
const callerFallback = toPluginRootFromUrl(callerUrl);
|
|
50
|
+
if (isValidPluginRoot(callerFallback)) return callerFallback;
|
|
51
|
+
|
|
52
|
+
const moduleFallback = toPluginRootFromUrl(import.meta.url) || process.cwd();
|
|
53
|
+
process.stderr.write(
|
|
54
|
+
`[resolve-root] warning: failed to resolve plugin root from breadcrumb/env/caller; fallback=${moduleFallback}\n`
|
|
55
|
+
);
|
|
56
|
+
return moduleFallback;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const PLUGIN_ROOT = resolvePluginRoot(import.meta.url);
|
package/hooks/pipeline-stop.mjs
CHANGED
|
@@ -6,20 +6,23 @@
|
|
|
6
6
|
// 파이프라인이 없으면 정상 종료를 허용한다.
|
|
7
7
|
|
|
8
8
|
import { existsSync } from "node:fs";
|
|
9
|
-
import {
|
|
10
|
-
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { PLUGIN_ROOT } from "./lib/resolve-root.mjs";
|
|
11
10
|
|
|
12
11
|
let getPipelineStateDbPath;
|
|
12
|
+
let ensurePipelineTable;
|
|
13
|
+
let listPipelineStates;
|
|
13
14
|
try {
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
({
|
|
16
|
+
getPipelineStateDbPath,
|
|
17
|
+
ensurePipelineTable,
|
|
18
|
+
listPipelineStates,
|
|
19
|
+
} = await import("../hub/pipeline/state.mjs"));
|
|
16
20
|
} catch {
|
|
17
21
|
// hub/pipeline 모듈 없으면 훅 무동작
|
|
18
22
|
process.exit(0);
|
|
19
23
|
}
|
|
20
24
|
|
|
21
|
-
const
|
|
22
|
-
const HUB_DB_PATH = getPipelineStateDbPath(PROJECT_ROOT);
|
|
25
|
+
const HUB_DB_PATH = getPipelineStateDbPath(PLUGIN_ROOT);
|
|
23
26
|
const TERMINAL = new Set(["complete", "failed"]);
|
|
24
27
|
|
|
25
28
|
async function checkActivePipelines() {
|
|
@@ -27,14 +30,6 @@ async function checkActivePipelines() {
|
|
|
27
30
|
|
|
28
31
|
try {
|
|
29
32
|
const { default: Database } = await import("better-sqlite3");
|
|
30
|
-
const { ensurePipelineTable, listPipelineStates } = await import(
|
|
31
|
-
join(
|
|
32
|
-
process.env.CLAUDE_PLUGIN_ROOT || PROJECT_ROOT,
|
|
33
|
-
"hub",
|
|
34
|
-
"pipeline",
|
|
35
|
-
"state.mjs"
|
|
36
|
-
)
|
|
37
|
-
);
|
|
38
33
|
|
|
39
34
|
const db = new Database(HUB_DB_PATH, { readonly: true });
|
|
40
35
|
ensurePipelineTable(db);
|
package/package.json
CHANGED
package/scripts/run.cjs
CHANGED
|
@@ -1,62 +1,79 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
"use strict";
|
|
3
|
-
|
|
4
|
-
const { execFileSync } = require("child_process");
|
|
5
|
-
const { existsSync, readFileSync } = require("fs");
|
|
6
|
-
const { dirname, isAbsolute, join, resolve } = require("path");
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
return
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const { execFileSync } = require("child_process");
|
|
5
|
+
const { existsSync, readFileSync } = require("fs");
|
|
6
|
+
const { dirname, isAbsolute, join, resolve } = require("path");
|
|
7
|
+
const { homedir } = require("os");
|
|
8
|
+
|
|
9
|
+
function isValidPluginRoot(candidate) {
|
|
10
|
+
return typeof candidate === "string"
|
|
11
|
+
&& candidate.trim().length > 0
|
|
12
|
+
&& existsSync(join(candidate.trim(), "hooks", "hook-orchestrator.mjs"));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function resolvePluginRoot() {
|
|
16
|
+
const breadcrumb = join(homedir(), ".claude", "scripts", ".tfx-pkg-root");
|
|
17
|
+
if (existsSync(breadcrumb)) {
|
|
18
|
+
try {
|
|
19
|
+
const value = readFileSync(breadcrumb, "utf8").trim();
|
|
20
|
+
if (isValidPluginRoot(value)) return value;
|
|
21
|
+
} catch {
|
|
22
|
+
// breadcrumb 읽기 실패 시 다음 fallback
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (isValidPluginRoot(process.env.CLAUDE_PLUGIN_ROOT)) return process.env.CLAUDE_PLUGIN_ROOT;
|
|
27
|
+
return dirname(__dirname);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveTargetPath(rawTarget) {
|
|
31
|
+
if (!rawTarget || typeof rawTarget !== "string") return null;
|
|
32
|
+
|
|
33
|
+
const pluginRoot = resolvePluginRoot();
|
|
34
|
+
const trimmed = rawTarget.trim();
|
|
35
|
+
|
|
36
|
+
if (trimmed.startsWith("${CLAUDE_PLUGIN_ROOT}/")) {
|
|
37
|
+
return join(pluginRoot, trimmed.replace("${CLAUDE_PLUGIN_ROOT}/", ""));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (trimmed.startsWith("/scripts/")) {
|
|
41
|
+
return join(pluginRoot, trimmed.replace(/^\/+/, ""));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (isAbsolute(trimmed)) return trimmed;
|
|
45
|
+
return resolve(process.cwd(), trimmed);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const targetArg = process.argv[2];
|
|
49
|
+
if (!targetArg) {
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const targetPath = resolveTargetPath(targetArg);
|
|
54
|
+
if (!targetPath || !existsSync(targetPath)) {
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const stdinBuffer = (() => {
|
|
59
|
+
try {
|
|
60
|
+
return readFileSync(0);
|
|
61
|
+
} catch {
|
|
62
|
+
return Buffer.alloc(0);
|
|
63
|
+
}
|
|
64
|
+
})();
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
execFileSync(process.execPath, [targetPath, ...process.argv.slice(3)], {
|
|
68
|
+
env: process.env,
|
|
69
|
+
stdio: ["pipe", "inherit", "inherit"],
|
|
70
|
+
input: stdinBuffer,
|
|
71
|
+
windowsHide: true
|
|
72
|
+
});
|
|
73
|
+
process.exit(0);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (typeof error?.status === "number") {
|
|
76
|
+
process.exit(error.status);
|
|
77
|
+
}
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
package/scripts/setup.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// - skills/를 ~/.claude/skills/에 동기화
|
|
6
6
|
|
|
7
7
|
import { copyFileSync, mkdirSync, readFileSync, writeFileSync, readdirSync, existsSync, chmodSync, unlinkSync, rmSync } from "fs";
|
|
8
|
-
import { join, dirname } from "path";
|
|
8
|
+
import { join, dirname, basename } from "path";
|
|
9
9
|
import { homedir } from "os";
|
|
10
10
|
import { spawn, execFileSync } from "child_process";
|
|
11
11
|
import { fileURLToPath } from "url";
|
|
@@ -412,13 +412,253 @@ function ensureCodexProfiles() {
|
|
|
412
412
|
}
|
|
413
413
|
}
|
|
414
414
|
|
|
415
|
+
const WINDOWS_DEFAULT_NODE_PATH = "C:/Program Files/nodejs/node.exe";
|
|
416
|
+
const MANAGED_HOOK_FILENAMES = new Set([
|
|
417
|
+
"safety-guard.mjs",
|
|
418
|
+
"agent-route-guard.mjs",
|
|
419
|
+
"cross-review-tracker.mjs",
|
|
420
|
+
"error-context.mjs",
|
|
421
|
+
"keyword-detector.mjs",
|
|
422
|
+
"pipeline-stop.mjs",
|
|
423
|
+
"subagent-verifier.mjs",
|
|
424
|
+
]);
|
|
425
|
+
|
|
426
|
+
function toForwardPath(value) {
|
|
427
|
+
return String(value || "").replace(/\\/g, "/");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function quotePath(value) {
|
|
431
|
+
return `"${toForwardPath(value)}"`;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function normalizeCommand(value) {
|
|
435
|
+
return toForwardPath(value).replace(/\s+/g, " ").trim();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function extractManagedHookFilename(command) {
|
|
439
|
+
if (typeof command !== "string") return null;
|
|
440
|
+
const matches = command.match(/[A-Za-z0-9._-]+\.mjs/g) || [];
|
|
441
|
+
for (const match of matches) {
|
|
442
|
+
const fileName = basename(match);
|
|
443
|
+
if (MANAGED_HOOK_FILENAMES.has(fileName)) return fileName;
|
|
444
|
+
}
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function isValidManagedHookRoot(candidateRoot) {
|
|
449
|
+
if (typeof candidateRoot !== "string" || !candidateRoot.trim()) return false;
|
|
450
|
+
const root = candidateRoot.trim();
|
|
451
|
+
if (!existsSync(join(root, "hooks", "hook-registry.json"))) return false;
|
|
452
|
+
if (!existsSync(join(root, "scripts", "run.cjs"))) return false;
|
|
453
|
+
if (!existsSync(join(root, "scripts", "keyword-detector.mjs"))) return false;
|
|
454
|
+
|
|
455
|
+
for (const fileName of MANAGED_HOOK_FILENAMES) {
|
|
456
|
+
if (!existsSync(join(root, "hooks", fileName))) return false;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function resolveHookPluginRoot() {
|
|
463
|
+
const envRoot = process.env.PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT;
|
|
464
|
+
if (isValidManagedHookRoot(envRoot)) {
|
|
465
|
+
return toForwardPath(envRoot.trim());
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
try {
|
|
469
|
+
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
470
|
+
const npmGlobalRoot = execFileSync(npmCmd, ["root", "-g"], {
|
|
471
|
+
encoding: "utf8",
|
|
472
|
+
timeout: 5000,
|
|
473
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
474
|
+
windowsHide: true,
|
|
475
|
+
}).trim();
|
|
476
|
+
const npmPluginRoot = npmGlobalRoot ? join(npmGlobalRoot, "triflux") : "";
|
|
477
|
+
if (isValidManagedHookRoot(npmPluginRoot)) {
|
|
478
|
+
return toForwardPath(npmPluginRoot);
|
|
479
|
+
}
|
|
480
|
+
} catch {
|
|
481
|
+
// npm global root 조회 실패 시 로컬 패키지 루트를 fallback으로 사용
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return toForwardPath(PLUGIN_ROOT);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function resolveManagedNodePath() {
|
|
488
|
+
if (process.platform === "win32" && existsSync(WINDOWS_DEFAULT_NODE_PATH)) {
|
|
489
|
+
return toForwardPath(WINDOWS_DEFAULT_NODE_PATH);
|
|
490
|
+
}
|
|
491
|
+
return toForwardPath(process.execPath || "node");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function buildManagedHookCommand(fileName, { pluginRoot, nodePath }) {
|
|
495
|
+
if (fileName === "keyword-detector.mjs") {
|
|
496
|
+
const runScript = join(pluginRoot, "scripts", "run.cjs");
|
|
497
|
+
const detectorScript = join(pluginRoot, "scripts", "keyword-detector.mjs");
|
|
498
|
+
return `${quotePath(nodePath)} ${quotePath(runScript)} ${quotePath(detectorScript)}`;
|
|
499
|
+
}
|
|
500
|
+
const hookPath = join(pluginRoot, "hooks", fileName);
|
|
501
|
+
return `${quotePath(nodePath)} ${quotePath(hookPath)}`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function getManagedRegistryHooks(registryPath = join(PLUGIN_ROOT, "hooks", "hook-registry.json")) {
|
|
505
|
+
if (!existsSync(registryPath)) return [];
|
|
506
|
+
|
|
507
|
+
let registry;
|
|
508
|
+
try {
|
|
509
|
+
registry = JSON.parse(readFileSync(registryPath, "utf8"));
|
|
510
|
+
} catch {
|
|
511
|
+
return [];
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const hooks = [];
|
|
515
|
+
for (const [event, eventEntries] of Object.entries(registry.events || {})) {
|
|
516
|
+
if (!Array.isArray(eventEntries)) continue;
|
|
517
|
+
|
|
518
|
+
for (const eventEntry of eventEntries) {
|
|
519
|
+
if (!eventEntry || eventEntry.enabled === false || eventEntry.source !== "triflux") continue;
|
|
520
|
+
const fileName = extractManagedHookFilename(eventEntry.command);
|
|
521
|
+
if (!fileName) continue;
|
|
522
|
+
|
|
523
|
+
hooks.push({
|
|
524
|
+
id: String(eventEntry.id || fileName.replace(/\.mjs$/i, "")),
|
|
525
|
+
event: String(event),
|
|
526
|
+
matcher: String(eventEntry.matcher || "*"),
|
|
527
|
+
fileName,
|
|
528
|
+
timeout: Number.isFinite(eventEntry.timeout) ? eventEntry.timeout : undefined,
|
|
529
|
+
blocking: typeof eventEntry.blocking === "boolean" ? eventEntry.blocking : undefined,
|
|
530
|
+
priority: Number.isFinite(eventEntry.priority) ? eventEntry.priority : undefined,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return hooks;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function ensureHooksInSettings({
|
|
539
|
+
settingsPath = join(homedir(), ".claude", "settings.json"),
|
|
540
|
+
registryPath = join(PLUGIN_ROOT, "hooks", "hook-registry.json"),
|
|
541
|
+
pluginRoot = resolveHookPluginRoot(),
|
|
542
|
+
nodePath = resolveManagedNodePath(),
|
|
543
|
+
} = {}) {
|
|
544
|
+
const managedHooks = getManagedRegistryHooks(registryPath);
|
|
545
|
+
if (managedHooks.length === 0) {
|
|
546
|
+
return {
|
|
547
|
+
ok: false,
|
|
548
|
+
changed: false,
|
|
549
|
+
total: 0,
|
|
550
|
+
added: [],
|
|
551
|
+
reason: "registry_unavailable",
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
let settings = {};
|
|
556
|
+
if (existsSync(settingsPath)) {
|
|
557
|
+
try {
|
|
558
|
+
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
559
|
+
} catch (error) {
|
|
560
|
+
return {
|
|
561
|
+
ok: false,
|
|
562
|
+
changed: false,
|
|
563
|
+
total: managedHooks.length,
|
|
564
|
+
added: [],
|
|
565
|
+
reason: `settings_parse_failed:${error.message}`,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (!settings.hooks || typeof settings.hooks !== "object") settings.hooks = {};
|
|
570
|
+
|
|
571
|
+
const added = [];
|
|
572
|
+
for (const hookSpec of managedHooks) {
|
|
573
|
+
if (!Array.isArray(settings.hooks[hookSpec.event])) settings.hooks[hookSpec.event] = [];
|
|
574
|
+
const eventEntries = settings.hooks[hookSpec.event];
|
|
575
|
+
const expectedCommand = buildManagedHookCommand(hookSpec.fileName, { pluginRoot, nodePath });
|
|
576
|
+
const expectedNormalizedCommand = normalizeCommand(expectedCommand);
|
|
577
|
+
|
|
578
|
+
// 중복 체크: (1) 정확한 command 일치 또는 (2) 같은 파일명의 훅이 이미 등록됨
|
|
579
|
+
const hasSameMatcherAndCommand = eventEntries.some((entry) =>
|
|
580
|
+
entry?.matcher === hookSpec.matcher &&
|
|
581
|
+
Array.isArray(entry.hooks) &&
|
|
582
|
+
entry.hooks.some((hook) => {
|
|
583
|
+
if (normalizeCommand(hook?.command) === expectedNormalizedCommand) return true;
|
|
584
|
+
// pluginRoot가 달라도 같은 훅 파일이면 중복으로 판단
|
|
585
|
+
const existingFileName = extractManagedHookFilename(hook?.command);
|
|
586
|
+
return existingFileName === hookSpec.fileName;
|
|
587
|
+
}),
|
|
588
|
+
);
|
|
589
|
+
if (hasSameMatcherAndCommand) continue;
|
|
590
|
+
|
|
591
|
+
const hookEntry = {
|
|
592
|
+
type: "command",
|
|
593
|
+
command: expectedCommand,
|
|
594
|
+
};
|
|
595
|
+
if (Number.isFinite(hookSpec.timeout)) hookEntry.timeout = hookSpec.timeout;
|
|
596
|
+
if (typeof hookSpec.blocking === "boolean") hookEntry.blocking = hookSpec.blocking;
|
|
597
|
+
if (Number.isFinite(hookSpec.priority)) hookEntry.priority = hookSpec.priority;
|
|
598
|
+
|
|
599
|
+
const matcherEntry = eventEntries.find(
|
|
600
|
+
(entry) => entry?.matcher === hookSpec.matcher && Array.isArray(entry.hooks),
|
|
601
|
+
);
|
|
602
|
+
if (matcherEntry) {
|
|
603
|
+
matcherEntry.hooks.push(hookEntry);
|
|
604
|
+
} else {
|
|
605
|
+
eventEntries.push({ matcher: hookSpec.matcher, hooks: [hookEntry] });
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
added.push({
|
|
609
|
+
id: hookSpec.id,
|
|
610
|
+
event: hookSpec.event,
|
|
611
|
+
matcher: hookSpec.matcher,
|
|
612
|
+
fileName: hookSpec.fileName,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (added.length === 0) {
|
|
617
|
+
return {
|
|
618
|
+
ok: true,
|
|
619
|
+
changed: false,
|
|
620
|
+
total: managedHooks.length,
|
|
621
|
+
added: [],
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
let backupPath = null;
|
|
626
|
+
try {
|
|
627
|
+
if (existsSync(settingsPath)) {
|
|
628
|
+
backupPath = `${settingsPath}.bak.${Date.now()}`;
|
|
629
|
+
copyFileSync(settingsPath, backupPath);
|
|
630
|
+
} else {
|
|
631
|
+
const settingsDir = dirname(settingsPath);
|
|
632
|
+
if (!existsSync(settingsDir)) mkdirSync(settingsDir, { recursive: true });
|
|
633
|
+
}
|
|
634
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
635
|
+
} catch (error) {
|
|
636
|
+
return {
|
|
637
|
+
ok: false,
|
|
638
|
+
changed: false,
|
|
639
|
+
total: managedHooks.length,
|
|
640
|
+
added,
|
|
641
|
+
backupPath,
|
|
642
|
+
reason: `settings_write_failed:${error.message}`,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return {
|
|
647
|
+
ok: true,
|
|
648
|
+
changed: true,
|
|
649
|
+
total: managedHooks.length,
|
|
650
|
+
added,
|
|
651
|
+
backupPath,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
415
655
|
export {
|
|
416
656
|
replaceProfileSection, hasProfileSection, escapeRegExp, detectDevMode,
|
|
417
657
|
SYNC_MAP, BREADCRUMB_PATH, PLUGIN_ROOT, CLAUDE_DIR,
|
|
418
658
|
SKILL_ALIASES, REQUIRED_CODEX_PROFILES,
|
|
419
659
|
DEPRECATED_SKILLS, LEGACY_CODEX_MODELS,
|
|
420
660
|
buildAliasedSkillContent, syncAliasedSkillDir, getVersion, ensureCodexProfiles,
|
|
421
|
-
cleanupStaleSkills,
|
|
661
|
+
cleanupStaleSkills, extractManagedHookFilename, getManagedRegistryHooks, ensureHooksInSettings,
|
|
422
662
|
};
|
|
423
663
|
|
|
424
664
|
function runMcpGuardAudit() {
|
|
@@ -877,6 +1117,12 @@ if (settingsChanged) {
|
|
|
877
1117
|
}
|
|
878
1118
|
}
|
|
879
1119
|
|
|
1120
|
+
// ── hook-registry 기반 누락 훅 자동 등록 ──
|
|
1121
|
+
{
|
|
1122
|
+
const hookEnsureResult = ensureHooksInSettings();
|
|
1123
|
+
if (hookEnsureResult.changed) synced++;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
880
1126
|
// ── Stale PID 파일 정리 (hub 좀비 방지) ──
|
|
881
1127
|
|
|
882
1128
|
const HUB_PID_FILE = join(CLAUDE_DIR, "cache", "tfx-hub", "hub.pid");
|