@dusky-bluehour/agent-service 0.6.2

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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +205 -0
  3. package/antigravity/README.md +37 -0
  4. package/antigravity/agents/agent-catalog.json +72 -0
  5. package/antigravity/artifacts/artifact-catalog.json +184 -0
  6. package/antigravity/commands/command-catalog.json +942 -0
  7. package/antigravity/skills/code-review-and-improvement/SKILL.md +15 -0
  8. package/antigravity/skills/frontend-repetition-pack/SKILL.md +15 -0
  9. package/antigravity/skills/incident-response/SKILL.md +15 -0
  10. package/antigravity/skills/prd-to-production-pipeline/SKILL.md +16 -0
  11. package/antigravity/skills/release-and-operations/SKILL.md +15 -0
  12. package/antigravity/skills/security-hardening/SKILL.md +15 -0
  13. package/antigravity/skills/service-lifecycle-orchestration/SKILL.md +16 -0
  14. package/antigravity/workflows/workflow-catalog.json +362 -0
  15. package/catalog/tool-catalog.ko.json +296 -0
  16. package/claude-code/README.md +47 -0
  17. package/claude-code/agent-teams/team-catalog.json +69 -0
  18. package/claude-code/commands/command-catalog.json +942 -0
  19. package/claude-code/skills/code-review-and-improvement/SKILL.md +16 -0
  20. package/claude-code/skills/frontend-repetition-pack/SKILL.md +16 -0
  21. package/claude-code/skills/incident-response/SKILL.md +16 -0
  22. package/claude-code/skills/prd-to-production-pipeline/SKILL.md +17 -0
  23. package/claude-code/skills/release-and-operations/SKILL.md +16 -0
  24. package/claude-code/skills/security-hardening/SKILL.md +15 -0
  25. package/claude-code/skills/service-lifecycle-orchestration/SKILL.md +17 -0
  26. package/claude-code/subagents/backend-engineer.md +20 -0
  27. package/claude-code/subagents/code-reviewer.md +19 -0
  28. package/claude-code/subagents/frontend-engineer.md +20 -0
  29. package/claude-code/subagents/hook-refactor-engineer.md +19 -0
  30. package/claude-code/subagents/incident-commander.md +19 -0
  31. package/claude-code/subagents/lead-orchestrator.md +18 -0
  32. package/claude-code/subagents/operations-owner.md +20 -0
  33. package/claude-code/subagents/performance-engineer.md +19 -0
  34. package/claude-code/subagents/prd-writer.md +20 -0
  35. package/claude-code/subagents/product-planner.md +19 -0
  36. package/claude-code/subagents/qa-engineer.md +19 -0
  37. package/claude-code/subagents/security-engineer.md +20 -0
  38. package/claude-code/subagents/solution-architect.md +19 -0
  39. package/claude-code/subagents/sre-release-engineer.md +20 -0
  40. package/claude-code/subagents/ui-component-engineer.md +19 -0
  41. package/claude-code/workflows/workflow-catalog.json +680 -0
  42. package/codex/README.md +38 -0
  43. package/codex/automations/automation-recipes.toml +30 -0
  44. package/codex/commands/command-catalog.json +942 -0
  45. package/codex/instructions/AGENTS.override.template.md +21 -0
  46. package/codex/instructions/AGENTS.template.md +31 -0
  47. package/codex/skills/code-review-and-improvement/SKILL.md +16 -0
  48. package/codex/skills/code-review-and-improvement/agents/openai.yaml +4 -0
  49. package/codex/skills/frontend-repetition-pack/SKILL.md +15 -0
  50. package/codex/skills/frontend-repetition-pack/agents/openai.yaml +4 -0
  51. package/codex/skills/incident-response/SKILL.md +16 -0
  52. package/codex/skills/incident-response/agents/openai.yaml +4 -0
  53. package/codex/skills/prd-to-production-pipeline/SKILL.md +16 -0
  54. package/codex/skills/prd-to-production-pipeline/agents/openai.yaml +4 -0
  55. package/codex/skills/release-and-operations/SKILL.md +15 -0
  56. package/codex/skills/release-and-operations/agents/openai.yaml +4 -0
  57. package/codex/skills/security-hardening/SKILL.md +15 -0
  58. package/codex/skills/security-hardening/agents/openai.yaml +4 -0
  59. package/codex/skills/service-lifecycle-orchestration/SKILL.md +17 -0
  60. package/codex/skills/service-lifecycle-orchestration/agents/openai.yaml +4 -0
  61. package/codex/workflows/workflow-catalog.json +444 -0
  62. package/package.json +44 -0
  63. package/scripts/init.mjs +993 -0
  64. package/scripts/validate.mjs +591 -0
