triflux 9.6.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/.claude-plugin/plugin.json +1 -1
- package/bin/triflux.mjs +535 -26
- package/hooks/hook-manager.mjs +6 -5
- package/hooks/hook-orchestrator.mjs +3 -2
- package/hooks/hook-registry.json +27 -5
- package/hooks/lib/resolve-root.mjs +59 -0
- package/hooks/mcp-config-watcher.mjs +85 -0
- package/hooks/pipeline-stop.mjs +9 -14
- package/package.json +1 -1
- package/scripts/__tests__/mcp-guard-engine.test.mjs +118 -0
- package/scripts/lib/mcp-guard-engine.mjs +940 -0
- package/scripts/mcp-safety-guard.mjs +44 -0
- package/scripts/run.cjs +79 -62
- package/scripts/setup.mjs +306 -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";
|
|
@@ -12,10 +12,19 @@ import { forceCleanupTeam } from "../hub/team/nativeProxy.mjs";
|
|
|
12
12
|
import { cleanupStaleOmcTeams, inspectStaleOmcTeams } from "../hub/team/staleState.mjs";
|
|
13
13
|
import { getPipelineStateDbPath } from "../hub/pipeline/state.mjs";
|
|
14
14
|
import { ensureGeminiProfiles } from "../scripts/lib/gemini-profiles.mjs";
|
|
15
|
+
import {
|
|
16
|
+
addRegistryServer,
|
|
17
|
+
inspectRegistry,
|
|
18
|
+
inspectRegistryStatus,
|
|
19
|
+
removeRegistryServer,
|
|
20
|
+
removeServerFromTargets,
|
|
21
|
+
syncRegistryTargets,
|
|
22
|
+
} from "../scripts/lib/mcp-guard-engine.mjs";
|
|
15
23
|
import {
|
|
16
24
|
SYNC_MAP, SKILL_ALIASES, REQUIRED_CODEX_PROFILES, LEGACY_CODEX_MODELS,
|
|
17
25
|
syncAliasedSkillDir, hasProfileSection, replaceProfileSection,
|
|
18
26
|
ensureCodexProfiles, getVersion, cleanupStaleSkills, DEPRECATED_SKILLS,
|
|
27
|
+
extractManagedHookFilename, getManagedRegistryHooks, ensureHooksInSettings,
|
|
19
28
|
} from "../scripts/setup.mjs";
|
|
20
29
|
|
|
21
30
|
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
@@ -113,6 +122,31 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
|
|
|
113
122
|
toggle: "특정 훅 활성/비활성 토글: hooks toggle <hookId>",
|
|
114
123
|
},
|
|
115
124
|
},
|
|
125
|
+
mcp: {
|
|
126
|
+
usage: "tfx mcp <list|sync|add|remove> [--json]",
|
|
127
|
+
description: "MCP registry 상태 확인 및 중앙 동기화",
|
|
128
|
+
subcommands: {
|
|
129
|
+
list: {
|
|
130
|
+
usage: "tfx mcp list [--json]",
|
|
131
|
+
options: [{ name: "--json", type: "boolean", description: "registry + 실제 설정 상태를 JSON으로 출력" }],
|
|
132
|
+
},
|
|
133
|
+
sync: {
|
|
134
|
+
usage: "tfx mcp sync [--json]",
|
|
135
|
+
options: [{ name: "--json", type: "boolean", description: "동기화 결과를 JSON으로 출력" }],
|
|
136
|
+
},
|
|
137
|
+
add: {
|
|
138
|
+
usage: "tfx mcp add <name> --url <url> [--json]",
|
|
139
|
+
options: [
|
|
140
|
+
{ name: "--url", type: "string", description: "등록할 MCP URL" },
|
|
141
|
+
{ name: "--json", type: "boolean", description: "등록 결과를 JSON으로 출력" },
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
remove: {
|
|
145
|
+
usage: "tfx mcp remove <name> [--json]",
|
|
146
|
+
options: [{ name: "--json", type: "boolean", description: "제거 결과를 JSON으로 출력" }],
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
116
150
|
hub: {
|
|
117
151
|
usage: "tfx hub <start|stop|status> [--port N] [--json]",
|
|
118
152
|
description: "tfx-hub 프로세스 제어",
|
|
@@ -908,6 +942,135 @@ function addDoctorCheck(report, entry) {
|
|
|
908
942
|
report.checks.push(entry);
|
|
909
943
|
}
|
|
910
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
|
+
|
|
976
|
+
function formatPathForDisplay(filePath) {
|
|
977
|
+
const value = String(filePath || "").replace(/\\/g, "/");
|
|
978
|
+
const homePath = homedir().replace(/\\/g, "/");
|
|
979
|
+
return value.startsWith(homePath) ? `~${value.slice(homePath.length)}` : value;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function renderTable(headers, rows) {
|
|
983
|
+
if (!rows.length) return;
|
|
984
|
+
const widths = headers.map((header, index) => {
|
|
985
|
+
const cellWidths = rows.map((row) => stripAnsi(String(row[index] ?? "")).length);
|
|
986
|
+
return Math.max(stripAnsi(header).length, ...cellWidths);
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
const padCell = (cell, width) => {
|
|
990
|
+
const text = String(cell ?? "");
|
|
991
|
+
return text + " ".repeat(Math.max(0, width - stripAnsi(text).length));
|
|
992
|
+
};
|
|
993
|
+
const formatRow = (row) => row.map((cell, index) => padCell(cell, widths[index])).join(" ");
|
|
994
|
+
console.log(` ${formatRow(headers)}`);
|
|
995
|
+
console.log(` ${widths.map((width) => "─".repeat(width)).join(" ")}`);
|
|
996
|
+
for (const row of rows) {
|
|
997
|
+
console.log(` ${formatRow(row)}`);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function getOptionValue(args, optionName) {
|
|
1002
|
+
const index = args.indexOf(optionName);
|
|
1003
|
+
if (index === -1) return null;
|
|
1004
|
+
return args[index + 1] ?? null;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function statusBadge(status) {
|
|
1008
|
+
switch (status) {
|
|
1009
|
+
case "present":
|
|
1010
|
+
case "ok":
|
|
1011
|
+
case "removed":
|
|
1012
|
+
return `${GREEN_BRIGHT}${status}${RESET}`;
|
|
1013
|
+
case "updated":
|
|
1014
|
+
return `${AMBER}${status}${RESET}`;
|
|
1015
|
+
case "missing":
|
|
1016
|
+
case "missing-file":
|
|
1017
|
+
case "warning":
|
|
1018
|
+
return `${YELLOW}${status}${RESET}`;
|
|
1019
|
+
case "mismatch":
|
|
1020
|
+
case "invalid":
|
|
1021
|
+
case "invalid-config":
|
|
1022
|
+
return `${RED_BRIGHT}${status}${RESET}`;
|
|
1023
|
+
default:
|
|
1024
|
+
return status;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function buildMcpStatusRows(statusInfo) {
|
|
1029
|
+
const registryRows = statusInfo.rows
|
|
1030
|
+
.filter((row) => row.type === "registry")
|
|
1031
|
+
.map((row) => {
|
|
1032
|
+
let detail = "";
|
|
1033
|
+
if (row.status === "present") detail = row.actualUrl || row.expectedUrl;
|
|
1034
|
+
else if (row.status === "missing") detail = "registry only";
|
|
1035
|
+
else if (row.status === "missing-file") detail = "config missing";
|
|
1036
|
+
else if (row.status === "mismatch") detail = `expected ${row.expectedUrl}`;
|
|
1037
|
+
else if (row.status === "invalid-config") detail = "parse error";
|
|
1038
|
+
else if (row.status === "stdio") detail = "configured as stdio";
|
|
1039
|
+
return [row.name, row.label, statusBadge(row.status), formatPathForDisplay(row.filePath), detail];
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
const stdioRows = statusInfo.rows
|
|
1043
|
+
.filter((row) => row.type === "stdio")
|
|
1044
|
+
.map((row) => [
|
|
1045
|
+
row.name,
|
|
1046
|
+
row.label,
|
|
1047
|
+
statusBadge("warning"),
|
|
1048
|
+
formatPathForDisplay(row.filePath),
|
|
1049
|
+
row.command ? `stdio: ${row.command}` : "stdio MCP",
|
|
1050
|
+
]);
|
|
1051
|
+
|
|
1052
|
+
return [...registryRows, ...stdioRows];
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function ensureValidRegistryState() {
|
|
1056
|
+
const registryState = inspectRegistry();
|
|
1057
|
+
if (!registryState.exists) {
|
|
1058
|
+
throw createCliError(`MCP registry missing: ${registryState.path}`, {
|
|
1059
|
+
exitCode: EXIT_CONFIG_ERROR,
|
|
1060
|
+
reason: "configError",
|
|
1061
|
+
fix: "config/mcp-registry.json을 복원하거나 `tfx mcp add <name> --url <url>`로 다시 생성하세요.",
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
if (!registryState.valid) {
|
|
1065
|
+
throw createCliError(`MCP registry invalid: ${registryState.errors.join("; ")}`, {
|
|
1066
|
+
exitCode: EXIT_CONFIG_ERROR,
|
|
1067
|
+
reason: "configError",
|
|
1068
|
+
fix: `${registryState.path}의 JSON 구조를 수정하세요.`,
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
return registryState;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
911
1074
|
async function cmdDoctor(options = {}) {
|
|
912
1075
|
const { fix = false, reset = false, json = false } = options;
|
|
913
1076
|
const report = {
|
|
@@ -915,6 +1078,7 @@ async function cmdDoctor(options = {}) {
|
|
|
915
1078
|
mode: reset ? "reset" : fix ? "fix" : "check",
|
|
916
1079
|
checks: [],
|
|
917
1080
|
actions: [],
|
|
1081
|
+
hook_coverage: { total: 0, registered: 0, missing: [] },
|
|
918
1082
|
issue_count: 0,
|
|
919
1083
|
};
|
|
920
1084
|
|
|
@@ -1081,6 +1245,25 @@ async function cmdDoctor(options = {}) {
|
|
|
1081
1245
|
} catch {
|
|
1082
1246
|
warn("웜업 캐시 자동 복구 실패");
|
|
1083
1247
|
}
|
|
1248
|
+
const registryStateForFix = inspectRegistry();
|
|
1249
|
+
if (registryStateForFix.valid) {
|
|
1250
|
+
try {
|
|
1251
|
+
const mcpSync = syncRegistryTargets({ registry: registryStateForFix.registry });
|
|
1252
|
+
const updatedCount = mcpSync.actions.filter((action) => action.status === "updated").length;
|
|
1253
|
+
const invalidCount = mcpSync.actions.filter((action) => action.status === "invalid-config").length;
|
|
1254
|
+
report.actions.push({ type: "mcp-sync", status: invalidCount > 0 ? "issues" : "ok", actions: mcpSync.actions });
|
|
1255
|
+
if (updatedCount > 0) ok(`MCP registry 동기화: ${updatedCount}개 설정 반영됨`);
|
|
1256
|
+
else info("MCP registry: 이미 최신 상태");
|
|
1257
|
+
if (invalidCount > 0) warn(`MCP registry 동기화 건너뜀: parse error ${invalidCount}개`);
|
|
1258
|
+
} catch (error) {
|
|
1259
|
+
report.actions.push({ type: "mcp-sync", status: "failed", message: error.message });
|
|
1260
|
+
warn(`MCP registry 자동 동기화 실패: ${error.message}`);
|
|
1261
|
+
}
|
|
1262
|
+
} else if (registryStateForFix.exists) {
|
|
1263
|
+
warn("MCP registry invalid — auto sync 건너뜀");
|
|
1264
|
+
} else {
|
|
1265
|
+
info("MCP registry 없음 — auto sync 건너뜀");
|
|
1266
|
+
}
|
|
1084
1267
|
console.log(`\n ${LINE}`);
|
|
1085
1268
|
info("수정 완료 — 아래 진단 결과를 확인하세요");
|
|
1086
1269
|
console.log("");
|
|
@@ -1810,34 +1993,100 @@ async function cmdDoctor(options = {}) {
|
|
|
1810
1993
|
}
|
|
1811
1994
|
}
|
|
1812
1995
|
|
|
1813
|
-
// ──
|
|
1814
|
-
section("
|
|
1996
|
+
// ── MCP 중앙 레지스트리 ──
|
|
1997
|
+
section("MCP Registry");
|
|
1815
1998
|
{
|
|
1816
|
-
const
|
|
1817
|
-
if (
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1999
|
+
const registryState = inspectRegistry();
|
|
2000
|
+
if (!registryState.exists) {
|
|
2001
|
+
addDoctorCheck(report, {
|
|
2002
|
+
name: "mcp-registry",
|
|
2003
|
+
status: "missing",
|
|
2004
|
+
path: registryState.path,
|
|
2005
|
+
fix: "config/mcp-registry.json을 복원하거나 `tfx mcp add <name> --url <url>`를 실행하세요.",
|
|
2006
|
+
});
|
|
2007
|
+
warn("mcp-registry.json 없음");
|
|
2008
|
+
info(`path: ${registryState.path}`);
|
|
2009
|
+
issues++;
|
|
2010
|
+
} else if (!registryState.valid) {
|
|
2011
|
+
addDoctorCheck(report, {
|
|
2012
|
+
name: "mcp-registry",
|
|
2013
|
+
status: "invalid",
|
|
2014
|
+
path: registryState.path,
|
|
2015
|
+
errors: registryState.errors,
|
|
2016
|
+
fix: "config/mcp-registry.json 구조를 수정하세요.",
|
|
2017
|
+
});
|
|
2018
|
+
fail("mcp-registry.json invalid");
|
|
2019
|
+
for (const entry of registryState.errors) info(entry);
|
|
2020
|
+
issues++;
|
|
2021
|
+
} else {
|
|
2022
|
+
const statusInfo = inspectRegistryStatus(registryState.registry);
|
|
2023
|
+
const invalidConfigs = statusInfo.configs.filter((config) => config.parseError);
|
|
2024
|
+
const mismatchRows = statusInfo.rows.filter((row) => row.type === "registry" && row.status === "mismatch");
|
|
2025
|
+
const missingRows = statusInfo.rows.filter((row) => row.type === "registry" && row.status === "missing");
|
|
2026
|
+
const missingFileRows = statusInfo.rows.filter((row) => row.type === "registry" && row.status === "missing-file");
|
|
2027
|
+
const stdioRows = statusInfo.rows.filter((row) => row.type === "stdio");
|
|
2028
|
+
const hasHardIssues = invalidConfigs.length > 0 || mismatchRows.length > 0;
|
|
2029
|
+
const status = hasHardIssues
|
|
2030
|
+
? "issues"
|
|
2031
|
+
: stdioRows.length > 0
|
|
2032
|
+
? "warning"
|
|
2033
|
+
: "ok";
|
|
2034
|
+
|
|
2035
|
+
addDoctorCheck(report, {
|
|
2036
|
+
name: "mcp-registry",
|
|
2037
|
+
status,
|
|
2038
|
+
path: registryState.path,
|
|
2039
|
+
server_count: Object.keys(registryState.registry.servers || {}).length,
|
|
2040
|
+
rows: statusInfo.rows,
|
|
2041
|
+
invalid_configs: invalidConfigs.map((config) => ({
|
|
2042
|
+
file: config.filePath,
|
|
2043
|
+
error: config.parseError?.message || "parse error",
|
|
2044
|
+
})),
|
|
2045
|
+
...(stdioRows.length > 0 ? { fix: "tfx doctor --fix 또는 tfx mcp sync" } : {}),
|
|
2046
|
+
});
|
|
2047
|
+
|
|
2048
|
+
ok(`registry 정상 (${Object.keys(registryState.registry.servers || {}).length}개 server)`);
|
|
2049
|
+
|
|
2050
|
+
if (statusInfo.rows.length > 0) {
|
|
2051
|
+
renderTable(
|
|
2052
|
+
["server", "target", "status", "config", "detail"],
|
|
2053
|
+
buildMcpStatusRows(statusInfo),
|
|
2054
|
+
);
|
|
2055
|
+
} else {
|
|
2056
|
+
info("등록된 MCP server 없음");
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
for (const config of invalidConfigs) {
|
|
2060
|
+
fail(`${config.label}: 설정 파싱 실패`);
|
|
2061
|
+
info(`${formatPathForDisplay(config.filePath)} — ${config.parseError.message}`);
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
for (const row of mismatchRows) {
|
|
2065
|
+
warn(`${row.label}: ${row.name} URL 불일치`);
|
|
2066
|
+
info(`expected ${row.expectedUrl}`);
|
|
2067
|
+
if (row.actualUrl) info(`actual ${row.actualUrl}`);
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
for (const row of missingFileRows) {
|
|
2071
|
+
info(`${row.label}: ${row.name} 미배치 (${formatPathForDisplay(row.filePath)})`);
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
for (const row of missingRows) {
|
|
2075
|
+
info(`${row.label}: ${row.name} 누락`);
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
if (stdioRows.length === 0) {
|
|
2079
|
+
ok("미등록 stdio MCP 없음");
|
|
2080
|
+
} else {
|
|
2081
|
+
warn(`${stdioRows.length}개 미등록 stdio MCP 감지`);
|
|
2082
|
+
for (const row of stdioRows) {
|
|
2083
|
+
info(`${row.label}: ${row.name}${row.command ? ` (${row.command})` : ""}`);
|
|
1833
2084
|
}
|
|
1834
|
-
} catch {
|
|
1835
|
-
addDoctorCheck(report, { name: "gemini-mcp-safety", status: "ok" });
|
|
1836
|
-
ok("설정 파일 파싱 불가 — 건너뜀");
|
|
1837
2085
|
}
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
2086
|
+
|
|
2087
|
+
issues += invalidConfigs.length;
|
|
2088
|
+
issues += mismatchRows.length;
|
|
2089
|
+
issues += stdioRows.length;
|
|
1841
2090
|
}
|
|
1842
2091
|
}
|
|
1843
2092
|
|
|
@@ -1875,6 +2124,93 @@ async function cmdDoctor(options = {}) {
|
|
|
1875
2124
|
}
|
|
1876
2125
|
}
|
|
1877
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
|
+
|
|
1878
2214
|
// 결과
|
|
1879
2215
|
console.log(`\n ${LINE}`);
|
|
1880
2216
|
if (issues === 0) {
|
|
@@ -2198,6 +2534,175 @@ function cmdSchema(args = []) {
|
|
|
2198
2534
|
});
|
|
2199
2535
|
}
|
|
2200
2536
|
|
|
2537
|
+
function cmdMcp(args = [], options = {}) {
|
|
2538
|
+
const { json = false } = options;
|
|
2539
|
+
const sub = String(args[0] || "list").trim().toLowerCase();
|
|
2540
|
+
|
|
2541
|
+
if (sub === "help" || sub === "--help" || sub === "-h") {
|
|
2542
|
+
console.log(`
|
|
2543
|
+
${AMBER}${BOLD}⬡ tfx mcp${RESET}
|
|
2544
|
+
|
|
2545
|
+
${WHITE_BRIGHT}tfx mcp list${RESET} ${GRAY}registry + 실제 설정 상태 테이블${RESET}
|
|
2546
|
+
${WHITE_BRIGHT}tfx mcp sync${RESET} ${GRAY}registry 기준 전체 스캔 + 치환${RESET}
|
|
2547
|
+
${WHITE_BRIGHT}tfx mcp add <name> --url <url>${RESET} ${GRAY}registry 등록 + 대상 설정 반영${RESET}
|
|
2548
|
+
${WHITE_BRIGHT}tfx mcp remove <name>${RESET} ${GRAY}registry + 실제 설정에서 제거${RESET}
|
|
2549
|
+
`);
|
|
2550
|
+
return;
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
switch (sub) {
|
|
2554
|
+
case "list": {
|
|
2555
|
+
const registryState = ensureValidRegistryState();
|
|
2556
|
+
const statusInfo = inspectRegistryStatus(registryState.registry);
|
|
2557
|
+
if (json) {
|
|
2558
|
+
printJson({
|
|
2559
|
+
registry_path: registryState.path,
|
|
2560
|
+
server_count: Object.keys(registryState.registry.servers || {}).length,
|
|
2561
|
+
rows: statusInfo.rows,
|
|
2562
|
+
configs: statusInfo.configs.map((config) => ({
|
|
2563
|
+
file: config.filePath,
|
|
2564
|
+
label: config.label,
|
|
2565
|
+
exists: config.exists,
|
|
2566
|
+
parse_error: config.parseError?.message || null,
|
|
2567
|
+
})),
|
|
2568
|
+
});
|
|
2569
|
+
return;
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
console.log(`\n ${AMBER}${BOLD}⬡ triflux mcp${RESET} ${VER}\n`);
|
|
2573
|
+
console.log(` ${LINE}`);
|
|
2574
|
+
section("Registry");
|
|
2575
|
+
info(formatPathForDisplay(registryState.path));
|
|
2576
|
+
ok(`${Object.keys(registryState.registry.servers || {}).length}개 server 등록됨`);
|
|
2577
|
+
if (statusInfo.rows.length === 0) {
|
|
2578
|
+
info("표시할 MCP 상태 없음");
|
|
2579
|
+
} else {
|
|
2580
|
+
renderTable(
|
|
2581
|
+
["server", "target", "status", "config", "detail"],
|
|
2582
|
+
buildMcpStatusRows(statusInfo),
|
|
2583
|
+
);
|
|
2584
|
+
}
|
|
2585
|
+
console.log("");
|
|
2586
|
+
return;
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
case "sync": {
|
|
2590
|
+
const registryState = ensureValidRegistryState();
|
|
2591
|
+
const result = syncRegistryTargets({ registry: registryState.registry });
|
|
2592
|
+
if (json) {
|
|
2593
|
+
printJson({
|
|
2594
|
+
registry_path: registryState.path,
|
|
2595
|
+
actions: result.actions,
|
|
2596
|
+
});
|
|
2597
|
+
return;
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
console.log(`\n ${AMBER}${BOLD}⬡ triflux mcp sync${RESET} ${VER}\n`);
|
|
2601
|
+
console.log(` ${LINE}`);
|
|
2602
|
+
section("Actions");
|
|
2603
|
+
for (const action of result.actions) {
|
|
2604
|
+
const label = `${action.label} ${DIM}(${formatPathForDisplay(action.filePath)})${RESET}`;
|
|
2605
|
+
if (action.status === "updated") ok(`${label} → updated`);
|
|
2606
|
+
else if (action.status === "warning") warn(`${label} → warning`);
|
|
2607
|
+
else if (action.status === "invalid-config") fail(`${label} → invalid-config`);
|
|
2608
|
+
else info(`${stripAnsi(label)} → ${action.status}`);
|
|
2609
|
+
}
|
|
2610
|
+
console.log("");
|
|
2611
|
+
return;
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
case "add": {
|
|
2615
|
+
const name = String(args[1] || "").trim();
|
|
2616
|
+
const url = getOptionValue(args, "--url");
|
|
2617
|
+
if (!name) {
|
|
2618
|
+
throw createCliError("MCP server name is required", {
|
|
2619
|
+
exitCode: EXIT_ARG_ERROR,
|
|
2620
|
+
reason: "argError",
|
|
2621
|
+
fix: "tfx mcp add <name> --url <url>",
|
|
2622
|
+
});
|
|
2623
|
+
}
|
|
2624
|
+
if (!url) {
|
|
2625
|
+
throw createCliError("MCP server url is required", {
|
|
2626
|
+
exitCode: EXIT_ARG_ERROR,
|
|
2627
|
+
reason: "argError",
|
|
2628
|
+
fix: "tfx mcp add <name> --url <url>",
|
|
2629
|
+
});
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
const normalizedUrl = (() => {
|
|
2633
|
+
try { return new URL(url).toString(); } catch {
|
|
2634
|
+
throw createCliError(`Invalid MCP URL: ${url}`, {
|
|
2635
|
+
exitCode: EXIT_ARG_ERROR,
|
|
2636
|
+
reason: "argError",
|
|
2637
|
+
fix: "http:// 또는 https:// URL을 사용하세요.",
|
|
2638
|
+
});
|
|
2639
|
+
}
|
|
2640
|
+
})();
|
|
2641
|
+
|
|
2642
|
+
const server = addRegistryServer(name, normalizedUrl);
|
|
2643
|
+
const registryState = ensureValidRegistryState();
|
|
2644
|
+
const syncResult = syncRegistryTargets({ registry: registryState.registry });
|
|
2645
|
+
if (json) {
|
|
2646
|
+
printJson({
|
|
2647
|
+
name,
|
|
2648
|
+
server,
|
|
2649
|
+
actions: syncResult.actions,
|
|
2650
|
+
});
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
console.log(`\n ${AMBER}${BOLD}⬡ triflux mcp add${RESET} ${VER}\n`);
|
|
2655
|
+
console.log(` ${LINE}`);
|
|
2656
|
+
ok(`${name} 등록됨`);
|
|
2657
|
+
info(normalizedUrl);
|
|
2658
|
+
const updated = syncResult.actions.filter((action) => action.status === "updated").length;
|
|
2659
|
+
info(`동기화 반영: ${updated}개`);
|
|
2660
|
+
console.log("");
|
|
2661
|
+
return;
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
case "remove": {
|
|
2665
|
+
const name = String(args[1] || "").trim();
|
|
2666
|
+
if (!name) {
|
|
2667
|
+
throw createCliError("MCP server name is required", {
|
|
2668
|
+
exitCode: EXIT_ARG_ERROR,
|
|
2669
|
+
reason: "argError",
|
|
2670
|
+
fix: "tfx mcp remove <name>",
|
|
2671
|
+
});
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
ensureValidRegistryState();
|
|
2675
|
+
const removed = removeRegistryServer(name);
|
|
2676
|
+
const cleanup = removeServerFromTargets(name, { targets: removed?.targets });
|
|
2677
|
+
if (json) {
|
|
2678
|
+
printJson({
|
|
2679
|
+
name,
|
|
2680
|
+
removed: Boolean(removed),
|
|
2681
|
+
server: removed,
|
|
2682
|
+
actions: cleanup.actions,
|
|
2683
|
+
});
|
|
2684
|
+
return;
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
console.log(`\n ${AMBER}${BOLD}⬡ triflux mcp remove${RESET} ${VER}\n`);
|
|
2688
|
+
console.log(` ${LINE}`);
|
|
2689
|
+
if (removed) ok(`${name} registry에서 제거됨`);
|
|
2690
|
+
else warn(`${name} registry entry 없음`);
|
|
2691
|
+
const changed = cleanup.actions.filter((action) => action.status === "removed").length;
|
|
2692
|
+
info(`설정 제거 반영: ${changed}개`);
|
|
2693
|
+
console.log("");
|
|
2694
|
+
return;
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
default:
|
|
2698
|
+
throw createCliError(`알 수 없는 mcp 서브커맨드: ${sub}`, {
|
|
2699
|
+
exitCode: EXIT_ARG_ERROR,
|
|
2700
|
+
reason: "argError",
|
|
2701
|
+
fix: "tfx mcp help",
|
|
2702
|
+
});
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2201
2706
|
function checkForUpdate() {
|
|
2202
2707
|
const cacheFile = join(CLAUDE_DIR, "cache", "triflux-update-check.json");
|
|
2203
2708
|
const cacheDir = dirname(cacheFile);
|
|
@@ -2250,6 +2755,7 @@ ${updateNotice}
|
|
|
2250
2755
|
${DIM} --fix${RESET} ${GRAY}진단 + 자동 수정${RESET}
|
|
2251
2756
|
${DIM} --reset${RESET} ${GRAY}캐시 전체 초기화${RESET}
|
|
2252
2757
|
${DIM} --json${RESET} ${GRAY}구조화된 진단 결과 JSON 출력${RESET}
|
|
2758
|
+
${WHITE_BRIGHT}tfx mcp${RESET} ${GRAY}MCP registry 관리 (list/sync/add/remove)${RESET}
|
|
2253
2759
|
${WHITE_BRIGHT}tfx update${RESET} ${GRAY}최신 안정 버전으로 업데이트${RESET}
|
|
2254
2760
|
${DIM} --dev / dev${RESET} ${GRAY}dev 태그로 업데이트${RESET}
|
|
2255
2761
|
${WHITE_BRIGHT}tfx list${RESET} ${GRAY}설치된 스킬 목록${RESET}
|
|
@@ -2752,6 +3258,9 @@ async function main() {
|
|
|
2752
3258
|
await cmdDoctor({ fix, reset, json: JSON_OUTPUT });
|
|
2753
3259
|
return;
|
|
2754
3260
|
}
|
|
3261
|
+
case "mcp":
|
|
3262
|
+
cmdMcp(cmdArgs, { json: JSON_OUTPUT });
|
|
3263
|
+
return;
|
|
2755
3264
|
case "schema":
|
|
2756
3265
|
cmdSchema(cmdArgs);
|
|
2757
3266
|
return;
|
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 = {};
|