relayax-cli 0.4.34 → 0.4.36

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.
@@ -34,7 +34,13 @@ export declare function computeContentsDiff(contents: ContentEntry[], relayDir:
34
34
  /**
35
35
  * contents 항목 단위로 from → .relay/ 동기화한다.
36
36
  */
37
- export declare function syncContentsToRelay(contents: ContentEntry[], contentsDiff: ContentDiffEntry[], relayDir: string, projectPath: string): void;
37
+ export declare function syncContentsToRelay(contents: ContentEntry[], contentsDiff: ContentDiffEntry[], relayDir: string, projectPath: string): {
38
+ removed: string[];
39
+ };
40
+ /**
41
+ * .relay/ 내에 있지만 contents 매니페스트에 없는 orphan 항목을 찾는다.
42
+ */
43
+ export declare function findOrphanItems(contents: ContentEntry[], relayDir: string): string[];
38
44
  /**
39
45
  * 패키지 홈 디렉토리를 결정한다.
40
46
  * 1. 프로젝트에 .relay/가 있으면 → projectPath/.relay/
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.computeContentsDiff = computeContentsDiff;
7
7
  exports.syncContentsToRelay = syncContentsToRelay;
8
+ exports.findOrphanItems = findOrphanItems;
8
9
  exports.resolveRelayDir = resolveRelayDir;
9
10
  exports.initGlobalAgentHome = initGlobalAgentHome;
10
11
  exports.registerPackage = registerPackage;
@@ -214,27 +215,60 @@ function discoverNewItems(contents, projectPath) {
214
215
  * contents 항목 단위로 from → .relay/ 동기화한다.
215
216
  */
216
217
  function syncContentsToRelay(contents, contentsDiff, relayDir, projectPath) {
218
+ const removed = [];
217
219
  for (const diffEntry of contentsDiff) {
220
+ const content = contents.find((c) => c.name === diffEntry.name && c.type === diffEntry.type);
221
+ // source_missing: 소스에서 삭제됨 → .relay/에서도 제거
222
+ if (diffEntry.status === 'source_missing') {
223
+ const relaySubPath = content ? deriveRelaySubPath(content) : `${diffEntry.type}s/${diffEntry.name}`;
224
+ const relayTarget = path_1.default.join(relayDir, relaySubPath);
225
+ if (fs_1.default.existsSync(relayTarget)) {
226
+ fs_1.default.rmSync(relayTarget, { recursive: true, force: true });
227
+ removed.push(relaySubPath);
228
+ }
229
+ continue;
230
+ }
218
231
  if (diffEntry.status !== 'modified')
219
232
  continue;
220
- const content = contents.find((c) => c.name === diffEntry.name && c.type === diffEntry.type);
221
233
  if (!content)
222
234
  continue;
223
235
  const absFrom = resolveFromPath(getFromPath(content), projectPath);
224
236
  const relaySubPath = deriveRelaySubPath(content);
225
237
  const relayTarget = path_1.default.join(relayDir, relaySubPath);
226
- // 단일 파일인 경우 직접 복사 (디렉토리 기반 diff/sync 불필요)
238
+ // 단일 파일인 경우 직접 복사
227
239
  if (fs_1.default.existsSync(absFrom) && fs_1.default.statSync(absFrom).isFile()) {
228
240
  fs_1.default.mkdirSync(path_1.default.dirname(relayTarget), { recursive: true });
229
241
  fs_1.default.copyFileSync(absFrom, relayTarget);
230
242
  continue;
231
243
  }
232
- // 디렉토리인 경우 diff 기반 동기화
244
+ // 디렉토리인 경우 diff 기반 동기화 (deleted 포함)
233
245
  const sourceFiles = scanPath(absFrom);
234
246
  const relayFiles = scanPath(relayTarget);
235
247
  const fileDiff = computeDiff(sourceFiles, relayFiles);
236
248
  syncToRelay(absFrom, relayTarget, fileDiff);
237
249
  }
250
+ return { removed };
251
+ }
252
+ /**
253
+ * .relay/ 내에 있지만 contents 매니페스트에 없는 orphan 항목을 찾는다.
254
+ */
255
+ function findOrphanItems(contents, relayDir) {
256
+ const contentPaths = new Set(contents.map((c) => deriveRelaySubPath(c)));
257
+ const orphans = [];
258
+ for (const dir of SYNC_DIRS) {
259
+ const fullDir = path_1.default.join(relayDir, dir);
260
+ if (!fs_1.default.existsSync(fullDir))
261
+ continue;
262
+ for (const entry of fs_1.default.readdirSync(fullDir, { withFileTypes: true })) {
263
+ if (entry.name.startsWith('.'))
264
+ continue;
265
+ const relPath = `${dir}/${entry.name}`;
266
+ if (!contentPaths.has(relPath)) {
267
+ orphans.push(relPath);
268
+ }
269
+ }
270
+ }
271
+ return orphans;
238
272
  }
239
273
  // ─── Global Agent Home ───
240
274
  /**
@@ -462,20 +496,31 @@ function registerPackage(program) {
462
496
  }
463
497
  // contents 기반 diff 계산
464
498
  const { diff: contentsDiff, newItems } = computeContentsDiff(contents, relayDir, projectPath);
499
+ const orphans = findOrphanItems(contents, relayDir);
465
500
  const summary = {
466
501
  modified: contentsDiff.filter((d) => d.status === 'modified').length,
467
502
  unchanged: contentsDiff.filter((d) => d.status === 'unchanged').length,
468
503
  source_missing: contentsDiff.filter((d) => d.status === 'source_missing').length,
469
504
  new_available: newItems.length,
505
+ orphaned: orphans.length,
470
506
  };
471
- const hasChanges = summary.modified > 0;
472
- // --sync: contents 단위 동기화
507
+ const hasChanges = summary.modified > 0 || summary.source_missing > 0 || summary.orphaned > 0;
508
+ // --sync: contents 단위 동기화 + orphan 정리
473
509
  if (opts.sync && hasChanges) {
474
- syncContentsToRelay(contents, contentsDiff, relayDir, projectPath);
510
+ const { removed } = syncContentsToRelay(contents, contentsDiff, relayDir, projectPath);
511
+ // orphan 항목 삭제
512
+ for (const orphan of orphans) {
513
+ const orphanPath = path_1.default.join(relayDir, orphan);
514
+ if (fs_1.default.existsSync(orphanPath)) {
515
+ fs_1.default.rmSync(orphanPath, { recursive: true, force: true });
516
+ removed.push(orphan);
517
+ }
518
+ }
475
519
  }
476
520
  const result = {
477
521
  diff: contentsDiff.filter((d) => d.status !== 'unchanged'),
478
522
  new_items: newItems,
523
+ orphans,
479
524
  synced: opts.sync === true && hasChanges,
480
525
  summary,
481
526
  };
@@ -499,6 +544,12 @@ function registerPackage(program) {
499
544
  }
500
545
  }
501
546
  }
547
+ if (orphans.length > 0) {
548
+ console.error('\n \x1b[33m.relay/에만 존재 (소스에서 삭제됨):\x1b[0m');
549
+ for (const orphan of orphans) {
550
+ console.error(` \x1b[31m✗ ${orphan}\x1b[0m`);
551
+ }
552
+ }
502
553
  if (newItems.length > 0) {
503
554
  console.error('\n 새로 발견된 콘텐츠:');
504
555
  for (const item of newItems) {
@@ -506,7 +557,7 @@ function registerPackage(program) {
506
557
  }
507
558
  }
508
559
  console.error('');
509
- console.error(` 합계: 변경 ${summary.modified}, 유지 ${summary.unchanged}, 원본 없음 ${summary.source_missing}, 신규 ${summary.new_available}`);
560
+ console.error(` 합계: 변경 ${summary.modified}, 유지 ${summary.unchanged}, 원본 없음 ${summary.source_missing}, 신규 ${summary.new_available}, 고아 ${summary.orphaned}`);
510
561
  if (opts.sync) {
511
562
  console.error('\n✓ .relay/에 반영 완료');
512
563
  }
@@ -16,6 +16,7 @@ const error_report_js_1 = require("../lib/error-report.js");
16
16
  const step_tracker_js_1 = require("../lib/step-tracker.js");
17
17
  const git_operations_js_1 = require("../lib/git-operations.js");
18
18
  const setup_command_js_1 = require("../lib/setup-command.js");
19
+ const requires_suggest_js_1 = require("../lib/requires-suggest.js");
19
20
  // eslint-disable-next-line @typescript-eslint/no-var-requires
20
21
  const cliPkg = require('../../package.json');
21
22
  const VALID_DIRS = ['skills', 'agents', 'rules', 'commands', 'bin'];
@@ -681,6 +682,28 @@ function registerPublish(program) {
681
682
  console.error(` → relay.yaml에 visibility: ${config.visibility} 저장됨 (${visLabelMap[config.visibility]})\n`);
682
683
  }
683
684
  }
685
+ // ── Requires 자동 감지 + 제안 ──
686
+ if (isTTY && !json) {
687
+ const suggestions = (0, requires_suggest_js_1.suggestRequires)(relayDir, config.requires);
688
+ if (suggestions.length > 0) {
689
+ console.error('\n\x1b[33m⚡ requires에 빠진 항목이 감지되었습니다:\x1b[0m');
690
+ for (const line of (0, requires_suggest_js_1.formatSuggestions)(suggestions)) {
691
+ console.error(line);
692
+ }
693
+ const { confirm } = await import('@inquirer/prompts');
694
+ const addThem = await confirm({
695
+ message: 'requires에 추가할까요?',
696
+ default: true,
697
+ });
698
+ if (addThem) {
699
+ config.requires = (0, requires_suggest_js_1.mergeIntoRequires)(config.requires ?? {}, suggestions);
700
+ const yamlData = js_yaml_1.default.load(fs_1.default.readFileSync(relayYamlPath, 'utf-8'));
701
+ yamlData.requires = config.requires;
702
+ fs_1.default.writeFileSync(relayYamlPath, js_yaml_1.default.dump(yamlData, { lineWidth: 120 }), 'utf-8');
703
+ console.error(' → relay.yaml에 requires 업데이트됨\n');
704
+ }
705
+ }
706
+ }
684
707
  // Generate setup command BEFORE detectCommands so it's included in metadata
685
708
  {
686
709
  const commandsDir = path_1.default.join(relayDir, 'commands');
@@ -0,0 +1,23 @@
1
+ import type { Requires } from '../commands/publish.js';
2
+ interface Suggestion {
3
+ category: 'env' | 'cli' | 'pip' | 'runtime';
4
+ name: string;
5
+ source: string;
6
+ description?: string;
7
+ setup_hint?: string;
8
+ install?: string;
9
+ required?: boolean;
10
+ }
11
+ /**
12
+ * .relay/ 디렉토리를 스캔하여 requires에 빠진 항목을 제안한다.
13
+ */
14
+ export declare function suggestRequires(relayDir: string, currentRequires?: Requires): Suggestion[];
15
+ /**
16
+ * 제안 항목을 사람이 읽기 좋은 형태로 포맷한다.
17
+ */
18
+ export declare function formatSuggestions(suggestions: Suggestion[]): string[];
19
+ /**
20
+ * 제안 항목을 requires 객체에 병합한다.
21
+ */
22
+ export declare function mergeIntoRequires(requires: Requires, suggestions: Suggestion[]): Requires;
23
+ export {};
@@ -0,0 +1,295 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.suggestRequires = suggestRequires;
7
+ exports.formatSuggestions = formatSuggestions;
8
+ exports.mergeIntoRequires = mergeIntoRequires;
9
+ const fs_1 = __importDefault(require("fs"));
10
+ const path_1 = __importDefault(require("path"));
11
+ // Python stdlib — 이 목록에 없는 import는 pip 패키지 후보
12
+ const PYTHON_STDLIB = new Set([
13
+ 'abc', 'argparse', 'ast', 'asyncio', 'base64', 'bisect', 'calendar',
14
+ 'cmath', 'codecs', 'collections', 'concurrent', 'contextlib', 'copy',
15
+ 'csv', 'ctypes', 'dataclasses', 'datetime', 'decimal', 'difflib',
16
+ 'email', 'enum', 'errno', 'filecmp', 'fnmatch', 'fractions', 'ftplib',
17
+ 'functools', 'gc', 'gettext', 'glob', 'gzip', 'hashlib', 'heapq',
18
+ 'hmac', 'html', 'http', 'imaplib', 'importlib', 'inspect', 'io',
19
+ 'itertools', 'json', 'keyword', 'linecache', 'locale', 'logging',
20
+ 'lzma', 'math', 'mimetypes', 'multiprocessing', 'numbers', 'operator',
21
+ 'os', 'pathlib', 'pickle', 'platform', 'plistlib', 'pprint',
22
+ 'profile', 'pstats', 'queue', 'random', 're', 'readline',
23
+ 'reprlib', 'resource', 'rlcompleter', 'sched', 'secrets', 'select',
24
+ 'shelve', 'shlex', 'shutil', 'signal', 'site', 'smtplib', 'socket',
25
+ 'socketserver', 'sqlite3', 'ssl', 'stat', 'statistics', 'string',
26
+ 'struct', 'subprocess', 'sys', 'sysconfig', 'syslog', 'tempfile',
27
+ 'textwrap', 'threading', 'time', 'timeit', 'token', 'tokenize',
28
+ 'tomllib', 'trace', 'traceback', 'tracemalloc', 'types', 'typing',
29
+ 'unicodedata', 'unittest', 'urllib', 'uuid', 'venv', 'warnings',
30
+ 'wave', 'weakref', 'webbrowser', 'xml', 'xmlrpc', 'zipfile', 'zipimport', 'zlib',
31
+ // 자주 쓰이는 서브모듈
32
+ 'os.path', 'collections.abc', 'concurrent.futures', 'urllib.parse',
33
+ 'http.client', 'http.server', 'email.mime',
34
+ ]);
35
+ // pip 패키지명 → import 이름 매핑 (다른 경우만)
36
+ const PIP_IMPORT_MAP = {
37
+ 'Pillow': 'PIL',
38
+ 'google-genai': 'google',
39
+ 'scikit-learn': 'sklearn',
40
+ 'python-dotenv': 'dotenv',
41
+ 'beautifulsoup4': 'bs4',
42
+ 'opencv-python': 'cv2',
43
+ 'pyyaml': 'yaml',
44
+ };
45
+ // import 이름 → pip 패키지명 역매핑
46
+ const IMPORT_TO_PIP = {};
47
+ for (const [pip, imp] of Object.entries(PIP_IMPORT_MAP)) {
48
+ IMPORT_TO_PIP[imp] = pip;
49
+ }
50
+ /**
51
+ * .relay/ 디렉토리를 스캔하여 requires에 빠진 항목을 제안한다.
52
+ */
53
+ function suggestRequires(relayDir, currentRequires) {
54
+ const suggestions = [];
55
+ const existing = normalizeExisting(currentRequires);
56
+ // 모든 스크립트 파일 수집
57
+ const scripts = findScripts(relayDir);
58
+ for (const scriptPath of scripts) {
59
+ const content = fs_1.default.readFileSync(scriptPath, 'utf-8');
60
+ const relName = path_1.default.relative(relayDir, scriptPath);
61
+ const ext = path_1.default.extname(scriptPath);
62
+ // Python 스크립트
63
+ if (ext === '.py') {
64
+ // 환경변수 감지: os.environ.get("VAR") / os.environ["VAR"] / os.getenv("VAR")
65
+ const envPattern = /os\.environ\.get\(\s*['"](\w+)['"]/g;
66
+ const envPattern2 = /os\.environ\[['"](\w+)['"]\]/g;
67
+ const envPattern3 = /os\.getenv\(\s*['"](\w+)['"]/g;
68
+ for (const pattern of [envPattern, envPattern2, envPattern3]) {
69
+ let match;
70
+ while ((match = pattern.exec(content)) !== null) {
71
+ const varName = match[1];
72
+ if (!existing.envNames.has(varName)) {
73
+ suggestions.push({ category: 'env', name: varName, source: relName });
74
+ }
75
+ }
76
+ }
77
+ // pip 패키지 감지: import xxx / from xxx import
78
+ const importPattern = /^(?:import|from)\s+(\w+)/gm;
79
+ let match;
80
+ while ((match = importPattern.exec(content)) !== null) {
81
+ const moduleName = match[1];
82
+ if (PYTHON_STDLIB.has(moduleName))
83
+ continue;
84
+ const pipName = IMPORT_TO_PIP[moduleName] ?? moduleName;
85
+ if (!existing.pipNames.has(pipName) && !existing.pipNames.has(moduleName)) {
86
+ suggestions.push({ category: 'pip', name: pipName, source: relName });
87
+ }
88
+ }
89
+ // shebang → python3 런타임
90
+ if (content.startsWith('#!/') && content.includes('python')) {
91
+ if (!existing.hasRuntime.python) {
92
+ suggestions.push({ category: 'runtime', name: 'python', source: relName });
93
+ }
94
+ }
95
+ }
96
+ // Node/TS 스크립트
97
+ if (ext === '.js' || ext === '.ts' || ext === '.mjs') {
98
+ // 환경변수 감지: process.env.VAR / process.env['VAR']
99
+ const envPattern = /process\.env\.(\w+)/g;
100
+ const envPattern2 = /process\.env\[['"](\w+)['"]\]/g;
101
+ for (const pattern of [envPattern, envPattern2]) {
102
+ let match;
103
+ while ((match = pattern.exec(content)) !== null) {
104
+ const varName = match[1];
105
+ if (['NODE_ENV', 'PATH', 'HOME', 'PWD'].includes(varName))
106
+ continue;
107
+ if (!existing.envNames.has(varName)) {
108
+ suggestions.push({ category: 'env', name: varName, source: relName });
109
+ }
110
+ }
111
+ }
112
+ }
113
+ }
114
+ // setup-* 스킬 감지: 관련 env가 requires에 없으면 제안
115
+ const skillsDir = path_1.default.join(relayDir, 'skills');
116
+ if (fs_1.default.existsSync(skillsDir)) {
117
+ for (const entry of fs_1.default.readdirSync(skillsDir, { withFileTypes: true })) {
118
+ if (entry.isDirectory() && entry.name.startsWith('setup-')) {
119
+ const setupName = entry.name; // e.g., setup-kling
120
+ const skillMd = path_1.default.join(skillsDir, setupName, 'SKILL.md');
121
+ if (fs_1.default.existsSync(skillMd)) {
122
+ const skillContent = fs_1.default.readFileSync(skillMd, 'utf-8');
123
+ // SKILL.md에서 환경변수 참조 찾기
124
+ const envRefs = /[A-Z][A-Z0-9_]{2,}/g;
125
+ let match;
126
+ while ((match = envRefs.exec(skillContent)) !== null) {
127
+ const varName = match[0];
128
+ if (['SKILL', 'WHEN', 'THEN', 'SHALL', 'MUST', 'TODO', 'NOTE', 'IMPORTANT', 'WARNING', 'ERROR', 'JSON', 'API', 'URL', 'HTTP', 'HTTPS', 'MCP', 'CLI', 'SDK', 'README', 'YAML'].includes(varName))
129
+ continue;
130
+ if (varName.endsWith('_KEY') || varName.endsWith('_TOKEN') || varName.endsWith('_SECRET') || varName.endsWith('_SESSION')) {
131
+ if (!existing.envNames.has(varName)) {
132
+ suggestions.push({
133
+ category: 'env',
134
+ name: varName,
135
+ source: `skills/${setupName}/SKILL.md`,
136
+ setup_hint: `/${setupName} 스킬을 실행하세요`,
137
+ required: false,
138
+ });
139
+ }
140
+ }
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
146
+ // CLI 도구 감지: subprocess.run(['cmd', ...]) / execSync('cmd ...')
147
+ for (const scriptPath of scripts) {
148
+ const content = fs_1.default.readFileSync(scriptPath, 'utf-8');
149
+ const relName = path_1.default.relative(relayDir, scriptPath);
150
+ // Python subprocess
151
+ const subprocessPattern = /subprocess\.(?:run|Popen|call)\(\s*\[?\s*['"](\w+)['"]/g;
152
+ let match;
153
+ while ((match = subprocessPattern.exec(content)) !== null) {
154
+ const cmd = match[1];
155
+ if (['python', 'python3', 'node', 'npm', 'npx'].includes(cmd))
156
+ continue;
157
+ if (!existing.cliNames.has(cmd)) {
158
+ suggestions.push({ category: 'cli', name: cmd, source: relName });
159
+ }
160
+ }
161
+ // Node execSync
162
+ const execPattern = /execSync\(\s*['"`](\w+)/g;
163
+ while ((match = execPattern.exec(content)) !== null) {
164
+ const cmd = match[1];
165
+ if (['node', 'npm', 'npx', 'git'].includes(cmd))
166
+ continue;
167
+ if (!existing.cliNames.has(cmd)) {
168
+ suggestions.push({ category: 'cli', name: cmd, source: relName });
169
+ }
170
+ }
171
+ }
172
+ // 중복 제거
173
+ const seen = new Set();
174
+ return suggestions.filter((s) => {
175
+ const key = `${s.category}:${s.name}`;
176
+ if (seen.has(key))
177
+ return false;
178
+ seen.add(key);
179
+ return true;
180
+ });
181
+ }
182
+ function normalizeExisting(requires) {
183
+ const envNames = new Set();
184
+ const cliNames = new Set();
185
+ const pipNames = new Set();
186
+ const npmNames = new Set();
187
+ const hasRuntime = { node: false, python: false };
188
+ if (!requires)
189
+ return { envNames, cliNames, pipNames, npmNames, hasRuntime };
190
+ for (const e of requires.env ?? [])
191
+ envNames.add(e.name);
192
+ for (const c of requires.cli ?? [])
193
+ cliNames.add(c.name);
194
+ for (const n of requires.npm ?? [])
195
+ npmNames.add(typeof n === 'string' ? n : n.name);
196
+ // pip은 Requires 타입에 없지만 relay.yaml에 존재할 수 있음
197
+ const raw = requires;
198
+ if (Array.isArray(raw.pip)) {
199
+ for (const p of raw.pip)
200
+ pipNames.add(typeof p === 'string' ? p : p.name);
201
+ }
202
+ if (requires.runtime?.node)
203
+ hasRuntime.node = true;
204
+ if (requires.runtime?.python)
205
+ hasRuntime.python = true;
206
+ return { envNames, cliNames, pipNames, npmNames, hasRuntime };
207
+ }
208
+ function findScripts(dir) {
209
+ const scripts = [];
210
+ if (!fs_1.default.existsSync(dir))
211
+ return scripts;
212
+ function walk(d) {
213
+ for (const entry of fs_1.default.readdirSync(d, { withFileTypes: true })) {
214
+ if (entry.name.startsWith('.'))
215
+ continue;
216
+ const full = path_1.default.join(d, entry.name);
217
+ if (entry.isDirectory()) {
218
+ walk(full);
219
+ }
220
+ else if (/\.(py|js|ts|mjs|sh)$/.test(entry.name)) {
221
+ scripts.push(full);
222
+ }
223
+ }
224
+ }
225
+ walk(dir);
226
+ return scripts;
227
+ }
228
+ /**
229
+ * 제안 항목을 사람이 읽기 좋은 형태로 포맷한다.
230
+ */
231
+ function formatSuggestions(suggestions) {
232
+ const lines = [];
233
+ const envs = suggestions.filter((s) => s.category === 'env');
234
+ const clis = suggestions.filter((s) => s.category === 'cli');
235
+ const pips = suggestions.filter((s) => s.category === 'pip');
236
+ const runtimes = suggestions.filter((s) => s.category === 'runtime');
237
+ if (envs.length > 0) {
238
+ lines.push(' 환경변수:');
239
+ for (const e of envs) {
240
+ const hint = e.setup_hint ? ` (${e.setup_hint})` : '';
241
+ lines.push(` ${e.name}${hint} — ${e.source}에서 감지`);
242
+ }
243
+ }
244
+ if (clis.length > 0) {
245
+ lines.push(' CLI 도구:');
246
+ for (const c of clis)
247
+ lines.push(` ${c.name} — ${c.source}에서 감지`);
248
+ }
249
+ if (pips.length > 0) {
250
+ lines.push(' pip 패키지:');
251
+ for (const p of pips)
252
+ lines.push(` ${p.name} — ${p.source}에서 감지`);
253
+ }
254
+ if (runtimes.length > 0) {
255
+ lines.push(' 런타임:');
256
+ for (const r of runtimes)
257
+ lines.push(` ${r.name} — ${r.source}에서 감지`);
258
+ }
259
+ return lines;
260
+ }
261
+ /**
262
+ * 제안 항목을 requires 객체에 병합한다.
263
+ */
264
+ function mergeIntoRequires(requires, suggestions) {
265
+ const result = { ...requires };
266
+ for (const s of suggestions) {
267
+ if (s.category === 'env') {
268
+ if (!result.env)
269
+ result.env = [];
270
+ result.env.push({
271
+ name: s.name,
272
+ required: s.required ?? true,
273
+ description: s.description,
274
+ setup_hint: s.setup_hint,
275
+ });
276
+ }
277
+ else if (s.category === 'cli') {
278
+ if (!result.cli)
279
+ result.cli = [];
280
+ result.cli.push({
281
+ name: s.name,
282
+ install: s.install,
283
+ });
284
+ }
285
+ else if (s.category === 'runtime') {
286
+ if (!result.runtime)
287
+ result.runtime = {};
288
+ if (s.name === 'python')
289
+ result.runtime.python = '>=3.8';
290
+ if (s.name === 'node')
291
+ result.runtime.node = '>=18';
292
+ }
293
+ }
294
+ return result;
295
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relayax-cli",
3
- "version": "0.4.34",
3
+ "version": "0.4.36",
4
4
  "description": "RelayAX Agent Team Marketplace CLI - Install and manage agent teams",
5
5
  "main": "dist/index.js",
6
6
  "bin": {