relayax-cli 0.3.58 → 0.3.61

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.
@@ -1,10 +1,39 @@
1
1
  import { Command } from 'commander';
2
+ type DiffStatus = 'added' | 'modified' | 'deleted' | 'unchanged';
3
+ interface DiffEntry {
4
+ relPath: string;
5
+ status: DiffStatus;
6
+ }
2
7
  import type { ContentType } from '../lib/ai-tools.js';
3
8
  export interface ContentEntry {
4
9
  name: string;
5
10
  type: ContentType;
6
11
  from: string;
7
12
  }
13
+ type ContentDiffStatus = 'modified' | 'unchanged' | 'source_missing';
14
+ interface ContentDiffEntry {
15
+ name: string;
16
+ type: ContentType;
17
+ status: ContentDiffStatus;
18
+ files?: DiffEntry[];
19
+ }
20
+ interface NewItemEntry {
21
+ name: string;
22
+ type: ContentType;
23
+ source: string;
24
+ relativePath: string;
25
+ }
26
+ /**
27
+ * contents 매니페스트 기반으로 각 항목의 원본과 .relay/ 복사본을 비교한다.
28
+ */
29
+ export declare function computeContentsDiff(contents: ContentEntry[], relayDir: string, projectPath: string): {
30
+ diff: ContentDiffEntry[];
31
+ newItems: NewItemEntry[];
32
+ };
33
+ /**
34
+ * contents 항목 단위로 from → .relay/ 동기화한다.
35
+ */
36
+ export declare function syncContentsToRelay(contents: ContentEntry[], contentsDiff: ContentDiffEntry[], relayDir: string, projectPath: string): void;
8
37
  /**
9
38
  * 패키지 홈 디렉토리를 결정한다.
10
39
  * 1. 프로젝트에 .relay/가 있으면 → projectPath/.relay/
@@ -18,3 +47,4 @@ export declare function resolveRelayDir(projectPath: string, slug?: string): str
18
47
  */
19
48
  export declare function initGlobalAgentHome(slug: string, yamlData: Record<string, unknown>): string;
20
49
  export declare function registerPackage(program: Command): void;
50
+ export {};
@@ -3,6 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.computeContentsDiff = computeContentsDiff;
7
+ exports.syncContentsToRelay = syncContentsToRelay;
6
8
  exports.resolveRelayDir = resolveRelayDir;
7
9
  exports.initGlobalAgentHome = initGlobalAgentHome;
8
10
  exports.registerPackage = registerPackage;
@@ -23,18 +23,18 @@ const js_yaml_1 = __importDefault(require("js-yaml"));
23
23
  // eslint-disable-next-line @typescript-eslint/no-var-requires
24
24
  const pkg = require('../../package.json');
25
25
  // ─── Helpers ───
