triflux 10.57.3 → 10.59.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { setTimeout as delay } from "node:timers/promises";
3
- import { execFileSync, execSync } from "child_process";
3
+ import { execFileSync, execSync, spawnSync } from "child_process";
4
4
  // triflux CLI — setup, doctor, version
5
5
  import {
6
6
  chmodSync,
@@ -15,6 +15,7 @@ import {
15
15
  statSync,
16
16
  unlinkSync,
17
17
  writeFileSync,
18
+ writeSync,
18
19
  } from "fs";
19
20
  import { homedir } from "os";
20
21
  import { dirname, join, resolve } from "path";
@@ -31,6 +32,15 @@ import {
31
32
  listSessions,
32
33
  tmuxExec,
33
34
  } from "../hub/team/session.mjs";
35
+ import {
36
+ addStep,
37
+ addWarning,
38
+ commandErrorMessage,
39
+ createCliResult,
40
+ outputOptions,
41
+ recordFile,
42
+ renderCliResult,
43
+ } from "../scripts/lib/cli-output.mjs";
34
44
  import {
35
45
  commandExists,
36
46
  inspectMacTimeoutDependency,
@@ -79,6 +89,7 @@ import {
79
89
  retireOldInstallFiles,
80
90
  runConsentSteps,
81
91
  SYNC_MAP,
92
+ setupTargets,
82
93
  syncSkills,
83
94
  } from "../scripts/setup.mjs";
84
95
  import { cleanupTmpFiles } from "../scripts/tmp-cleanup.mjs";
@@ -91,7 +102,6 @@ const PKG = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
91
102
 
92
103
  // ── 색상 체계 (triflux brand: amber/orange accent) ──
93
104
  const CYAN = "\x1b[36m";
94
- const GREEN = "\x1b[32m";
95
105
  const RED = "\x1b[31m";
96
106
  const YELLOW = "\x1b[33m";
97
107
  const DIM = "\x1b[2m";
@@ -123,9 +133,25 @@ const NORMALIZED_ARGS = RAW_ARGS.filter((arg) => arg !== "--json");
123
133
 
124
134
  const CLI_COMMAND_SCHEMAS = Object.freeze({
125
135
  setup: {
126
- usage: "tfx setup [--dry-run] [--mods]",
136
+ usage: "tfx setup [--dry-run] [--mods] [--json] [-q|--quiet] [--verbose]",
127
137
  description: "파일 동기화 + HUD/MCP 설정",
128
138
  options: [
139
+ {
140
+ name: "--json",
141
+ type: "boolean",
142
+ description: "비대화식 JSON 결과 출력",
143
+ },
144
+ {
145
+ name: "-q, --quiet",
146
+ type: "boolean",
147
+ description: "경고와 오류만 출력",
148
+ },
149
+ {
150
+ name: "--verbose",
151
+ type: "boolean",
152
+ description: "최신 파일도 모두 표시",
153
+ },
154
+
129
155
  {
130
156
  name: "--mods",
131
157
  type: "boolean",
@@ -308,10 +334,31 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
308
334
  },
309
335
  },
310
336
  update: {
311
- usage: "tfx update",
337
+ usage: "tfx update [--check] [--json] [-q|--quiet] [--verbose]",
312
338
  description:
313
339
  "설치 방식(plugin/npm/git)을 감지해 triflux를 업데이트하고 setup/cache를 재동기화",
314
340
  options: [
341
+ {
342
+ name: "--json",
343
+ type: "boolean",
344
+ description: "비대화식 JSON 결과 출력",
345
+ },
346
+ {
347
+ name: "-q, --quiet",
348
+ type: "boolean",
349
+ description: "경고와 오류만 출력",
350
+ },
351
+ {
352
+ name: "--verbose",
353
+ type: "boolean",
354
+ description: "최신 파일도 모두 표시",
355
+ },
356
+ {
357
+ name: "--check",
358
+ type: "boolean",
359
+ description:
360
+ "registry 버전만 확인. triflux 관리 파일(~/.claude, ~/.codex 등)은 변경하지 않음 (npm 캐시와 로그는 기록될 수 있음)",
361
+ },
315
362
  {
316
363
  name: "--help",
317
364
  type: "boolean",
@@ -382,14 +429,19 @@ function printCommandHelp(command) {
382
429
  const aliases = schema.aliases?.length
383
430
  ? `\n ${BOLD}Aliases${RESET}\n ${schema.aliases.join(", ")}\n`
384
431
  : "";
385
- console.log(`
432
+ const help = `
386
433
  ${AMBER}${BOLD}⬡ tfx ${command}${RESET}
387
434
 
388
435
  ${GRAY}${schema.description || ""}${RESET}
389
436
 
390
437
  ${BOLD}Usage${RESET}
391
438
  ${WHITE_BRIGHT}${schema.usage}${RESET}
392
- ${aliases}${subcommands ? `\n ${BOLD}Subcommands${RESET}\n${subcommands}\n` : ""}${options ? `\n ${BOLD}Options${RESET}\n${options}\n` : ""}`);
439
+ ${aliases}${subcommands ? `\n ${BOLD}Subcommands${RESET}\n${subcommands}\n` : ""}${options ? `\n ${BOLD}Options${RESET}\n${options}\n` : ""}`;
440
+ console.log(
441
+ ["setup", "update"].includes(command) && !outputOptions(RAW_ARGS).color
442
+ ? stripAnsi(help)
443
+ : help,
444
+ );
393
445
  return true;
394
446
  }
395
447
 
@@ -1174,7 +1226,7 @@ function buildSetupDryRunPlan() {
1174
1226
  };
1175
1227
  }
1176
1228
 
1177
- function refreshSetupCaches() {
1229
+ function refreshSetupCaches(report) {
1178
1230
  const cacheDir = join(CLAUDE_DIR, "cache");
1179
1231
  for (const name of ["tfx-preflight.json", "mcp-inventory.json"]) {
1180
1232
  const file = join(cacheDir, name);
@@ -1191,250 +1243,314 @@ function refreshSetupCaches() {
1191
1243
  windowsHide: true,
1192
1244
  stdio: ["ignore", "pipe", "pipe"],
1193
1245
  });
1194
- ok(`${name}: 캐시 갱신 완료`);
1195
1246
  } catch (error) {
1196
- warn(
1247
+ addWarning(
1248
+ report,
1249
+ "cache-refresh",
1197
1250
  `${name}: 캐시 갱신 실패: ${error.message?.split(/\r?\n/)[0] || "unknown"}`,
1198
1251
  );
1199
1252
  }
1200
1253
  }
1201
1254
  }
1202
1255
 
1203
- export function runUpdatedSetup({
1256
+ export async function runUpdatedSetup({
1204
1257
  packageRoot = PKG_ROOT,
1205
1258
  execFileSyncFn = execFileSync,
1259
+ options = outputOptions(RAW_ARGS),
1206
1260
  } = {}) {
1207
1261
  const cli = join(packageRoot, "bin", "triflux.mjs");
1208
1262
  for (const file of [cli, join(packageRoot, "scripts", "setup.mjs")]) {
1209
1263
  if (!existsSync(file)) throw new Error(`업데이트 핵심 파일 누락: ${file}`);
1210
1264
  }
1211
- execFileSyncFn(process.execPath, [cli, "setup", "--from-update"], {
1212
- stdio: "inherit",
1213
- windowsHide: true,
1214
- });
1265
+ let stdout;
1266
+ try {
1267
+ stdout = execFileSyncFn(
1268
+ process.execPath,
1269
+ [cli, "setup", "--from-update", "--json"],
1270
+ {
1271
+ encoding: "utf8",
1272
+ stdio: ["ignore", "pipe", "pipe"],
1273
+ windowsHide: true,
1274
+ },
1275
+ );
1276
+ } catch (error) {
1277
+ if (!error.stdout?.toString().trim()) {
1278
+ error.message = commandErrorMessage(error);
1279
+ throw error;
1280
+ }
1281
+ stdout = error.stdout.toString();
1282
+ }
1283
+ const result = JSON.parse(stdout.trim().split(/\r?\n/).at(-1));
1284
+ if (result.outcome === "ok") {
1285
+ const interactive = !options.nonInteractive;
1286
+ let consent;
1287
+ try {
1288
+ const child = spawnSync(
1289
+ process.execPath,
1290
+ [
1291
+ cli,
1292
+ "setup",
1293
+ "--from-update",
1294
+ "--consent-only",
1295
+ ...(interactive ? [] : ["--json"]),
1296
+ ],
1297
+ {
1298
+ encoding: "utf8",
1299
+ env: { ...process.env, TFX_CONSENT_FD: interactive ? "3" : "" },
1300
+ stdio: interactive
1301
+ ? ["inherit", "inherit", "inherit", "pipe"]
1302
+ : ["ignore", "pipe", "pipe"],
1303
+ windowsHide: true,
1304
+ },
1305
+ );
1306
+ if (child.error) throw child.error;
1307
+ const consentOutput = interactive ? child.output[3] : child.stdout;
1308
+ if (!consentOutput?.trim())
1309
+ throw new Error(
1310
+ commandErrorMessage({
1311
+ message: child.signal
1312
+ ? `시그널 ${child.signal}`
1313
+ : `종료 코드 ${child.status}`,
1314
+ stderr: child.stderr,
1315
+ }),
1316
+ );
1317
+ consent = JSON.parse(consentOutput.trim().split(/\r?\n/).at(-1));
1318
+ if (consent.outcome !== "ok") throw new Error(consent.message);
1319
+ } catch (error) {
1320
+ addWarning(
1321
+ result,
1322
+ "consent",
1323
+ `동의 단계 실패: ${commandErrorMessage(error)}. 다시 실행: tfx setup`,
1324
+ );
1325
+ return result;
1326
+ }
1327
+ result.steps.push(...consent.steps);
1328
+ result.notices.push(...consent.notices);
1329
+ for (const warning of consent.warnings)
1330
+ addWarning(result, warning.code, warning.message);
1331
+ result.alreadyInGoalState &&= consent.alreadyInGoalState;
1332
+ const hooks = consent.targets.find(({ name }) => name === "codex");
1333
+ const codex = result.targets.find(({ name }) => name === "codex");
1334
+ if (codex && hooks && hooks.status !== "skipped") {
1335
+ if (hooks.status === "failed") codex.status = "failed";
1336
+ else if (codex.status === "ok") codex.detail += ", 훅 등록";
1337
+ }
1338
+ }
1339
+ return result;
1215
1340
  }
1216
1341
 
1217
- function reportSkillSync() {
1218
- const result = syncSkills();
1219
- for (const warning of result.warnings) warn(warning);
1220
- if (!result.ok)
1221
- throw createCliError("스킬 동기화 실패", { exitCode: EXIT_CONFIG_ERROR });
1222
- ok(`스킬: ${result.total}개 확인, 파일 ${result.changed}개 반영`);
1342
+ async function cmdConsentOnly(options, report) {
1343
+ try {
1344
+ const consent = await runConsentSteps({
1345
+ interactive: !options.nonInteractive,
1346
+ report,
1347
+ });
1348
+ if (consent.changed) report.alreadyInGoalState = false;
1349
+ report.targets.push({
1350
+ name: "codex",
1351
+ status:
1352
+ consent.codexHooks === "skipped"
1353
+ ? "skipped"
1354
+ : consent.codexHooks === "failed"
1355
+ ? "failed"
1356
+ : "ok",
1357
+ detail: "훅 등록",
1358
+ });
1359
+ } catch (error) {
1360
+ report.outcome = "failed";
1361
+ report.alreadyInGoalState = false;
1362
+ report.message = error.message;
1363
+ report.exitCode = error.exitCode ?? EXIT_ERROR;
1364
+ process.exitCode = report.exitCode;
1365
+ }
1366
+ // 부모가 연 파이프가 있을 때만 질문 출력과 JSON 을 분리한다.
1367
+ writeSync(
1368
+ process.env.TFX_CONSENT_FD === "3" ? 3 : 1,
1369
+ `${JSON.stringify(report)}\n`,
1370
+ );
1371
+ return report;
1372
+ }
1373
+
1374
+ export function installNpmGlobal({
1375
+ platform = process.platform,
1376
+ exec = execSync,
1377
+ } = {}) {
1378
+ const command = "npm install -g triflux@latest";
1379
+ const options = {
1380
+ encoding: "utf8",
1381
+ timeout: 90000,
1382
+ stdio: ["pipe", "pipe", "pipe"],
1383
+ windowsHide: true,
1384
+ };
1385
+ try {
1386
+ exec(command, options);
1387
+ } catch (error) {
1388
+ if (platform !== "win32") throw error;
1389
+ exec(`${command} --force`, options);
1390
+ }
1223
1391
  }
1224
1392
 
1225
1393
  async function cmdSetup(options = {}) {
1226
- const {
1227
- dryRun = false,
1228
- fromUpdate = false,
1229
- overrideVersion,
1230
- mods = false,
1231
- } = options;
1394
+ const { dryRun = false, fromUpdate = false, mods = false } = options;
1232
1395
  if (dryRun) {
1233
1396
  printJson(buildSetupDryRunPlan());
1234
1397
  return;
1235
1398
  }
1399
+ const report = createCliResult("setup", PKG.version);
1400
+ const warning = (code) => (message) => addWarning(report, code, message);
1236
1401
  if (writesRealHomeInTest()) {
1237
- info("setup: skip (테스트가 홈을 격리하지 않음)");
1238
- return;
1239
- }
1240
-
1241
- const cleanup = cleanupLegacyHooks({
1242
- settingsPath: join(CLAUDE_DIR, "settings.json"),
1243
- });
1244
- if (!cleanup.ok) {
1245
- throw createCliError(`이전 hook 정리 실패: ${cleanup.error}`, {
1246
- exitCode: EXIT_CONFIG_ERROR,
1247
- reason: "configError",
1248
- fix: `${join(CLAUDE_DIR, "settings.json")}의 JSON 문법과 쓰기 권한을 확인하세요.`,
1249
- });
1250
- }
1251
- // agy 는 hooks.json 의 훅을 실행하므로 지워진 훅 스크립트를 가리키는 항목을 남기지 않는다.
1252
- const agyCleanup = cleanupAgyHooks();
1253
- if (agyCleanup.changed) ok("agy 옛 triflux-session 훅 정리됨");
1254
- else if (!agyCleanup.ok) warn(`agy 옛 훅 정리 실패: ${agyCleanup.error}`);
1255
- // 이주가 막혀도 setup 은 계속한다. 남은 항목은 경고로 알린다.
1256
- const mcpBackups = new Map();
1257
- for (const warning of cleanupLegacyMcp({ backups: mcpBackups }).warnings)
1258
- warn(warning);
1259
- const hubCleanup = cleanupTfxHub({
1260
- pluginRoot: existsSync(join(PKG_ROOT, ".git")) ? undefined : PKG_ROOT,
1261
- log: info,
1262
- backups: mcpBackups,
1263
- });
1264
- for (const warning of hubCleanup.warnings) warn(warning);
1265
- if (hubCleanup.changed) ok("제거된 허브의 설정과 실행 흔적 정리");
1266
- const mcpPins = pinRegistryMcpPackages({ backups: mcpBackups });
1267
- for (const warning of mcpPins.warnings) warn(warning);
1268
- if (mcpPins.pinned) ok(`MCP 패키지 고정 버전 반영: ${mcpPins.pinned}개 항목`);
1269
- if (fromUpdate) refreshSetupCaches();
1270
-
1271
- console.log(`\n${BOLD}triflux setup${RESET}\n`);
1272
-
1273
- for (const target of SYNC_MAP) {
1274
- syncFile(target.src, target.dst, target.label);
1275
- }
1276
- retireOldInstallFiles(info);
1277
- reportSkillSync();
1278
-
1279
- // ── 결과 추적 ──
1280
- const summary = [];
1281
-
1282
- const codexProfileResult = ensureCodexProfiles();
1283
- if (!codexProfileResult.ok) {
1284
- const reason = renderErrorMessage(codexProfileResult.message);
1285
- warn(`Codex profiles 설정 실패: ${reason}`);
1286
- summary.push({ item: "Codex profiles", status: "⚠️", detail: reason });
1287
- } else if (codexProfileResult.changed > 0) {
1288
- ok(
1289
- `Codex profiles: ${codexProfileResult.changed}개 반영됨 (~/.codex/<프로필>.config.toml)`,
1290
- );
1291
- summary.push({
1292
- item: "Codex profiles",
1293
- status: "✅",
1294
- detail: `${codexProfileResult.changed}개 반영됨`,
1295
- });
1296
- } else {
1297
- ok("Codex profiles: 이미 준비됨");
1298
- summary.push({
1299
- item: "Codex profiles",
1300
- status: "✅",
1301
- detail: "이미 준비됨",
1302
- });
1402
+ report.outcome = "skipped";
1403
+ report.message = "테스트가 홈을 격리하지 않아 건너뜀";
1404
+ process.stdout.write(renderCliResult(report, options));
1405
+ return report;
1303
1406
  }
1304
-
1305
- // Antigravity/Gemini 호환 프로필
1306
- const geminiResult = ensureGeminiProfiles();
1307
- if (!geminiResult.ok) {
1308
- const reason = renderErrorMessage(geminiResult.message);
1309
- warn(`Antigravity/Gemini profiles 설정 실패: ${reason}`);
1310
- summary.push({
1311
- item: "Antigravity/Gemini profiles",
1312
- status: "⚠️",
1313
- detail: reason,
1314
- });
1315
- } else if (geminiResult.created) {
1316
- ok(
1317
- `Antigravity/Gemini profiles: ${geminiResult.count}개 생성됨 (~/.gemini/triflux-profiles.json)`,
1318
- );
1319
- summary.push({
1320
- item: "Antigravity/Gemini profiles",
1321
- status: "✅",
1322
- detail: `${geminiResult.count}개 생성됨`,
1323
- });
1324
- } else if (geminiResult.added > 0) {
1325
- ok(`Antigravity/Gemini profiles: ${geminiResult.added}개 추가됨`);
1326
- summary.push({
1327
- item: "Antigravity/Gemini profiles",
1328
- status: "✅",
1329
- detail: `${geminiResult.added}개 추가됨 (총 ${geminiResult.count}개)`,
1330
- });
1331
- } else {
1332
- ok(`Antigravity/Gemini profiles: ${geminiResult.count}개 준비됨`);
1333
- summary.push({
1334
- item: "Antigravity/Gemini profiles",
1335
- status: "✅",
1336
- detail: `${geminiResult.count}개 준비됨`,
1407
+ if (options.fromUpdate && options.consentOnly)
1408
+ return cmdConsentOnly(options, report);
1409
+ try {
1410
+ const cleanup = cleanupLegacyHooks({
1411
+ settingsPath: join(CLAUDE_DIR, "settings.json"),
1337
1412
  });
1338
- }
1339
-
1340
- // HUD statusLine 설정
1341
- console.log(`${CYAN}[HUD 설정]${RESET}`);
1342
- const settingsPath = join(CLAUDE_DIR, "settings.json");
1343
- const hudPath = join(CLAUDE_DIR, "hud", "hud-qos-status.mjs");
1344
-
1345
- if (existsSync(hudPath)) {
1346
- try {
1347
- let settings = {};
1348
- if (existsSync(settingsPath)) {
1349
- settings = JSON.parse(readFileSync(settingsPath, "utf8"));
1350
- }
1351
-
1352
- const changed = applyStatusLine(settings, { hudPath, warn });
1353
- if (changed) persistSettings(settings, settingsPath);
1354
- const detail = changed ? "설정 완료" : "기존 설정 유지";
1355
- ok(`statusLine: ${detail}`);
1356
- summary.push({ item: "HUD statusLine", status: "✅", detail });
1357
- } catch (e) {
1358
- throw createCliError(`settings.json 처리 실패: ${e.message}`, {
1413
+ if (!cleanup.ok)
1414
+ throw createCliError(`settings.json 처리 실패: ${cleanup.error}`, {
1359
1415
  exitCode: EXIT_CONFIG_ERROR,
1360
1416
  reason: "configError",
1361
- fix: `${settingsPath}의 JSON 문법을 수정하세요.`,
1362
- cause: e,
1417
+ fix: "settings.json의 JSON 문법과 쓰기 권한을 확인하세요.",
1363
1418
  });
1419
+ if (cleanup.changed)
1420
+ addStep(report, `옛 Claude 훅 ${cleanup.removed}개 정리`);
1421
+ const agyCleanup = cleanupAgyHooks();
1422
+ if (agyCleanup.changed) addStep(report, "agy 옛 triflux-session 훅 정리");
1423
+ else if (!agyCleanup.ok)
1424
+ addWarning(
1425
+ report,
1426
+ "agy-cleanup",
1427
+ `agy 옛 훅 정리 실패: ${agyCleanup.error}`,
1428
+ );
1429
+ const mcpBackups = new Map();
1430
+ const mcpCleanup = cleanupLegacyMcp({ backups: mcpBackups });
1431
+ if (!mcpCleanup.skipped)
1432
+ mcpCleanup.warnings.forEach(warning("mcp-cleanup"));
1433
+ if (mcpCleanup.changed) addStep(report, "옛 MCP 설정 정리");
1434
+ const hubCleanup = cleanupTfxHub({
1435
+ pluginRoot: existsSync(join(PKG_ROOT, ".git")) ? undefined : PKG_ROOT,
1436
+ backups: mcpBackups,
1437
+ });
1438
+ hubCleanup.warnings.forEach(warning("hub-cleanup"));
1439
+ if (hubCleanup.changed)
1440
+ addStep(report, "제거된 허브의 설정과 실행 흔적 정리");
1441
+ const mcpPins = pinRegistryMcpPackages({ backups: mcpBackups });
1442
+ mcpPins.warnings.forEach(warning("mcp-pins"));
1443
+ if (mcpPins.pinned)
1444
+ addStep(report, `MCP 패키지 고정 버전 반영: ${mcpPins.pinned}개 항목`);
1445
+ if (fromUpdate) refreshSetupCaches(report);
1446
+
1447
+ for (const { src, dst, label } of SYNC_MAP) {
1448
+ const { change } = describeSyncAction(src, dst, label);
1449
+ if (change !== "noop") {
1450
+ mkdirSync(dirname(dst), { recursive: true });
1451
+ copyFileSync(src, dst);
1452
+ try {
1453
+ chmodSync(dst, 0o755);
1454
+ } catch {}
1455
+ }
1456
+ recordFile(report, label, change);
1364
1457
  }
1365
- } else {
1366
- warn("HUD 파일 없음 — 먼저 파일 동기화 필요");
1367
- summary.push({
1368
- item: "HUD statusLine",
1369
- status: "⚠️",
1370
- detail: "HUD 파일 없음",
1458
+ const retired = retireOldInstallFiles();
1459
+ retired.warnings.forEach(warning("retired-files"));
1460
+ if (retired.moved.length || retired.deleted.length)
1461
+ addStep(
1462
+ report,
1463
+ `옛 설치 파일 ${retired.moved.length + retired.deleted.length}개 정리`,
1464
+ );
1465
+ const skills = syncSkills();
1466
+ skills.warnings.forEach(warning("skills"));
1467
+ if (!skills.ok) throw new Error("스킬 동기화 실패");
1468
+ if (skills.changed) report.alreadyInGoalState = false;
1469
+ if (skills.removed) addStep(report, `구형 스킬 ${skills.removed}개 정리`);
1470
+
1471
+ const codex = ensureCodexProfiles();
1472
+ if (!codex.ok)
1473
+ addWarning(
1474
+ report,
1475
+ "codex-profiles",
1476
+ `Codex profiles 설정 실패: ${renderErrorMessage(codex.message)}`,
1477
+ );
1478
+ if (codex.changed) report.alreadyInGoalState = false;
1479
+ const gemini = ensureGeminiProfiles();
1480
+ if (!gemini.ok)
1481
+ addWarning(
1482
+ report,
1483
+ "gemini-profiles",
1484
+ `Antigravity/Gemini profiles 설정 실패: ${renderErrorMessage(gemini.message)}`,
1485
+ );
1486
+ if (gemini.added) report.alreadyInGoalState = false;
1487
+
1488
+ const settingsPath = join(CLAUDE_DIR, "settings.json");
1489
+ const hudPath = join(CLAUDE_DIR, "hud", "hud-qos-status.mjs");
1490
+ if (existsSync(hudPath)) {
1491
+ const settings = existsSync(settingsPath)
1492
+ ? JSON.parse(readFileSync(settingsPath, "utf8"))
1493
+ : {};
1494
+ if (applyStatusLine(settings, { hudPath, warn: warning("statusline") })) {
1495
+ persistSettings(settings, settingsPath);
1496
+ report.alreadyInGoalState = false;
1497
+ }
1498
+ } else
1499
+ addWarning(report, "statusline", "HUD 파일 없음: 먼저 파일 동기화 필요");
1500
+
1501
+ const consent = fromUpdate
1502
+ ? { changed: false, codexHooks: "skipped" }
1503
+ : await runConsentSteps({
1504
+ modsInstall: mods,
1505
+ interactive: !options.nonInteractive,
1506
+ report,
1507
+ });
1508
+ if (consent.changed) report.alreadyInGoalState = false;
1509
+ setupTargets(report, {
1510
+ skills: skills.total,
1511
+ files: SYNC_MAP.length,
1512
+ codex,
1513
+ gemini,
1514
+ hooks: consent.codexHooks,
1371
1515
  });
1372
- }
1373
-
1374
- // Codex 프로필 정리가 끝난 뒤에 훅을 다룬다.
1375
- await runConsentSteps({ modsInstall: mods, log: info, warn });
1376
-
1377
- // ── psmux 기본 셸 자동 수정 (cmd.exe → PowerShell) ──
1378
- if (process.platform === "win32" && which("psmux")) {
1379
- try {
1380
- const shellOut = execSync("psmux show-options -g default-shell 2>NUL", {
1381
- encoding: "utf8",
1382
- timeout: 3000,
1383
- }).trim();
1384
- if (!/powershell|pwsh/i.test(shellOut)) {
1385
- const pwsh = which("pwsh")
1386
- ? "pwsh"
1387
- : which("powershell.exe")
1388
- ? "powershell.exe"
1389
- : "";
1390
- if (pwsh) {
1391
- execSync(`psmux set-option -g default-shell "${pwsh}"`, {
1392
- timeout: 3000,
1393
- stdio: "pipe",
1394
- });
1395
- ok(`psmux 기본 셸 → ${pwsh}`);
1516
+ // ── psmux 기본 셸 자동 수정 (cmd.exe → PowerShell) ──
1517
+ if (process.platform === "win32" && which("psmux")) {
1518
+ try {
1519
+ const shellOut = execSync("psmux show-options -g default-shell 2>NUL", {
1520
+ encoding: "utf8",
1521
+ timeout: 3000,
1522
+ }).trim();
1523
+ if (!/powershell|pwsh/i.test(shellOut)) {
1524
+ const pwsh = which("pwsh")
1525
+ ? "pwsh"
1526
+ : which("powershell.exe")
1527
+ ? "powershell.exe"
1528
+ : "";
1529
+ if (pwsh) {
1530
+ execSync(`psmux set-option -g default-shell "${pwsh}"`, {
1531
+ timeout: 3000,
1532
+ stdio: "pipe",
1533
+ });
1534
+ addStep(report, `psmux 기본 셸: ${pwsh}`);
1535
+ }
1396
1536
  }
1537
+ } catch {
1538
+ /* psmux 서버 미실행이면 무시 */
1397
1539
  }
1398
- } catch {
1399
- /* psmux 서버 미실행이면 무시 */
1400
1540
  }
1401
- }
1402
-
1403
- // CLI 존재 확인
1404
- const cliChecks = [
1405
- { name: "codex", install: "npm i -g @openai/codex" },
1406
- {
1407
- name: "agy",
1408
- label: "Antigravity",
1409
- install: "Install Google Antigravity and ensure `agy` is on PATH",
1410
- },
1411
- ];
1412
- for (const { name, label, install } of cliChecks) {
1413
- const displayName = label || name;
1414
- if (which(name)) {
1415
- summary.push({
1416
- item: `${displayName} CLI`,
1417
- status: "✅",
1418
- detail: "설치됨",
1419
- });
1420
- } else {
1421
- summary.push({
1422
- item: `${displayName} CLI`,
1423
- status: "⏭️",
1424
- detail: `미설치 (${install})`,
1425
- });
1426
- }
1427
- }
1428
-
1429
- // ── 결과 요약 테이블 ──
1430
- console.log(`\n${BOLD}── 설정 요약 ──${RESET}`);
1431
- const maxItem = Math.max(...summary.map((s) => s.item.length));
1432
- for (const { item, status, detail } of summary) {
1433
- console.log(` ${status} ${item.padEnd(maxItem)} ${DIM}${detail}${RESET}`);
1434
- }
1435
1541
 
1436
- console.log(`\n${DIM}설치 위치: ${CLAUDE_DIR}${RESET}`);
1437
- console.log(`${DIM}버전: v${overrideVersion || PKG.version}${RESET}\n`);
1542
+ report.message = "설정 완료";
1543
+ } catch (error) {
1544
+ report.outcome = "failed";
1545
+ report.alreadyInGoalState = false;
1546
+ report.message = error.message;
1547
+ report.exitCode = error.exitCode ?? EXIT_ERROR;
1548
+ process.exitCode = report.exitCode;
1549
+ }
1550
+ process.stdout.write(
1551
+ renderCliResult(report, { ...options, details: fromUpdate }),
1552
+ );
1553
+ return report;
1438
1554
  }
1439
1555
 
1440
1556
  function addDoctorCheck(report, entry) {
@@ -1810,7 +1926,16 @@ async function cmdDoctor(options = {}) {
1810
1926
  info("Homebrew 설치 후: brew install coreutils");
1811
1927
  }
1812
1928
  }
1813
- reportSkillSync();
1929
+ const skillFix = syncSkills();
1930
+ for (const warning of skillFix.warnings) warn(warning);
1931
+ report.actions.push({
1932
+ type: "skill-sync",
1933
+ status: skillFix.ok ? "ok" : "failed",
1934
+ changed: skillFix.changed,
1935
+ });
1936
+ if (!skillFix.ok) warn("스킬 동기화 실패");
1937
+ else if (skillFix.changed) ok(`스킬 ${skillFix.changed}개 반영됨`);
1938
+ else info("스킬: 이미 최신 상태");
1814
1939
  const profileFix = ensureCodexProfiles();
1815
1940
  if (!profileFix.ok) {
1816
1941
  warn(
@@ -3230,201 +3355,164 @@ async function cmdUpdate(args = []) {
3230
3355
  printCommandHelp("update");
3231
3356
  return;
3232
3357
  }
3358
+ const options = outputOptions(RAW_ARGS);
3359
+ const report = createCliResult("update", PKG.version);
3360
+ const started = Date.now();
3361
+ const registryVersion = () =>
3362
+ execSync("npm view triflux version", {
3363
+ encoding: "utf8",
3364
+ timeout: 10000,
3365
+ stdio: ["pipe", "pipe", "pipe"],
3366
+ windowsHide: true,
3367
+ }).trim();
3368
+ try {
3369
+ if (options.check) {
3370
+ report.to = registryVersion();
3371
+ if (!report.to) throw new Error("레지스트리 버전 응답이 비어 있음");
3372
+ report.alreadyInGoalState = report.from === report.to;
3373
+ } else {
3374
+ // 1. 설치 방식 감지
3375
+ const pluginsFile = join(CLAUDE_DIR, "plugins", "installed_plugins.json");
3376
+ let installMode = "unknown";
3377
+ let pluginPath = null;
3233
3378
 
3234
- console.log(`\n${BOLD}triflux update${RESET}\n`);
3235
-
3236
- // 1. 설치 방식 감지
3237
- const pluginsFile = join(CLAUDE_DIR, "plugins", "installed_plugins.json");
3238
- let installMode = "unknown";
3239
- let pluginPath = null;
3379
+ // 플러그인 모드 감지
3380
+ if (existsSync(pluginsFile)) {
3381
+ try {
3382
+ const entry = findTrifluxPluginEntry(
3383
+ JSON.parse(readFileSync(pluginsFile, "utf8")),
3384
+ );
3385
+ if (entry) {
3386
+ pluginPath = entry.installPath;
3387
+ installMode = "plugin";
3388
+ }
3389
+ } catch {}
3390
+ }
3240
3391
 
3241
- // 플러그인 모드 감지
3242
- if (existsSync(pluginsFile)) {
3243
- try {
3244
- const entry = findTrifluxPluginEntry(
3245
- JSON.parse(readFileSync(pluginsFile, "utf8")),
3246
- );
3247
- if (entry) {
3248
- pluginPath = entry.installPath;
3392
+ // PKG_ROOT가 플러그인 캐시 내에 있으면 플러그인 모드
3393
+ if (
3394
+ installMode === "unknown" &&
3395
+ PKG_ROOT.includes(join(".claude", "plugins"))
3396
+ ) {
3249
3397
  installMode = "plugin";
3398
+ pluginPath = PKG_ROOT;
3250
3399
  }
3251
- } catch {}
3252
- }
3253
-
3254
- // PKG_ROOT가 플러그인 캐시 내에 있으면 플러그인 모드
3255
- if (
3256
- installMode === "unknown" &&
3257
- PKG_ROOT.includes(join(".claude", "plugins"))
3258
- ) {
3259
- installMode = "plugin";
3260
- pluginPath = PKG_ROOT;
3261
- }
3262
-
3263
- // npm global 감지
3264
- if (installMode === "unknown") {
3265
- try {
3266
- const npmList = execSync("npm list -g triflux --depth=0", {
3267
- encoding: "utf8",
3268
- timeout: 10000,
3269
- stdio: ["pipe", "pipe", "ignore"],
3270
- windowsHide: true,
3271
- });
3272
- if (npmList.includes("triflux")) installMode = "npm-global";
3273
- } catch {}
3274
- }
3275
3400
 
3276
- // npm local 감지
3277
- if (installMode === "unknown") {
3278
- const localPkg = join(process.cwd(), "node_modules", "triflux");
3279
- if (existsSync(localPkg)) installMode = "npm-local";
3280
- }
3281
-
3282
- // git 저장소 직접 사용
3283
- if (installMode === "unknown" && existsSync(join(PKG_ROOT, ".git"))) {
3284
- installMode = "git-local";
3285
- }
3286
-
3287
- info(
3288
- `검색: ${installMode === "plugin" ? "플러그인" : installMode === "npm-global" ? "npm global" : installMode === "npm-local" ? "npm local" : installMode === "git-local" ? "git 로컬 저장소" : "알 수 없음"} 설치 감지`,
3289
- );
3290
-
3291
- const networkTargets = resolveUpdateTargets({ installMode, pluginPath });
3292
- if (networkTargets.length > 0) {
3293
- const networkStatus = await checkNetworkAvailability(networkTargets);
3294
- if (!networkStatus.online) {
3295
- fail(`네트워크 확인 실패: ${networkStatus.unreachable.join(", ")}`);
3296
- info("네트워크 연결을 확인한 뒤 다시 시도하세요.");
3297
- return;
3298
- }
3299
- ok(`네트워크 확인 완료 (${networkStatus.reachable.join(", ")})`);
3300
- }
3401
+ // npm global 감지
3402
+ if (installMode === "unknown") {
3403
+ try {
3404
+ const npmList = execSync("npm list -g triflux --depth=0", {
3405
+ encoding: "utf8",
3406
+ timeout: 10000,
3407
+ stdio: ["pipe", "pipe", "ignore"],
3408
+ windowsHide: true,
3409
+ });
3410
+ if (npmList.includes("triflux")) installMode = "npm-global";
3411
+ } catch {}
3412
+ }
3301
3413
 
3302
- const cacheValidation = validateRuntimeCachePaths(join(CLAUDE_DIR, "cache"));
3303
- if (!cacheValidation.ok) {
3304
- warn(`런타임 캐시 검증 이슈 ${cacheValidation.issues.length}건 발견`);
3305
- for (const issue of cacheValidation.issues) {
3306
- info(`${issue.file}: ${issue.error}`);
3307
- }
3308
- } else {
3309
- ok("런타임 캐시 검증 완료");
3310
- }
3414
+ // npm local 감지
3415
+ if (installMode === "unknown") {
3416
+ const localPkg = join(process.cwd(), "node_modules", "triflux");
3417
+ if (existsSync(localPkg)) installMode = "npm-local";
3418
+ }
3311
3419
 
3312
- // 2. 설치 방식에 따라 업데이트
3313
- const oldVer = PKG.version;
3314
- let updated = false;
3420
+ // git 저장소 직접 사용
3421
+ if (installMode === "unknown" && existsSync(join(PKG_ROOT, ".git"))) {
3422
+ installMode = "git-local";
3423
+ }
3315
3424
 
3316
- try {
3317
- switch (installMode) {
3318
- case "plugin": {
3319
- const gitDir = pluginPath || PKG_ROOT;
3320
- const result = execSync("git pull", {
3321
- encoding: "utf8",
3322
- timeout: 30000,
3323
- cwd: gitDir,
3324
- windowsHide: true,
3325
- }).trim();
3326
- ok(`git pull — ${result}`);
3327
- updated = true;
3328
- break;
3425
+ report.installMode = installMode;
3426
+ if (["git-local", "plugin", "npm-local"].includes(installMode))
3427
+ report.installPath =
3428
+ installMode === "npm-local" ? process.cwd() : pluginPath || PKG_ROOT;
3429
+ const networkTargets = resolveUpdateTargets({ installMode, pluginPath });
3430
+ if (networkTargets.length) {
3431
+ const network = await checkNetworkAvailability(networkTargets);
3432
+ if (!network.online)
3433
+ throw new Error(
3434
+ `네트워크 확인 실패: ${network.unreachable.join(", ")}`,
3435
+ );
3329
3436
  }
3330
- case "npm-global": {
3331
- const npmCmd = "npm install -g triflux@latest";
3332
- let result;
3333
- try {
3334
- result = execSync(npmCmd, {
3437
+ const cache = validateRuntimeCachePaths(join(CLAUDE_DIR, "cache"));
3438
+ for (const issue of cache.issues)
3439
+ addWarning(report, "cache", `${issue.file}: ${issue.error}`);
3440
+ switch (installMode) {
3441
+ case "plugin":
3442
+ case "git-local":
3443
+ execSync("git pull", {
3335
3444
  encoding: "utf8",
3336
- timeout: 90000,
3337
- stdio: ["pipe", "pipe", "pipe"],
3445
+ timeout: 30000,
3446
+ cwd: pluginPath || PKG_ROOT,
3447
+ stdio: ["ignore", "pipe", "pipe"],
3338
3448
  windowsHide: true,
3339
- })
3340
- .trim()
3341
- .split(/\r?\n/)[0];
3342
- } catch {
3343
- // Windows: 자기 자신의 파일 잠금으로 첫 시도 실패 가능 → --force 재시도
3344
- info("첫 시도 실패, --force 재시도 중...");
3345
- result = execSync(`${npmCmd} --force`, {
3449
+ });
3450
+ break;
3451
+ case "npm-global": {
3452
+ let latest;
3453
+ try {
3454
+ latest = registryVersion();
3455
+ } catch {}
3456
+ if (latest === PKG.version) break;
3457
+ installNpmGlobal();
3458
+ break;
3459
+ }
3460
+ case "npm-local":
3461
+ execSync("npm update triflux", {
3346
3462
  encoding: "utf8",
3347
- timeout: 90000,
3463
+ timeout: 60000,
3464
+ cwd: process.cwd(),
3348
3465
  stdio: ["pipe", "pipe", "pipe"],
3349
3466
  windowsHide: true,
3350
- })
3351
- .trim()
3352
- .split(/\r?\n/)[0];
3353
- }
3354
- ok(`${npmCmd}: ${result || "완료"}`);
3355
- updated = true;
3356
- break;
3357
- }
3358
- case "npm-local": {
3359
- const npmLocalCmd = "npm update triflux";
3360
- const result = execSync(npmLocalCmd, {
3361
- encoding: "utf8",
3362
- timeout: 60000,
3363
- cwd: process.cwd(),
3364
- stdio: ["pipe", "pipe", "ignore"],
3365
- windowsHide: true,
3366
- })
3367
- .trim()
3368
- .split(/\r?\n/)[0];
3369
- ok(`${npmLocalCmd}: ${result || "완료"}`);
3370
- updated = true;
3371
- break;
3372
- }
3373
- case "git-local": {
3374
- const result = execSync("git pull", {
3375
- encoding: "utf8",
3376
- timeout: 30000,
3377
- cwd: PKG_ROOT,
3378
- windowsHide: true,
3379
- }).trim();
3380
- ok(`git pull — ${result}`);
3381
- updated = true;
3382
- break;
3467
+ });
3468
+ break;
3469
+ default:
3470
+ throw new Error("설치 방식을 감지할 수 없음");
3383
3471
  }
3384
- default:
3385
- fail("설치 방식을 감지할 수 없음");
3386
- info("수동 업데이트: cd <triflux-dir> && git pull");
3387
- return;
3388
- }
3389
- } catch (e) {
3390
- const stderr = e.stderr?.toString().trim();
3391
- fail(
3392
- `업데이트 실패: ${e.message}${stderr ? `\n ${stderr.split(/\r?\n/)[0]}` : ""}`,
3393
- );
3394
- return;
3395
- }
3396
-
3397
- // 3. setup 재실행 (파일 동기화, 프로파일, HUD, CLI 확인)
3398
- if (updated) {
3399
- console.log("");
3400
- const updatedRoot =
3401
- installMode === "plugin" ? pluginPath || PKG_ROOT : PKG_ROOT;
3402
- // 업데이트 후 새 버전 읽기
3403
- let newVer = oldVer;
3404
- try {
3405
- const newPkg = JSON.parse(
3472
+ const updatedRoot =
3473
+ installMode === "plugin" ? pluginPath || PKG_ROOT : PKG_ROOT;
3474
+ report.to = JSON.parse(
3406
3475
  readFileSync(join(updatedRoot, "package.json"), "utf8"),
3407
- );
3408
- newVer = newPkg.version;
3409
- } catch {}
3410
-
3411
- if (newVer !== oldVer) {
3412
- ok(`버전: v${oldVer} → v${newVer}`);
3413
- } else {
3414
- ok(`버전: v${oldVer} (이미 최신)`);
3415
- }
3416
-
3417
- try {
3418
- runUpdatedSetup({ packageRoot: updatedRoot });
3419
- } catch (error) {
3420
- throw createCliError(`업데이트 후 설정 동기화 실패: ${error.message}`, {
3421
- exitCode: error.status || EXIT_ERROR,
3422
- reason: "error",
3476
+ ).version;
3477
+ const setup = await runUpdatedSetup({
3478
+ packageRoot: updatedRoot,
3479
+ options,
3480
+ });
3481
+ Object.assign(report, {
3482
+ outcome: setup.outcome,
3483
+ message: setup.message,
3484
+ to: setup.to,
3485
+ changed: setup.changed,
3486
+ unchanged: setup.unchanged,
3487
+ targets: setup.targets,
3488
+ files: setup.files,
3489
+ steps: setup.steps,
3490
+ notices: setup.notices,
3491
+ alreadyInGoalState:
3492
+ setup.alreadyInGoalState && report.from === setup.to,
3423
3493
  });
3494
+ for (const warning of setup.warnings)
3495
+ addWarning(report, warning.code, warning.message);
3496
+ if (setup.outcome !== "ok") {
3497
+ process.exitCode =
3498
+ setup.exitCode ?? (setup.outcome === "skipped" ? 0 : EXIT_ERROR);
3499
+ return process.stdout.write(renderCliResult(report, options));
3500
+ }
3424
3501
  }
3502
+ report.message =
3503
+ report.from === report.to
3504
+ ? "최신"
3505
+ : options.check
3506
+ ? "업데이트 있음"
3507
+ : "업데이트 완료";
3508
+ } catch (error) {
3509
+ report.outcome = "failed";
3510
+ report.alreadyInGoalState = false;
3511
+ report.message = commandErrorMessage(error);
3512
+ process.exitCode = error.exitCode ?? EXIT_ERROR;
3425
3513
  }
3426
-
3427
- console.log(`${GREEN}${BOLD}✓ 업데이트 완료${RESET}\n`);
3514
+ report.elapsed = Date.now() - started;
3515
+ process.stdout.write(renderCliResult(report, options));
3428
3516
  }
3429
3517
 
3430
3518
  function cmdList(options = {}) {
@@ -3815,9 +3903,10 @@ async function main() {
3815
3903
  const cmd = NORMALIZED_ARGS[0] || "help";
3816
3904
  const cmdArgs = NORMALIZED_ARGS.slice(1);
3817
3905
 
3818
- cleanupTmpFiles({
3819
- protectPaths: [process.env.HOME, process.env.USERPROFILE],
3820
- }).catch(() => {});
3906
+ if (!(["update", "setup"].includes(cmd) && cmdArgs.includes("--check")))
3907
+ cleanupTmpFiles({
3908
+ protectPaths: [process.env.HOME, process.env.USERPROFILE],
3909
+ }).catch(() => {});
3821
3910
 
3822
3911
  switch (cmd) {
3823
3912
  case "setup":
@@ -3825,9 +3914,20 @@ async function main() {
3825
3914
  printCommandHelp("setup");
3826
3915
  return;
3827
3916
  }
3917
+ if (cmdArgs.includes("--check"))
3918
+ throw createCliError(
3919
+ "setup 은 --check 를 지원하지 않습니다. --dry-run 을 쓰세요.",
3920
+ {
3921
+ exitCode: EXIT_ARG_ERROR,
3922
+ reason: "argError",
3923
+ fix: "tfx setup --dry-run",
3924
+ },
3925
+ );
3828
3926
  await cmdSetup({
3927
+ ...outputOptions(RAW_ARGS),
3829
3928
  dryRun: cmdArgs.includes("--dry-run"),
3830
3929
  fromUpdate: cmdArgs.includes("--from-update"),
3930
+ consentOnly: cmdArgs.includes("--consent-only"),
3831
3931
  mods: cmdArgs.includes("--mods"),
3832
3932
  });
3833
3933
  return;