@@ -0,0 +1,993 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import readline from 'node:readline';
6
+ import { createInterface } from 'node:readline/promises';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { stdin as input, stdout as output } from 'node:process';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = path.dirname(__filename);
12
+ const rootDir = path.resolve(__dirname, '..');
13
+ const catalogPath = path.join(rootDir, 'catalog', 'tool-catalog.ko.json');
14
+ const packageJsonPath = path.join(rootDir, 'package.json');
15
+ const CLI_NAME = 'tri-agent-manager';
16
+ const CLI_COMPAT_NAME = 'tri-agent-os';
17
+ const STATE_DIR_NAME = '.tri-agent-manager';
18
+ const LEGACY_STATE_DIR_NAME = '.tri-agent-os';
19
+
20
+ const HELP_TEXT = [
21
+ `${CLI_NAME} 사용법`,
22
+ '',
23
+ '명령:',
24
+ ` ${CLI_NAME} list`,
25
+ ` ${CLI_NAME} setup (install + interactive 별칭)`,
26
+ ` ${CLI_NAME} wizard (install + interactive 별칭)`,
27
+ ` ${CLI_NAME} install [--preset <id>] [--tool <ids>] [--components <ids>] [--target <path>] [--force] [--dry-run] [--yes]`,
28
+ ` ${CLI_NAME} update [--preset <id>] [--tool <ids>] [--components <ids>] [--target <path>] [--dry-run] [--yes]`,
29
+ ` ${CLI_NAME} init (install의 별칭)`,
30
+ '',
31
+ '옵션:',
32
+ ' --preset 프리셋 선택 (예: balanced-core, full-suite)',
33
+ ' --tool 도구 선택 (예: codex,claude-code) / all',
34
+ ' --components 구성요소 선택 (예: skills,workflows,commands) / all',
35
+ ' --target 설치 경로 (기본: .)',
36
+ ' --force install 시 기존 파일 덮어쓰기',
37
+ ' --dry-run 복사하지 않고 작업 계획만 출력',
38
+ ' --yes 확인 프롬프트 생략',
39
+ ' --interactive 대화형 선택 모드 강제',
40
+ ' --non-interactive 비대화형 모드 강제',
41
+ ' (중복 선택 입력은 자동으로 1회로 정리됨)',
42
+ '',
43
+ '호환 별칭:',
44
+ ` ${CLI_COMPAT_NAME} ... (동일 동작)`,
45
+ '',
46
+ 'pnpm 예시:',
47
+ ' pnpm dlx tri-agent-manager --interactive',
48
+ ' pnpm dlx tri-agent-manager update --interactive',
49
+ ' pnpm dlx tri-agent-manager setup',
50
+ ` pnpm exec ${CLI_NAME} list`
51
+ ].join('\n');
52
+
53
+ function parseArgs(argv) {
54
+ const args = [...argv];
55
+ let command = 'install';
56
+
57
+ if (args.length > 0 && !args[0].startsWith('--')) {
58
+ command = args.shift();
59
+ }
60
+
61
+ const getFlag = (name) => {
62
+ const idx = args.indexOf(name);
63
+ if (idx === -1) return undefined;
64
+ const value = args[idx + 1];
65
+ if (!value || value.startsWith('--')) return undefined;
66
+ return value;
67
+ };
68
+
69
+ const hasFlag = (name) => args.includes(name);
70
+
71
+ return {
72
+ command,
73
+ presetFlag: getFlag('--preset'),
74
+ toolFlag: getFlag('--tool'),
75
+ componentFlag: getFlag('--components'),
76
+ targetFlag: getFlag('--target') ?? '.',
77
+ force: hasFlag('--force'),
78
+ dryRun: hasFlag('--dry-run'),
79
+ yes: hasFlag('--yes'),
80
+ interactive: hasFlag('--interactive'),
81
+ nonInteractive: hasFlag('--non-interactive')
82
+ };
83
+ }
84
+
85
+ async function readJson(filePath) {
86
+ const raw = await fs.readFile(filePath, 'utf8');
87
+ return JSON.parse(raw);
88
+ }
89
+
90
+ async function exists(filePath) {
91
+ try {
92
+ await fs.access(filePath);
93
+ return true;
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ function parseCsv(value) {
100
+ if (!value) return [];
101
+ return value
102
+ .split(',')
103
+ .map((v) => v.trim())
104
+ .filter(Boolean);
105
+ }
106
+
107
+ function unique(values) {
108
+ return [...new Set(values)];
109
+ }
110
+
111
+ function parseChoiceInput(rawInput, entries, defaultIds = []) {
112
+ const normalized = (rawInput ?? '').trim();
113
+ if (!normalized) {
114
+ return unique(defaultIds.length > 0 ? defaultIds : entries.map((e) => e.id));
115
+ }
116
+
117
+ if (normalized === 'all' || normalized === '*') {
118
+ return entries.map((e) => e.id);
119
+ }
120
+
121
+ const indexMap = new Map(entries.map((entry, index) => [String(index + 1), entry.id]));
122
+ const idSet = new Set(entries.map((entry) => entry.id));
123
+ const tokens = normalized
124
+ .split(',')
125
+ .map((t) => t.trim())
126
+ .filter(Boolean);
127
+
128
+ const selected = [];
129
+ for (const token of tokens) {
130
+ if (indexMap.has(token)) {
131
+ selected.push(indexMap.get(token));
132
+ continue;
133
+ }
134
+ if (idSet.has(token)) {
135
+ selected.push(token);
136
+ continue;
137
+ }
138
+ throw new Error(`알 수 없는 선택값: ${token}`);
139
+ }
140
+
141
+ return unique(selected);
142
+ }
143
+
144
+ function findTool(catalog, toolId) {
145
+ return catalog.tools.find((t) => t.id === toolId);
146
+ }
147
+
148
+ function findPreset(catalog, presetId) {
149
+ if (!presetId) return null;
150
+ const presets = Array.isArray(catalog.presets) ? catalog.presets : [];
151
+ const preset = presets.find((p) => p.id === presetId);
152
+ if (!preset) {
153
+ throw new Error(`지원하지 않는 preset: ${presetId}`);
154
+ }
155
+ return preset;
156
+ }
157
+
158
+ function resolveToolsFromPreset(preset, catalog) {
159
+ if (!preset) return [];
160
+ const toolIdSet = new Set(catalog.tools.map((t) => t.id));
161
+ const selected = unique(Array.isArray(preset.tools) ? preset.tools : []);
162
+ for (const toolId of selected) {
163
+ if (!toolIdSet.has(toolId)) {
164
+ throw new Error(`preset(${preset.id})에 존재하지 않는 tool이 포함됨: ${toolId}`);
165
+ }
166
+ }
167
+ return selected;
168
+ }
169
+
170
+ function resolveComponentsFromPreset(tool, preset) {
171
+ if (!preset) return [];
172
+ const requested = preset.components?.[tool.id];
173
+ if (!requested) return [];
174
+
175
+ const availableIds = tool.components.map((c) => c.id);
176
+ const availableSet = new Set(availableIds);
177
+ const uniqueRequested = unique(requested);
178
+ for (const componentId of uniqueRequested) {
179
+ if (!availableSet.has(componentId)) {
180
+ throw new Error(
181
+ `preset(${preset.id})에 존재하지 않는 구성요소가 포함됨: ${tool.id}/${componentId}`
182
+ );
183
+ }
184
+ }
185
+ return uniqueRequested;
186
+ }
187
+
188
+ function resolvePresetFromInput(rawInput, catalog, defaultPresetId = '') {
189
+ const normalized = (rawInput ?? '').trim() || defaultPresetId;
190
+ if (!normalized || normalized === '0' || normalized.toLowerCase() === 'manual') {
191
+ return null;
192
+ }
193
+
194
+ const presets = Array.isArray(catalog.presets) ? catalog.presets : [];
195
+ const indexMap = new Map(presets.map((preset, index) => [String(index + 1), preset.id]));
196
+ const presetId = indexMap.get(normalized) ?? normalized;
197
+ return findPreset(catalog, presetId);
198
+ }
199
+
200
+ function resolveToolsFromFlag(toolFlag, catalog) {
201
+ const toolEntries = catalog.tools;
202
+ if (!toolFlag || toolFlag === 'all') {
203
+ return toolEntries.map((t) => t.id);
204
+ }
205
+
206
+ const requested = parseCsv(toolFlag);
207
+ const toolIdSet = new Set(toolEntries.map((t) => t.id));
208
+ for (const id of requested) {
209
+ if (!toolIdSet.has(id)) {
210
+ throw new Error(`지원하지 않는 tool: ${id}`);
211
+ }
212
+ }
213
+ return unique(requested);
214
+ }
215
+
216
+ function resolveComponentsFromFlag(componentFlag, tool, mode) {
217
+ const availableIds = tool.components.map((c) => c.id);
218
+ if (!componentFlag || componentFlag === 'all') {
219
+ return availableIds;
220
+ }
221
+
222
+ const requested = parseCsv(componentFlag);
223
+ const availableSet = new Set(availableIds);
224
+ const invalid = requested.filter((id) => !availableSet.has(id));
225
+ if (invalid.length > 0) {
226
+ throw new Error(`[${tool.id}] 지원하지 않는 구성요소: ${invalid.join(', ')}`);
227
+ }
228
+
229
+ if (mode === 'update' && requested.length === 0) {
230
+ return availableIds;
231
+ }
232
+
233
+ return unique(requested);
234
+ }
235
+
236
+ function clearTuiScreen() {
237
+ output.write('\x1Bc');
238
+ }
239
+
240
+ function formatTuiLine(prefix, label, selected = false) {
241
+ const marker = selected ? '[x]' : '[ ]';
242
+ return `${prefix} ${marker} ${label}`;
243
+ }
244
+
245
+ function setRawMode(enabled) {
246
+ if (input.isTTY) {
247
+ input.setRawMode(enabled);
248
+ }
249
+ }
250
+
251
+ function renderTuiHeader(title, helpLine) {
252
+ clearTuiScreen();
253
+ output.write(`${title}\n`);
254
+ output.write(`${'='.repeat(title.length)}\n`);
255
+ output.write(`${helpLine}\n\n`);
256
+ }
257
+
258
+ function readKeypressOnce() {
259
+ return new Promise((resolve) => {
260
+ const handler = (str, key) => {
261
+ input.off('keypress', handler);
262
+ resolve({ str, key: key ?? {} });
263
+ };
264
+ input.on('keypress', handler);
265
+ });
266
+ }
267
+
268
+ async function askLine(question, defaultValue = '') {
269
+ setRawMode(false);
270
+ const rl = createInterface({ input, output });
271
+ try {
272
+ const suffix = defaultValue ? ` (기본: ${defaultValue})` : '';
273
+ const answer = await rl.question(`${question}${suffix}\n> `);
274
+ return answer.trim() || defaultValue;
275
+ } finally {
276
+ rl.close();
277
+ setRawMode(true);
278
+ }
279
+ }
280
+
281
+ async function runSingleSelectMenu({ title, help, options, defaultIndex = 0 }) {
282
+ let cursor = Math.min(Math.max(defaultIndex, 0), Math.max(options.length - 1, 0));
283
+
284
+ while (true) {
285
+ renderTuiHeader(title, help);
286
+ options.forEach((option, index) => {
287
+ const pointer = index === cursor ? '>' : ' ';
288
+ output.write(`${pointer} ${option.label}\n`);
289
+ if (option.description) {
290
+ output.write(` ${option.description}\n`);
291
+ }
292
+ });
293
+
294
+ const { str, key } = await readKeypressOnce();
295
+ if (key.ctrl && key.name === 'c') {
296
+ throw new Error('사용자 취소');
297
+ }
298
+ if (str === 'q' || key.name === 'escape') {
299
+ throw new Error('사용자 취소');
300
+ }
301
+
302
+ if (key.name === 'up' || key.name === 'left') {
303
+ cursor = (cursor - 1 + options.length) % options.length;
304
+ continue;
305
+ }
306
+ if (key.name === 'down' || key.name === 'right') {
307
+ cursor = (cursor + 1) % options.length;
308
+ continue;
309
+ }
310
+ if (key.name === 'return' || key.name === 'enter') {
311
+ return options[cursor];
312
+ }
313
+ }
314
+ }
315
+
316
+ async function runMultiSelectMenu({
317
+ title,
318
+ help,
319
+ choices,
320
+ preselectedIds = [],
321
+ minSelected = 1
322
+ }) {
323
+ const selected = new Set(preselectedIds);
324
+ const optionRows = [
325
+ ...choices.map((choice) => ({ kind: 'choice', ...choice })),
326
+ { kind: 'action', action: 'toggle-all', label: '[전체 선택/해제]' },
327
+ { kind: 'action', action: 'done', label: '[선택 완료]' }
328
+ ];
329
+ let cursor = 0;
330
+ let notice = '';
331
+
332
+ while (true) {
333
+ renderTuiHeader(title, help);
334
+ output.write(`선택됨: ${selected.size}개\n`);
335
+ if (notice) {
336
+ output.write(`알림: ${notice}\n`);
337
+ }
338
+ output.write('\n');
339
+
340
+ optionRows.forEach((row, index) => {
341
+ const pointer = index === cursor ? '>' : ' ';
342
+ if (row.kind === 'choice') {
343
+ output.write(`${formatTuiLine(pointer, row.label, selected.has(row.id))}\n`);
344
+ if (row.description) {
345
+ output.write(` ${row.description}\n`);
346
+ }
347
+ } else {
348
+ output.write(`${pointer} ${row.label}\n`);
349
+ }
350
+ });
351
+
352
+ const { str, key } = await readKeypressOnce();
353
+ if (key.ctrl && key.name === 'c') {
354
+ throw new Error('사용자 취소');
355
+ }
356
+ if (str === 'q' || key.name === 'escape') {
357
+ throw new Error('사용자 취소');
358
+ }
359
+
360
+ const current = optionRows[cursor];
361
+ notice = '';
362
+
363
+ if (key.name === 'up') {
364
+ cursor = (cursor - 1 + optionRows.length) % optionRows.length;
365
+ continue;
366
+ }
367
+ if (key.name === 'down') {
368
+ cursor = (cursor + 1) % optionRows.length;
369
+ continue;
370
+ }
371
+
372
+ if (current.kind === 'choice' && (key.name === 'right' || str === ' ' || key.name === 'return' || key.name === 'enter')) {
373
+ if (selected.has(current.id)) {
374
+ selected.delete(current.id);
375
+ } else {
376
+ selected.add(current.id);
377
+ }
378
+ continue;
379
+ }
380
+
381
+ if (current.kind === 'choice' && key.name === 'left') {
382
+ selected.delete(current.id);
383
+ continue;
384
+ }
385
+
386
+ if (current.kind === 'action' && current.action === 'toggle-all' && (key.name === 'return' || key.name === 'enter' || key.name === 'right' || key.name === 'left')) {
387
+ if (selected.size === choices.length) {
388
+ selected.clear();
389
+ } else {
390
+ choices.forEach((choice) => selected.add(choice.id));
391
+ }
392
+ continue;
393
+ }
394
+
395
+ if (current.kind === 'action' && current.action === 'done' && (key.name === 'return' || key.name === 'enter' || key.name === 'right')) {
396
+ if (selected.size < minSelected) {
397
+ notice = `최소 ${minSelected}개 이상 선택해야 합니다.`;
398
+ continue;
399
+ }
400
+ return [...selected];
401
+ }
402
+ }
403
+ }
404
+
405
+ async function promptInteractiveTui({ catalog, mode, defaultTarget, presetFlag }) {
406
+ readline.emitKeypressEvents(input);
407
+ const wasRawMode = Boolean(input.isRaw);
408
+ setRawMode(true);
409
+ input.resume();
410
+
411
+ try {
412
+ const targetChoice = await runSingleSelectMenu({
413
+ title: `${CLI_NAME} 대화형 설정`,
414
+ help: '조작: 위/아래/좌/우 이동, Enter 선택, q 종료',
415
+ options: [
416
+ {
417
+ id: 'default',
418
+ label: `기본 경로 사용 (${defaultTarget})`,
419
+ description: '추천: 현재 실행 컨텍스트의 기본 경로를 그대로 사용'
420
+ },
421
+ {
422
+ id: 'cwd',
423
+ label: '현재 폴더(.) 사용',
424
+ description: '현재 터미널 위치를 바로 사용'
425
+ },
426
+ {
427
+ id: 'custom',
428
+ label: '직접 경로 입력',
429
+ description: '직접 절대/상대 경로를 입력'
430
+ }
431
+ ],
432
+ defaultIndex: 0
433
+ });
434
+
435
+ let targetInput = defaultTarget;
436
+ if (targetChoice.id === 'cwd') {
437
+ targetInput = '.';
438
+ } else if (targetChoice.id === 'custom') {
439
+ targetInput = await askLine('설치/업데이트 대상 경로를 입력하세요.', defaultTarget);
440
+ }
441
+
442
+ const targetDir = path.resolve(process.cwd(), targetInput);
443
+ const state = await readState(targetDir);
444
+ const presets = Array.isArray(catalog.presets) ? catalog.presets : [];
445
+
446
+ const defaultPresetId =
447
+ presetFlag ??
448
+ (mode === 'update' ? state?.preset_id : null) ??
449
+ (mode === 'install' ? presets[0]?.id ?? null : null);
450
+
451
+ const presetOptions = [
452
+ {
453
+ id: 'manual',
454
+ label: '수동 선택',
455
+ description: '프리셋 없이 도구/구성요소를 직접 선택'
456
+ },
457
+ ...presets.map((preset) => ({
458
+ id: preset.id,
459
+ label: `${preset.title} (${preset.id})`,
460
+ description: preset.description
461
+ }))
462
+ ];
463
+ const presetDefaultIndex =
464
+ defaultPresetId == null
465
+ ? 0
466
+ : Math.max(
467
+ 0,
468
+ presetOptions.findIndex((option) => option.id === defaultPresetId)
469
+ );
470
+ const presetOption = await runSingleSelectMenu({
471
+ title: '프리셋 선택',
472
+ help: '조작: 위/아래/좌/우 이동, Enter 선택, q 종료',
473
+ options: presetOptions,
474
+ defaultIndex: presetDefaultIndex
475
+ });
476
+ const selectedPreset = presetOption.id === 'manual' ? null : findPreset(catalog, presetOption.id);
477
+
478
+ let defaultToolIds = catalog.ux_defaults?.recommended_tools ?? catalog.tools.map((t) => t.id);
479
+ const presetToolIds = resolveToolsFromPreset(selectedPreset, catalog);
480
+ if (presetToolIds.length > 0) {
481
+ defaultToolIds = presetToolIds;
482
+ } else if (mode === 'update' && state?.tools) {
483
+ const installed = Object.keys(state.tools);
484
+ if (installed.length > 0) {
485
+ defaultToolIds = installed;
486
+ }
487
+ }
488
+
489
+ const selectedToolIds = await runMultiSelectMenu({
490
+ title: '도구 선택',
491
+ help: '조작: 위/아래 이동, Space 토글, 좌(해제), 우(선택), Enter',
492
+ choices: catalog.tools.map((tool) => ({
493
+ id: tool.id,
494
+ label: `${tool.title} (${tool.id})`,
495
+ description: tool.description
496
+ })),
497
+ preselectedIds: defaultToolIds,
498
+ minSelected: 1
499
+ });
500
+
501
+ const componentSelection = {};
502
+ for (const toolId of selectedToolIds) {
503
+ const tool = findTool(catalog, toolId);
504
+ if (!tool) continue;
505
+
506
+ let defaultComponents = tool.components.map((c) => c.id);
507
+ const presetComponents = resolveComponentsFromPreset(tool, selectedPreset);
508
+ if (presetComponents.length > 0) {
509
+ defaultComponents = presetComponents;
510
+ } else if (mode === 'install') {
511
+ const recommended = catalog.ux_defaults?.recommended_components?.[tool.id];
512
+ if (Array.isArray(recommended) && recommended.length > 0) {
513
+ defaultComponents = recommended;
514
+ }
515
+ }
516
+ if (mode === 'update' && state?.tools?.[tool.id]?.components?.length) {
517
+ defaultComponents = state.tools[tool.id].components;
518
+ }
519
+
520
+ const selectedComponents = await runMultiSelectMenu({
521
+ title: `${tool.title} 구성요소 선택`,
522
+ help: '조작: 위/아래 이동, Space 토글, 좌(해제), 우(선택), Enter',
523
+ choices: tool.components.map((component) => ({
524
+ id: component.id,
525
+ label: `${component.title} (${component.id})`,
526
+ description: component.description
527
+ })),
528
+ preselectedIds: defaultComponents,
529
+ minSelected: 1
530
+ });
531
+ componentSelection[toolId] = selectedComponents;
532
+ }
533
+
534
+ return {
535
+ targetDir,
536
+ selectedToolIds,
537
+ componentSelection,
538
+ presetId: selectedPreset?.id ?? null
539
+ };
540
+ } finally {
541
+ setRawMode(wasRawMode);
542
+ clearTuiScreen();
543
+ }
544
+ }
545
+
546
+ async function promptInteractiveText({ catalog, mode, defaultTarget, presetFlag }) {
547
+ const rl = createInterface({ input, output });
548
+
549
+ const ask = async (question, defaultValue = '') => {
550
+ const suffix = defaultValue ? ` (기본: ${defaultValue})` : '';
551
+ const answer = await rl.question(`${question}${suffix}\n> `);
552
+ return answer.trim() || defaultValue;
553
+ };
554
+
555
+ try {
556
+ console.log('');
557
+ console.log(`=== ${CLI_NAME} 대화형 설치/업데이트(텍스트 모드) ===`);
558
+ console.log('숫자 또는 ID를 콤마로 입력하세요. 예: 1,3 또는 codex,claude-code');
559
+ console.log('중복 입력은 자동으로 정리됩니다.');
560
+
561
+ const targetInput = await ask('설치/업데이트 대상 경로를 입력하세요.', defaultTarget);
562
+ const targetDir = path.resolve(process.cwd(), targetInput);
563
+ const state = await readState(targetDir);
564
+
565
+ const presets = Array.isArray(catalog.presets) ? catalog.presets : [];
566
+ let selectedPreset = findPreset(catalog, presetFlag);
567
+ if (!selectedPreset && presets.length > 0) {
568
+ console.log('');
569
+ console.log('[프리셋 목록]');
570
+ console.log('0. 수동 선택');
571
+ presets.forEach((preset, index) => {
572
+ console.log(`${index + 1}. ${preset.title} (${preset.id})`);
573
+ console.log(` - ${preset.description}`);
574
+ });
575
+
576
+ const defaultPresetId = mode === 'install' ? presets[0]?.id ?? '0' : '0';
577
+ const presetAnswer = await ask(
578
+ '프리셋을 선택하세요. (번호/ID, 수동 선택은 0)',
579
+ defaultPresetId
580
+ );
581
+ selectedPreset = resolvePresetFromInput(presetAnswer, catalog, defaultPresetId);
582
+ }
583
+
584
+ console.log('');
585
+ console.log('[도구 목록]');
586
+ catalog.tools.forEach((tool, index) => {
587
+ console.log(`${index + 1}. ${tool.title} (${tool.id})`);
588
+ console.log(` - ${tool.description}`);
589
+ });
590
+
591
+ let defaultToolIds = catalog.ux_defaults?.recommended_tools ?? catalog.tools.map((t) => t.id);
592
+ const presetToolIds = resolveToolsFromPreset(selectedPreset, catalog);
593
+ if (presetToolIds.length > 0) {
594
+ defaultToolIds = presetToolIds;
595
+ } else if (mode === 'update' && state?.tools) {
596
+ const installed = Object.keys(state.tools);
597
+ if (installed.length > 0) {
598
+ defaultToolIds = installed;
599
+ }
600
+ }
601
+
602
+ const toolAnswer = await ask(
603
+ '작업할 도구를 선택하세요. (`all` 가능)',
604
+ defaultToolIds.join(',')
605
+ );
606
+ const selectedToolIds = parseChoiceInput(toolAnswer, catalog.tools, defaultToolIds);
607
+
608
+ const componentSelection = {};
609
+ for (const toolId of selectedToolIds) {
610
+ const tool = findTool(catalog, toolId);
611
+ if (!tool) continue;
612
+
613
+ console.log('');
614
+ console.log(`[${tool.title}] 구성요소 목록`);
615
+ tool.components.forEach((component, index) => {
616
+ console.log(`${index + 1}. ${component.title} (${component.id})`);
617
+ console.log(` - ${component.description}`);
618
+ });
619
+
620
+ let defaultComponents = tool.components.map((c) => c.id);
621
+ const presetComponents = resolveComponentsFromPreset(tool, selectedPreset);
622
+ if (presetComponents.length > 0) {
623
+ defaultComponents = presetComponents;
624
+ } else if (mode === 'install') {
625
+ const recommended = catalog.ux_defaults?.recommended_components?.[tool.id];
626
+ if (Array.isArray(recommended) && recommended.length > 0) {
627
+ defaultComponents = recommended;
628
+ }
629
+ }
630
+ if (mode === 'update' && state?.tools?.[tool.id]?.components?.length) {
631
+ defaultComponents = state.tools[tool.id].components;
632
+ }
633
+
634
+ const componentAnswer = await ask(
635
+ `${tool.id}에서 설치/업데이트할 구성요소를 선택하세요. (` + '`all` 가능)',
636
+ defaultComponents.join(',')
637
+ );
638
+ componentSelection[tool.id] = parseChoiceInput(
639
+ componentAnswer,
640
+ tool.components,
641
+ defaultComponents
642
+ );
643
+ }
644
+
645
+ return {
646
+ targetDir,
647
+ selectedToolIds,
648
+ componentSelection,
649
+ presetId: selectedPreset?.id ?? null
650
+ };
651
+ } finally {
652
+ rl.close();
653
+ }
654
+ }
655
+
656
+ async function promptInteractive(params) {
657
+ if (input.isTTY && output.isTTY) {
658
+ return promptInteractiveTui(params);
659
+ }
660
+ return promptInteractiveText(params);
661
+ }
662
+
663
+ function buildSelectionFromFlags({
664
+ catalog,
665
+ mode,
666
+ presetFlag,
667
+ toolFlag,
668
+ componentFlag,
669
+ targetFlag
670
+ }) {
671
+ const preset = findPreset(catalog, presetFlag);
672
+ const presetToolIds = resolveToolsFromPreset(preset, catalog);
673
+ const selectedToolIds = toolFlag
674
+ ? resolveToolsFromFlag(toolFlag, catalog)
675
+ : presetToolIds.length > 0
676
+ ? presetToolIds
677
+ : resolveToolsFromFlag(undefined, catalog);
678
+ const componentSelection = {};
679
+
680
+ for (const toolId of selectedToolIds) {
681
+ const tool = findTool(catalog, toolId);
682
+ if (!tool) {
683
+ throw new Error(`카탈로그에 없는 tool: ${toolId}`);
684
+ }
685
+
686
+ if (componentFlag) {
687
+ componentSelection[toolId] = resolveComponentsFromFlag(componentFlag, tool, mode);
688
+ continue;
689
+ }
690
+
691
+ const presetComponents = resolveComponentsFromPreset(tool, preset);
692
+ if (presetComponents.length > 0) {
693
+ componentSelection[toolId] = presetComponents;
694
+ continue;
695
+ }
696
+
697
+ componentSelection[toolId] = resolveComponentsFromFlag(undefined, tool, mode);
698
+ }
699
+
700
+ return {
701
+ targetDir: path.resolve(process.cwd(), targetFlag),
702
+ selectedToolIds,
703
+ componentSelection,
704
+ presetId: preset?.id ?? null
705
+ };
706
+ }
707
+
708
+ async function copyEntry(srcPath, destPath, { overwrite, dryRun }) {
709
+ const srcStat = await fs.stat(srcPath);
710
+ const destExists = await exists(destPath);
711
+
712
+ if (destExists && !overwrite) {
713
+ return { status: 'skipped' };
714
+ }
715
+
716
+ if (dryRun) {
717
+ return { status: destExists ? 'would-overwrite' : 'would-copy' };
718
+ }
719
+
720
+ if (srcStat.isDirectory()) {
721
+ await fs.cp(srcPath, destPath, { recursive: true, force: overwrite });
722
+ } else {
723
+ await fs.mkdir(path.dirname(destPath), { recursive: true });
724
+ await fs.copyFile(srcPath, destPath);
725
+ }
726
+
727
+ return { status: destExists ? 'overwritten' : 'copied' };
728
+ }
729
+
730
+ function statusLabel(status) {
731
+ switch (status) {
732
+ case 'copied':
733
+ return '복사';
734
+ case 'overwritten':
735
+ return '업데이트';
736
+ case 'skipped':
737
+ return '건너뜀';
738
+ case 'would-copy':
739
+ return '복사 예정';
740
+ case 'would-overwrite':
741
+ return '업데이트 예정';
742
+ default:
743
+ return status;
744
+ }
745
+ }
746
+
747
+ function printPlan(catalog, selection, mode, targetDir) {
748
+ console.log('');
749
+ console.log('실행 계획');
750
+ console.log(`- 모드: ${mode}`);
751
+ console.log(`- 프리셋: ${selection.presetId ?? '수동/없음'}`);
752
+ console.log(`- 대상 경로: ${targetDir}`);
753
+
754
+ for (const toolId of selection.selectedToolIds) {
755
+ const tool = findTool(catalog, toolId);
756
+ const componentIds = selection.componentSelection[toolId] ?? [];
757
+ const labels = tool.components
758
+ .filter((c) => componentIds.includes(c.id))
759
+ .map((c) => `${c.title}(${c.id})`)
760
+ .join(', ');
761
+ console.log(`- ${tool.title}(${tool.id}): ${labels || '선택된 항목 없음'}`);
762
+ }
763
+ }
764
+
765
+ async function confirmProceed(yesFlag) {
766
+ if (yesFlag) return true;
767
+ if (!process.stdin.isTTY) return true;
768
+
769
+ const rl = createInterface({ input, output });
770
+ try {
771
+ const answer = await rl.question('계속 진행할까요? (y/N)\n> ');
772
+ const normalized = answer.trim().toLowerCase();
773
+ return normalized === 'y' || normalized === 'yes';
774
+ } finally {
775
+ rl.close();
776
+ }
777
+ }
778
+
779
+ async function readState(targetDir) {
780
+ const statePath = path.join(targetDir, STATE_DIR_NAME, 'state.json');
781
+ if (await exists(statePath)) {
782
+ try {
783
+ return await readJson(statePath);
784
+ } catch {
785
+ return null;
786
+ }
787
+ }
788
+
789
+ const legacyStatePath = path.join(targetDir, LEGACY_STATE_DIR_NAME, 'state.json');
790
+ if (await exists(legacyStatePath)) {
791
+ try {
792
+ return await readJson(legacyStatePath);
793
+ } catch {
794
+ return null;
795
+ }
796
+ }
797
+
798
+ return null;
799
+ }
800
+
801
+ async function writeState({ targetDir, packageData, mode, selection }) {
802
+ const stateDir = path.join(targetDir, STATE_DIR_NAME);
803
+ const statePath = path.join(stateDir, 'state.json');
804
+
805
+ const tools = {};
806
+ for (const toolId of selection.selectedToolIds) {
807
+ tools[toolId] = {
808
+ components: selection.componentSelection[toolId] ?? [],
809
+ last_mode: mode
810
+ };
811
+ }
812
+
813
+ const statePayload = {
814
+ schema_version: '1.0.0',
815
+ package_name: packageData.name,
816
+ package_version: packageData.version,
817
+ updated_at: new Date().toISOString(),
818
+ preset_id: selection.presetId ?? null,
819
+ tools
820
+ };
821
+
822
+ await fs.mkdir(stateDir, { recursive: true });
823
+ await fs.writeFile(statePath, `${JSON.stringify(statePayload, null, 2)}\n`, 'utf8');
824
+ }
825
+
826
+ async function runInstallOrUpdate({ catalog, packageData, mode, selection, force, dryRun }) {
827
+ const overwrite = mode === 'update' || force;
828
+ const targetDir = selection.targetDir;
829
+ await fs.mkdir(targetDir, { recursive: true });
830
+
831
+ const results = [];
832
+
833
+ for (const toolId of selection.selectedToolIds) {
834
+ const tool = findTool(catalog, toolId);
835
+ if (!tool) {
836
+ throw new Error(`카탈로그에 없는 tool: ${toolId}`);
837
+ }
838
+
839
+ const toolDestRoot = path.join(targetDir, tool.root);
840
+ await fs.mkdir(toolDestRoot, { recursive: true });
841
+
842
+ const readmeSrc = path.join(rootDir, tool.root, tool.readme);
843
+ const readmeDst = path.join(toolDestRoot, tool.readme);
844
+ const readmeResult = await copyEntry(readmeSrc, readmeDst, { overwrite, dryRun });
845
+ results.push({
846
+ toolId,
847
+ entry: `${tool.root}/${tool.readme}`,
848
+ status: readmeResult.status
849
+ });
850
+
851
+ const componentIds = selection.componentSelection[toolId] ?? [];
852
+ for (const componentId of componentIds) {
853
+ const component = tool.components.find((c) => c.id === componentId);
854
+ if (!component) {
855
+ throw new Error(`[${tool.id}] 알 수 없는 구성요소: ${componentId}`);
856
+ }
857
+
858
+ const src = path.join(rootDir, tool.root, component.path);
859
+ const dst = path.join(targetDir, tool.root, component.path);
860
+ const result = await copyEntry(src, dst, { overwrite, dryRun });
861
+ results.push({
862
+ toolId,
863
+ entry: `${tool.root}/${component.path}`,
864
+ status: result.status
865
+ });
866
+ }
867
+ }
868
+
869
+ console.log('');
870
+ console.log('적용 결과');
871
+ for (const item of results) {
872
+ console.log(`- [${item.toolId}] ${item.entry}: ${statusLabel(item.status)}`);
873
+ }
874
+
875
+ if (!dryRun) {
876
+ await writeState({ targetDir, packageData, mode, selection });
877
+ console.log('');
878
+ console.log(`상태 파일 저장: ${path.join(targetDir, STATE_DIR_NAME, 'state.json')}`);
879
+ }
880
+ }
881
+
882
+ function printList(catalog) {
883
+ console.log('');
884
+ console.log(`${CLI_NAME} 카탈로그 (한글)`);
885
+ console.log('');
886
+ for (const tool of catalog.tools) {
887
+ console.log(`- ${tool.title} (${tool.id})`);
888
+ console.log(` 설명: ${tool.description}`);
889
+ console.log(' 구성요소:');
890
+ for (const component of tool.components) {
891
+ console.log(` - ${component.title} (${component.id}): ${component.description}`);
892
+ }
893
+ console.log('');
894
+ }
895
+
896
+ const presets = Array.isArray(catalog.presets) ? catalog.presets : [];
897
+ if (presets.length > 0) {
898
+ console.log('프리셋 목록');
899
+ for (const preset of presets) {
900
+ console.log(`- ${preset.title} (${preset.id})`);
901
+ console.log(` 설명: ${preset.description}`);
902
+ }
903
+ console.log('');
904
+ }
905
+
906
+ console.log('권장 UX 흐름');
907
+ console.log('1) pnpm dlx tri-agent-manager --interactive 로 시작합니다.');
908
+ console.log('2) 설치 후 state.json을 기준으로 update를 수행한다.');
909
+ console.log('3) update 시 필요한 도구만 부분 갱신한다.');
910
+ }
911
+
912
+ async function main() {
913
+ const rawArgs = process.argv.slice(2);
914
+ if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
915
+ console.log(HELP_TEXT);
916
+ return;
917
+ }
918
+
919
+ const options = parseArgs(rawArgs);
920
+ let command = options.command;
921
+ if (command === 'setup' || command === 'wizard') {
922
+ options.interactive = true;
923
+ command = 'install';
924
+ }
925
+ if (command === 'init') {
926
+ command = 'install';
927
+ }
928
+
929
+ if (command === 'help' || command === '--help' || command === '-h') {
930
+ console.log(HELP_TEXT);
931
+ return;
932
+ }
933
+
934
+ const catalog = await readJson(catalogPath);
935
+ const packageData = await readJson(packageJsonPath);
936
+
937
+ if (command === 'list') {
938
+ printList(catalog);
939
+ return;
940
+ }
941
+
942
+ if (command !== 'install' && command !== 'update') {
943
+ console.error(`지원하지 않는 명령: ${command}`);
944
+ console.error('');
945
+ console.error(HELP_TEXT);
946
+ process.exit(1);
947
+ }
948
+
949
+ const shouldUseInteractive =
950
+ !options.nonInteractive &&
951
+ (options.interactive || (!options.toolFlag && !options.presetFlag && process.stdin.isTTY));
952
+
953
+ const selection = shouldUseInteractive
954
+ ? await promptInteractive({
955
+ catalog,
956
+ mode: command,
957
+ defaultTarget: options.targetFlag,
958
+ presetFlag: options.presetFlag
959
+ })
960
+ : buildSelectionFromFlags({
961
+ catalog,
962
+ mode: command,
963
+ presetFlag: options.presetFlag,
964
+ toolFlag: options.toolFlag,
965
+ componentFlag: options.componentFlag,
966
+ targetFlag: options.targetFlag
967
+ });
968
+
969
+ printPlan(catalog, selection, command, selection.targetDir);
970
+
971
+ const proceed = await confirmProceed(options.yes);
972
+ if (!proceed) {
973
+ console.log('작업을 취소했습니다.');
974
+ return;
975
+ }
976
+
977
+ await runInstallOrUpdate({
978
+ catalog,
979
+ packageData,
980
+ mode: command,
981
+ selection,
982
+ force: options.force,
983
+ dryRun: options.dryRun
984
+ });
985
+
986
+ console.log('');
987
+ console.log('완료: 선택한 운영팩 반영이 끝났습니다.');
988
+ }
989
+
990
+ main().catch((error) => {
991
+ console.error(`오류: ${error.message}`);
992
+ process.exit(1);
993
+ });