26
- async function resolveUsername(token) {
26
+ async function resolveUserInfo(token) {
27
27
  try {
28
28
  const res = await fetch(`${config_js_1.API_URL}/api/auth/me`, {
29
29
  headers: { Authorization: `Bearer ${token}` },
30
30
  });
31
31
  if (!res.ok)
32
- return undefined;
32
+ return {};
33
33
  const body = await res.json();
34
- return body.username;
34
+ return { username: body.username, email: body.email };
35
35
  }
36
36
  catch {
37
- return undefined;
37
+ return {};
38
38
  }
39
39
  }
40
40
  function countFiles(dir) {
@@ -208,8 +208,12 @@ function createMcpServer() {
208
208
  const projectPath = resolveMcpProjectPath(project_path);
209
209
  const token = await (0, config_js_1.getValidToken)();
210
210
  let username;
211
- if (token)
212
- username = await resolveUsername(token);
211
+ let email;
212
+ if (token) {
213
+ const info = await resolveUserInfo(token);
214
+ username = info.username;
215
+ email = info.email;
216
+ }
213
217
  const detected = (0, ai_tools_js_1.detectAgentCLIs)(projectPath);
214
218
  const mounted = (0, ai_tools_js_1.detectMountedCLIs)();
215
219
  const relayYaml = path_1.default.join(projectPath, '.relay', 'relay.yaml');
@@ -226,7 +230,7 @@ function createMcpServer() {
226
230
  const cliUpdate = await checkCliVersion(true);
227
231
  return { content: [jsonText({
228
232
  cli: { version: pkg.version, update_available: cliUpdate ? cliUpdate.latest : null },
229
- login: { authenticated: !!token, username },
233
+ login: { authenticated: !!token, username, email },
230
234
  agent_clis: detected.map((t) => t.name),
231
235
  mounted_paths: mounted.map((m) => m.basePath),
232
236
  project,
@@ -291,6 +295,108 @@ function createMcpServer() {
291
295
  }
292
296
  return { content: [jsonText({ sources })] };
293
297
  });
298
+ server.tool('relay_package', '소스 디렉토리에서 .relay/로 콘텐츠를 패키징합니다. mode: init(최초 소스 탐색), sync(변경 반영), migrate(source→contents 마이그레이션)', {
299
+ mode: zod_1.z.enum(['init', 'sync', 'migrate']).describe('패키징 모드'),
300
+ project_path: zod_1.z.string().optional().describe('프로젝트 경로'),
301
+ }, async ({ mode, project_path }) => {
302
+ try {
303
+ const projectPath = resolveMcpProjectPath(project_path);
304
+ const homeDir = (0, paths_js_1.resolveHome)();
305
+ const relayDir = path_1.default.join(projectPath, '.relay');
306
+ const relayYamlPath = path_1.default.join(relayDir, 'relay.yaml');
307
+ if (mode === 'init') {
308
+ // 최초 패키징: 소스 탐색
309
+ const { detectGlobalCLIs } = await import('../lib/ai-tools.js');
310
+ const localTools = (0, ai_tools_js_1.detectAgentCLIs)(projectPath);
311
+ const globalTools = detectGlobalCLIs(homeDir);
312
+ const sources = [];
313
+ for (const tool of localTools) {
314
+ const items = (0, ai_tools_js_1.scanLocalItems)(projectPath, tool);
315
+ if (items.length > 0)
316
+ sources.push({ path: tool.skillsDir, location: 'local', name: tool.name, items: items.map((i) => ({ name: i.name, type: i.type, relativePath: i.relativePath })) });
317
+ }
318
+ for (const tool of globalTools) {
319
+ const items = (0, ai_tools_js_1.scanGlobalItems)(tool, homeDir);
320
+ if (items.length > 0)
321
+ sources.push({ path: `~/${tool.skillsDir}`, location: 'global', name: `${tool.name} (global)`, items: items.map((i) => ({ name: i.name, type: i.type, relativePath: i.relativePath })) });
322
+ }
323
+ for (const { tool, basePath } of (0, ai_tools_js_1.detectMountedCLIs)()) {
324
+ const items = (0, ai_tools_js_1.scanMountedItems)(basePath, tool);
325
+ if (items.length > 0)
326
+ sources.push({ path: `${basePath}/${tool.skillsDir}`, location: 'mounted', name: `${tool.name} (mounted)`, items: items.map((i) => ({ name: i.name, type: i.type, relativePath: i.relativePath })) });
327
+ }
328
+ // 기존 글로벌 에이전트 패키지 스캔
329
+ const globalAgentsDir = path_1.default.join(homeDir, '.relay', 'agents');
330
+ const existingAgents = [];
331
+ if (fs_1.default.existsSync(globalAgentsDir)) {
332
+ for (const entry of fs_1.default.readdirSync(globalAgentsDir, { withFileTypes: true })) {
333
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
334
+ continue;
335
+ const agentYaml = path_1.default.join(globalAgentsDir, entry.name, 'relay.yaml');
336
+ if (fs_1.default.existsSync(agentYaml)) {
337
+ try {
338
+ const cfg = js_yaml_1.default.load(fs_1.default.readFileSync(agentYaml, 'utf-8'));
339
+ existingAgents.push({ slug: cfg.slug ?? entry.name, name: cfg.name ?? entry.name, version: cfg.version ?? '0.0.0', path: `~/.relay/agents/${entry.name}` });
340
+ }
341
+ catch { /* skip */ }
342
+ }
343
+ }
344
+ }
345
+ return { content: [jsonText({ status: 'init_required', sources, existing_agents: existingAgents })] };
346
+ }
347
+ // sync / migrate는 relay.yaml이 필요
348
+ if (!fs_1.default.existsSync(relayYamlPath)) {
349
+ return { content: [jsonText({ error: 'NOT_INITIALIZED', message: '.relay/relay.yaml이 없습니다. mode: init으로 먼저 실행하세요.' })], isError: true };
350
+ }
351
+ if (mode === 'migrate') {
352
+ const yamlMigrate = fs_1.default.readFileSync(relayYamlPath, 'utf-8');
353
+ const cfgMigrate = js_yaml_1.default.load(yamlMigrate);
354
+ if (cfgMigrate.contents) {
355
+ return { content: [jsonText({ status: 'already_migrated', message: '이미 contents 형식입니다.' })] };
356
+ }
357
+ const legacySource = cfgMigrate.source;
358
+ if (!legacySource) {
359
+ return { content: [jsonText({ status: 'no_source', message: 'source 필드가 없습니다.' })], isError: true };
360
+ }
361
+ const localTools = (0, ai_tools_js_1.detectAgentCLIs)(projectPath);
362
+ const tool = localTools.find((t) => t.skillsDir === legacySource);
363
+ const migratedContents = [];
364
+ if (tool) {
365
+ const items = (0, ai_tools_js_1.scanLocalItems)(projectPath, tool);
366
+ for (const item of items) {
367
+ migratedContents.push({ name: item.name, type: item.type, from: `${legacySource}/${item.relativePath}` });
368
+ }
369
+ }
370
+ delete cfgMigrate.source;
371
+ cfgMigrate.contents = migratedContents;
372
+ fs_1.default.writeFileSync(relayYamlPath, js_yaml_1.default.dump(cfgMigrate, { lineWidth: 120 }), 'utf-8');
373
+ return { content: [jsonText({ status: 'migrated', contents: migratedContents })] };
374
+ }
375
+ // mode === 'sync'
376
+ const { computeContentsDiff, syncContentsToRelay } = await import('../commands/package.js');
377
+ const yamlContent = fs_1.default.readFileSync(relayYamlPath, 'utf-8');
378
+ const config = js_yaml_1.default.load(yamlContent);
379
+ const contents = config.contents ?? [];
380
+ if (contents.length === 0) {
381
+ return { content: [jsonText({ status: 'no_contents', message: 'relay.yaml에 contents가 없습니다.' })], isError: true };
382
+ }
383
+ const { diff: contentsDiff, newItems } = computeContentsDiff(contents, relayDir, projectPath);
384
+ const hasChanges = contentsDiff.some((d) => d.status === 'modified');
385
+ if (hasChanges) {
386
+ syncContentsToRelay(contents, contentsDiff, relayDir, projectPath);
387
+ }
388
+ const summary = {
389
+ modified: contentsDiff.filter((d) => d.status === 'modified').length,
390
+ unchanged: contentsDiff.filter((d) => d.status === 'unchanged').length,
391
+ source_missing: contentsDiff.filter((d) => d.status === 'source_missing').length,
392
+ new_available: newItems.length,
393
+ };
394
+ return { content: [jsonText({ diff: contentsDiff, new_items: newItems, synced: hasChanges, summary })] };
395
+ }
396
+ catch (err) {
397
+ return { content: [jsonText({ error: String(err) })], isError: true };
398
+ }
399
+ });
294
400
  server.tool('relay_org_list', '소속 Organization 목록을 조회합니다', {}, async () => {
295
401
  try {
296
402
  const token = await (0, config_js_1.getValidToken)();
@@ -522,8 +628,8 @@ function createMcpServer() {
522
628
  // 이미 로그인되어 있는지 확인
523
629
  const existingToken = await (0, config_js_1.getValidToken)();
524
630
  if (existingToken) {
525
- const username = await resolveUsername(existingToken);
526
- return { content: [jsonText({ status: 'already_authenticated', username })] };
631
+ const { username, email } = await resolveUserInfo(existingToken);
632
+ return { content: [jsonText({ status: 'already_authenticated', username, email })] };
527
633
  }
528
634
  // Device code 발급
529
635
  const res = await fetch(`${config_js_1.API_URL}/api/auth/device/request`, { method: 'POST' });
@@ -561,8 +667,8 @@ function createMcpServer() {
561
667
  refresh_token: data.refresh_token,
562
668
  expires_at: data.expires_at ? Number(data.expires_at) : undefined,
563
669
  });
564
- const username = await resolveUsername(data.token);
565
- return { content: [jsonText({ status: 'ok', message: '로그인 완료', username })] };
670
+ const { username, email } = await resolveUserInfo(data.token);
671
+ return { content: [jsonText({ status: 'ok', message: '로그인 완료', username, email })] };
566
672
  }
567
673
  }
568
674
  return { content: [jsonText({ status: 'timeout', verification_url, user_code, message: `브라우저에서 ${verification_url} 을 열고 코드 ${user_code} 를 입력해주세요.` })], isError: true };
@@ -59,6 +59,9 @@ cat ~/Library/Application\ Support/Claude/claude_desktop_config.json 2>/dev/null
59
59
  | "relay install X" | `relay install X` | `relay_install` tool 호출 (`slug: "X"`) |
60
60
  | "relay publish" | `relay publish` | `relay_publish` tool 호출 |
61
61
  | "relay login" | `relay login` | `relay_login` tool 호출 |
62
+ | "relay package --init" | `relay package --init --json` | `relay_package` tool 호출 (`mode: "init"`) |
63
+ | "relay package --sync" | `relay package --sync --json` | `relay_package` tool 호출 (`mode: "sync"`) |
64
+ | "relay package --migrate" | `relay package --migrate --json` | `relay_package` tool 호출 (`mode: "migrate"`) |
62
65
  | "relay scan" | `relay package --init` | `relay_scan` tool 호출 |
63
66
  | "relay check-update X" | `relay check-update X` | `relay_check_update` tool 호출 (`slug: "X"`) |
64
67
  | "relay orgs list" | `relay orgs list --json` | `relay_org_list` tool 호출 |
@@ -28,6 +28,13 @@
28
28
 
29
29
  #### A. 최초 배포 (.relay/relay.yaml이 없음)
30
30
 
31
+ **환경 B (MCP)의 경우:**
32
+ `relay_package` MCP tool (`mode: "init"`)을 사용합니다. CLI의 `relay package --init --json`과 동일한 결과를 반환합니다.
33
+ - 결과의 `sources[]`는 배포할 콘텐츠 **후보** 목록입니다. 전부 배포 대상이 아닙니다.
34
+ - 사용자에게 어떤 콘텐츠를 패키지에 포함할지 반드시 물어보세요.
35
+
36
+ **환경 A (터미널)의 경우:**
37
+
31
38
  ##### 1단계: 소스 탐색
32
39
 
33
40
  `relay package --init --json` 실행
@@ -172,6 +179,14 @@ contents:
172
179
 
173
180
  #### B. 재배포 (.relay/relay.yaml이 있음)
174
181
 
182
+ **환경 B (MCP)의 경우:**
183
+ `relay_package` MCP tool을 사용합니다:
184
+ - `mode: "migrate"` — B-0 마이그레이션 (source → contents)
185
+ - `mode: "sync"` — B-1~B-2 동기화 + 변경 반영
186
+ - sync 결과의 `new_items`가 있으면 B-3 새 콘텐츠 추가도 진행합니다.
187
+
188
+ **환경 A (터미널)의 경우:**
189
+
175
190
  ##### B-0. 기존 source 필드 마이그레이션
176
191
 
177
192
  relay.yaml에 기존 `source` 필드만 있고 `contents`가 없으면:
@@ -276,7 +291,11 @@ Organization 목록을 조회합니다:
276
291
  - question: "Organization이 없습니다. 비공개 배포를 하려면 Organization이 필요합니다. Organization을 만들까요?"
277
292
  - options: `["Organization 생성", "Organization 없이 계속 (공개/링크공유만 가능)"]`
278
293
  - "Organization 생성" 선택 시:
279
- - **사용자 질문 도구 호출:** question: "Organization 이름을 입력하세요."
294
+ - 로그인 정보(username, email)를 기반으로 Organization 이름을 추천합니다:
295
+ - 업무용 이메일(커스텀 도메인)이면 → 도메인에서 회사명 추출하여 추천. 예: `haemin@relayax.com` → "relayax"
296
+ - 비업무용 이메일(gmail.com, naver.com, kakao.com, daum.net, hotmail.com, outlook.com, yahoo.com, icloud.com 등 무료 메일)이면 → username을 추천. 예: `haemin` → "haemin"
297
+ - email이 없으면 → username을 추천
298
+ - **사용자 질문 도구 호출:** question: "Organization 이름을 입력하세요. (추천: {추천이름})"
280
299
  - 환경 A: `relay orgs create "이름" --json` 실행
281
300
  - 환경 B: `relay_org_create` MCP tool 호출
282
301
  - 생성 후 org 목록을 갱신합니다.
@@ -300,18 +319,19 @@ relay.yaml의 `visibility` 설정을 확인합니다.
300
319
 
301
320
  Org가 선택된 경우:
302
321
  - **사용자 질문 도구 호출:**
303
- - question: "공개 범위를 선택하세요"
304
- - options: `["공개 — 누구나 설치", "링크 공유 접근 링크가 있는 사람만 설치", "비공개Org 멤버만"]`
322
+ - question: "{org_name} Organization에 배포합니다. 공개 범위를 선택하세요"
323
+ - options: `["public외부인 포함 누구나 설치", "internal조직 구성원 누구나 설치", "private조직 내에서도 허가받은 사람만 설치"]`
305
324
 
306
- Org가 없는 경우:
325
+ Org가 없는 경우 (개인 배포):
307
326
  - **사용자 질문 도구 호출:**
308
327
  - question: "공개 범위를 선택하세요"
309
- - options: `["공개 — 누구나 설치", "링크 공유 — 접근 링크가 있는 사람만 설치"]`
328
+ - options: `["public — 누구나 검색하여 설치 가능", "private — 접근 링크를 받은 사람만 설치 가능"]`
310
329
 
311
330
  **응답 처리:**
312
- - "공개" → relay.yaml에 `visibility: public` 저장
313
- - "링크 공유" → relay.yaml에 `visibility: private` 저장. 배포 후 웹 대시보드(/dashboard)에서 접근 링크를 생성하고 구매 안내를 설정할 수 있다고 안내.
314
- - "비공개" → relay.yaml에 `visibility: internal`, `org: <selected_slug>` 저장
331
+
332
+ - "public" → relay.yaml에 `visibility: public` 저장
333
+ - "internal" → relay.yaml에 `visibility: internal` 저장
334
+ - "private" → relay.yaml에 `visibility: private` 저장. 배포 후 웹 대시보드에서 접근 코드를 생성하여 공유할 수 있다고 안내.
315
335
 
316
336
  **재배포 (visibility 이미 설정됨):**
317
337
 
@@ -325,12 +345,21 @@ Org가 없는 경우:
325
345
 
326
346
  .relay/ 내 모든 파일을 자동 분석합니다.
327
347
 
328
- #### 2-1. 시크릿 스캔 (자동)
348
+ #### 2-1. 시크릿 & 개인정보 스캔 (자동)
349
+
350
+ **시크릿 스캔:**
329
351
  - 하드코딩된 API 키, 토큰, 비밀번호, Private Key 등을 탐색합니다.
330
352
  - 예: sk-..., ghp_..., AKIA..., Bearer 토큰, JWT, -----BEGIN PRIVATE KEY----- 등
331
353
  - 발견 시 **즉시 사용자에게 경고**하고, 환경변수로 대체하도록 안내합니다.
332
354
  - 시크릿이 제거되지 않으면 배포를 진행하지 않습니다.
333
355
 
356
+ **개인정보 스캔:**
357
+ - 이메일 주소, 전화번호, 실명, 주소 등 개인정보가 포함된 파일을 탐색합니다.
358
+ - **중요: 패키지에 포함된 모든 파일은 설치한 사람이 볼 수 있습니다.** 상세페이지에 노출되지 않더라도 패키지 자체에 포함됩니다.
359
+ - 발견 시 사용자에게 경고하고 제거/수정 여부를 확인받습니다:
360
+ - "⚠ {파일명}에 개인정보({종류})가 포함되어 있습니다. 이 파일은 패키지에 포함되어 설치한 사람이 볼 수 있습니다."
361
+ - **사용자 질문 도구 호출:** question: "개인정보가 포함된 파일을 어떻게 처리할까요?", options: `["제거 후 배포", "그대로 배포", "취소"]`
362
+
334
363
  #### 2-2. 환경변수 & 의존성 분석 (자동)
335
364
  분석 대상:
336
365
  - **env**: 환경변수 참조 (process.env.*, ${VAR}, os.environ 등)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relayax-cli",
3
- "version": "0.3.58",
3
+ "version": "0.3.61",
4
4
  "description": "RelayAX Agent Team Marketplace CLI - Install and manage agent teams",
5
5
  "main": "dist/index.js",
6
6
  "bin": {