relayax-cli 0.2.39 → 0.2.41

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.
@@ -0,0 +1,287 @@
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.registerPackage = registerPackage;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const crypto_1 = __importDefault(require("crypto"));
10
+ const js_yaml_1 = __importDefault(require("js-yaml"));
11
+ const ai_tools_js_1 = require("../lib/ai-tools.js");
12
+ const SYNC_DIRS = ['skills', 'commands', 'agents', 'rules'];
13
+ const EXCLUDE_SUBDIRS = ['relay']; // relay CLI 전용 하위 디렉토리 제외
14
+ // ─── Helpers ───
15
+ function fileHash(filePath) {
16
+ const content = fs_1.default.readFileSync(filePath);
17
+ return crypto_1.default.createHash('md5').update(content).digest('hex');
18
+ }
19
+ /**
20
+ * 디렉토리를 재귀 탐색하여 파일 목록을 반환한다.
21
+ * baseDir 기준 상대 경로 + 해시.
22
+ */
23
+ function scanDir(baseDir, subDir) {
24
+ const fullDir = path_1.default.join(baseDir, subDir);
25
+ if (!fs_1.default.existsSync(fullDir))
26
+ return [];
27
+ const entries = [];
28
+ function walk(dir) {
29
+ for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
30
+ if (entry.name.startsWith('.'))
31
+ continue;
32
+ const fullPath = path_1.default.join(dir, entry.name);
33
+ if (entry.isDirectory()) {
34
+ walk(fullPath);
35
+ }
36
+ else {
37
+ const relPath = path_1.default.relative(baseDir, fullPath);
38
+ entries.push({ relPath, hash: fileHash(fullPath) });
39
+ }
40
+ }
41
+ }
42
+ walk(fullDir);
43
+ return entries;
44
+ }
45
+ /**
46
+ * 소스 디렉토리(예: .claude/)에서 배포 가능한 콘텐츠를 스캔한다.
47
+ * relay/ 하위 디렉토리는 제외.
48
+ */
49
+ function scanSource(projectPath, tool) {
50
+ const sourceBase = path_1.default.join(projectPath, tool.skillsDir);
51
+ const files = [];
52
+ const summary = {};
53
+ for (const dir of SYNC_DIRS) {
54
+ const fullDir = path_1.default.join(sourceBase, dir);
55
+ if (!fs_1.default.existsSync(fullDir))
56
+ continue;
57
+ // 제외 대상 필터링 (예: commands/relay/)
58
+ const dirEntries = fs_1.default.readdirSync(fullDir, { withFileTypes: true });
59
+ let count = 0;
60
+ for (const entry of dirEntries) {
61
+ if (entry.name.startsWith('.'))
62
+ continue;
63
+ if (entry.isDirectory() && EXCLUDE_SUBDIRS.includes(entry.name))
64
+ continue;
65
+ const entryPath = path_1.default.join(fullDir, entry.name);
66
+ if (entry.isDirectory()) {
67
+ // 하위 파일 재귀 탐색
68
+ const subFiles = scanDir(sourceBase, path_1.default.join(dir, entry.name));
69
+ // relPath를 sourceBase 기준 → SYNC_DIRS 기준으로 유지
70
+ files.push(...subFiles);
71
+ count += subFiles.length > 0 ? 1 : 0; // 디렉토리 단위로 카운트
72
+ }
73
+ else {
74
+ const relPath = path_1.default.relative(sourceBase, entryPath);
75
+ files.push({ relPath, hash: fileHash(entryPath) });
76
+ count++;
77
+ }
78
+ }
79
+ if (count > 0)
80
+ summary[dir] = count;
81
+ }
82
+ return { tool, files, summary };
83
+ }
84
+ /**
85
+ * .relay/ 디렉토리의 현재 콘텐츠를 스캔한다.
86
+ */
87
+ function scanRelay(relayDir) {
88
+ const files = [];
89
+ for (const dir of SYNC_DIRS) {
90
+ files.push(...scanDir(relayDir, dir));
91
+ }
92
+ return files;
93
+ }
94
+ /**
95
+ * 소스와 .relay/를 비교하여 diff를 생성한다.
96
+ */
97
+ function computeDiff(sourceFiles, relayFiles) {
98
+ const relayMap = new Map(relayFiles.map((f) => [f.relPath, f.hash]));
99
+ const sourceMap = new Map(sourceFiles.map((f) => [f.relPath, f.hash]));
100
+ const diff = [];
101
+ // 소스에 있는 파일
102
+ for (const [relPath, hash] of sourceMap) {
103
+ const relayHash = relayMap.get(relPath);
104
+ if (!relayHash) {
105
+ diff.push({ relPath, status: 'added' });
106
+ }
107
+ else if (relayHash !== hash) {
108
+ diff.push({ relPath, status: 'modified' });
109
+ }
110
+ else {
111
+ diff.push({ relPath, status: 'unchanged' });
112
+ }
113
+ }
114
+ // .relay/에만 있는 파일 (소스에서 삭제됨)
115
+ for (const [relPath] of relayMap) {
116
+ if (!sourceMap.has(relPath)) {
117
+ diff.push({ relPath, status: 'deleted' });
118
+ }
119
+ }
120
+ return diff.sort((a, b) => a.relPath.localeCompare(b.relPath));
121
+ }
122
+ /**
123
+ * 소스에서 .relay/로 파일을 동기화한다.
124
+ */
125
+ function syncToRelay(sourceBase, relayDir, diff) {
126
+ for (const entry of diff) {
127
+ const sourcePath = path_1.default.join(sourceBase, entry.relPath);
128
+ const relayPath = path_1.default.join(relayDir, entry.relPath);
129
+ if (entry.status === 'added' || entry.status === 'modified') {
130
+ fs_1.default.mkdirSync(path_1.default.dirname(relayPath), { recursive: true });
131
+ fs_1.default.copyFileSync(sourcePath, relayPath);
132
+ }
133
+ else if (entry.status === 'deleted') {
134
+ if (fs_1.default.existsSync(relayPath)) {
135
+ fs_1.default.unlinkSync(relayPath);
136
+ // 빈 디렉토리 정리
137
+ const parentDir = path_1.default.dirname(relayPath);
138
+ try {
139
+ const remaining = fs_1.default.readdirSync(parentDir).filter((f) => !f.startsWith('.'));
140
+ if (remaining.length === 0)
141
+ fs_1.default.rmdirSync(parentDir);
142
+ }
143
+ catch { /* ignore */ }
144
+ }
145
+ }
146
+ }
147
+ }
148
+ // ─── Command ───
149
+ function registerPackage(program) {
150
+ program
151
+ .command('package')
152
+ .description('소스 디렉토리에서 .relay/로 콘텐츠를 동기화합니다')
153
+ .option('--source <dir>', '소스 디렉토리 지정 (예: .claude)')
154
+ .option('--sync', '변경사항을 .relay/에 즉시 반영', false)
155
+ .option('--init', '최초 패키징: 소스 감지 → .relay/ 초기화', false)
156
+ .action(async (opts) => {
157
+ const json = program.opts().json ?? false;
158
+ const projectPath = process.cwd();
159
+ const relayDir = path_1.default.join(projectPath, '.relay');
160
+ const relayYamlPath = path_1.default.join(relayDir, 'relay.yaml');
161
+ // ─── 최초 패키징 (--init) ───
162
+ if (opts.init || !fs_1.default.existsSync(relayYamlPath)) {
163
+ const detected = (0, ai_tools_js_1.detectAgentCLIs)(projectPath);
164
+ // 각 도구의 콘텐츠 스캔
165
+ const scans = detected
166
+ .map((tool) => scanSource(projectPath, tool))
167
+ .filter((s) => s.files.length > 0);
168
+ if (json) {
169
+ console.log(JSON.stringify({
170
+ status: 'init_required',
171
+ detected: scans.map((s) => ({
172
+ source: s.tool.skillsDir,
173
+ name: s.tool.name,
174
+ summary: s.summary,
175
+ fileCount: s.files.length,
176
+ })),
177
+ }));
178
+ }
179
+ else {
180
+ if (scans.length === 0) {
181
+ console.error('배포 가능한 에이전트 콘텐츠를 찾지 못했습니다.');
182
+ console.error('skills/, commands/, agents/, rules/ 중 하나를 만들어주세요.');
183
+ process.exit(1);
184
+ }
185
+ console.error('\n프로젝트에서 발견된 에이전트 콘텐츠:\n');
186
+ for (const scan of scans) {
187
+ const parts = Object.entries(scan.summary)
188
+ .map(([dir, count]) => `${dir} ${count}개`)
189
+ .join(', ');
190
+ console.error(` 📁 ${scan.tool.skillsDir}/ — ${parts}`);
191
+ }
192
+ console.error('');
193
+ }
194
+ return;
195
+ }
196
+ // ─── 재패키징 (source 기반 동기화) ───
197
+ const yamlContent = fs_1.default.readFileSync(relayYamlPath, 'utf-8');
198
+ const config = js_yaml_1.default.load(yamlContent);
199
+ const source = opts.source ?? config.source;
200
+ if (!source) {
201
+ if (json) {
202
+ console.log(JSON.stringify({
203
+ status: 'no_source',
204
+ message: 'relay.yaml에 source 필드가 없습니다. --source 옵션으로 지정하거나 relay.yaml에 source를 추가하세요.',
205
+ }));
206
+ }
207
+ else {
208
+ console.error('relay.yaml에 source 필드가 없습니다.');
209
+ console.error('--source <dir> 옵션으로 지정하거나 relay.yaml에 source를 추가하세요.');
210
+ }
211
+ process.exit(1);
212
+ }
213
+ // 소스 디렉토리 존재 확인
214
+ const sourceBase = path_1.default.join(projectPath, source);
215
+ if (!fs_1.default.existsSync(sourceBase)) {
216
+ const msg = `소스 디렉토리 '${source}'를 찾을 수 없습니다.`;
217
+ if (json) {
218
+ console.log(JSON.stringify({ error: 'SOURCE_NOT_FOUND', message: msg }));
219
+ }
220
+ else {
221
+ console.error(msg);
222
+ }
223
+ process.exit(1);
224
+ }
225
+ // 소스에서 해당 도구 찾기
226
+ const allTools = (0, ai_tools_js_1.detectAgentCLIs)(projectPath);
227
+ const tool = allTools.find((t) => t.skillsDir === source);
228
+ const toolName = tool?.name ?? source;
229
+ // diff 계산
230
+ const sourceScan = tool
231
+ ? scanSource(projectPath, tool)
232
+ : { tool: { name: source, value: source, skillsDir: source }, files: [], summary: {} };
233
+ // tool이 없으면 직접 스캔
234
+ if (!tool) {
235
+ for (const dir of SYNC_DIRS) {
236
+ const files = scanDir(sourceBase, dir);
237
+ sourceScan.files.push(...files);
238
+ if (files.length > 0)
239
+ sourceScan.summary[dir] = files.length;
240
+ }
241
+ }
242
+ const relayFiles = scanRelay(relayDir);
243
+ const diff = computeDiff(sourceScan.files, relayFiles);
244
+ const summary = {
245
+ added: diff.filter((d) => d.status === 'added').length,
246
+ modified: diff.filter((d) => d.status === 'modified').length,
247
+ deleted: diff.filter((d) => d.status === 'deleted').length,
248
+ unchanged: diff.filter((d) => d.status === 'unchanged').length,
249
+ };
250
+ const hasChanges = summary.added + summary.modified + summary.deleted > 0;
251
+ // --sync: 즉시 동기화
252
+ if (opts.sync && hasChanges) {
253
+ syncToRelay(sourceBase, relayDir, diff);
254
+ }
255
+ const result = {
256
+ source,
257
+ sourceName: toolName,
258
+ synced: opts.sync === true && hasChanges,
259
+ diff: diff.filter((d) => d.status !== 'unchanged'),
260
+ summary,
261
+ };
262
+ if (json) {
263
+ console.log(JSON.stringify(result));
264
+ }
265
+ else {
266
+ if (!hasChanges) {
267
+ console.error(`✓ 소스(${source})와 .relay/가 동기화 상태입니다.`);
268
+ return;
269
+ }
270
+ console.error(`\n📦 소스 동기화 (${source}/ → .relay/)\n`);
271
+ for (const entry of diff) {
272
+ if (entry.status === 'unchanged')
273
+ continue;
274
+ const icon = entry.status === 'added' ? ' 신규' : entry.status === 'modified' ? ' 변경' : ' 삭제';
275
+ console.error(`${icon}: ${entry.relPath}`);
276
+ }
277
+ console.error('');
278
+ console.error(` 합계: 신규 ${summary.added}, 변경 ${summary.modified}, 삭제 ${summary.deleted}, 유지 ${summary.unchanged}`);
279
+ if (opts.sync) {
280
+ console.error(`\n✓ .relay/에 반영 완료`);
281
+ }
282
+ else {
283
+ console.error(`\n반영하려면: relay package --sync`);
284
+ }
285
+ }
286
+ });
287
+ }
@@ -24,8 +24,9 @@ function parseRelayYaml(content) {
24
24
  const requires = raw.requires;
25
25
  const rawVisibility = String(raw.visibility ?? '');
26
26
  const visibility = rawVisibility === 'private' ? 'private'
27
- : rawVisibility === 'public' ? 'public'
28
- : undefined;
27
+ : rawVisibility === 'gated' ? 'gated'
28
+ : rawVisibility === 'public' ? 'public'
29
+ : undefined;
29
30
  const rawType = String(raw.type ?? '');
30
31
  const type = rawType === 'command' ? 'command'
31
32
  : rawType === 'passive' ? 'passive'
@@ -42,6 +43,7 @@ function parseRelayYaml(content) {
42
43
  requires,
43
44
  visibility,
44
45
  type,
46
+ source: raw.source ? String(raw.source) : undefined,
45
47
  };
46
48
  }
47
49
  function detectCommands(teamDir) {
@@ -244,7 +246,7 @@ async function createTarball(teamDir) {
244
246
  const dirsToInclude = VALID_DIRS.filter((d) => fs_1.default.existsSync(path_1.default.join(teamDir, d)));
245
247
  // Include root-level files if they exist
246
248
  const entries = [...dirsToInclude];
247
- const rootFiles = ['relay.yaml', 'SKILL.md'];
249
+ const rootFiles = ['relay.yaml', 'SKILL.md', 'guide.md'];
248
250
  for (const file of rootFiles) {
249
251
  if (fs_1.default.existsSync(path_1.default.join(teamDir, file))) {
250
252
  entries.push(file);
@@ -281,6 +283,8 @@ function registerPublish(program) {
281
283
  .command('publish')
282
284
  .description('현재 팀 패키지를 Space에 배포합니다 (relay.yaml 필요)')
283
285
  .option('--token <token>', '인증 토큰')
286
+ .option('--space <slug>', '배포할 Space 지정')
287
+ .option('--version <version>', '배포 버전 지정 (relay.yaml 업데이트)')
284
288
  .action(async (opts) => {
285
289
  const json = program.opts().json ?? false;
286
290
  const teamDir = process.cwd();
@@ -317,6 +321,7 @@ function registerPublish(program) {
317
321
  console.error(JSON.stringify({
318
322
  error: 'NOT_INITIALIZED',
319
323
  message: '.relay/relay.yaml이 없습니다. 먼저 `relay create`를 실행하세요.',
324
+ fix: 'relay create 또는 .relay/relay.yaml을 생성하세요.',
320
325
  }));
321
326
  process.exit(1);
322
327
  }
@@ -345,12 +350,16 @@ function registerPublish(program) {
345
350
  const visibility = await promptSelect({
346
351
  message: '공개 범위:',
347
352
  choices: [
348
- { name: '공개', value: 'public' },
349
- { name: '비공개 (Space 멤버만)', value: 'private' },
353
+ { name: '공개 — 누구나 설치', value: 'public' },
354
+ { name: '링크 공유 — 접근 링크가 있는 사람만 설치', value: 'gated' },
355
+ { name: '비공개 — Space 멤버만', value: 'private' },
350
356
  ],
351
357
  });
352
358
  console.error('\n\x1b[2m💡 프로필에 연락처를 설정하면 설치 시 명함이 전달됩니다: www.relayax.com/dashboard/profile\x1b[0m');
353
- if (visibility === 'private') {
359
+ if (visibility === 'gated') {
360
+ console.error('\x1b[2m💡 링크 공유 팀은 웹 대시보드에서 접근 링크와 구매 안내를 설정하세요: www.relayax.com/dashboard\x1b[0m');
361
+ }
362
+ else if (visibility === 'private') {
354
363
  console.error('\x1b[2m💡 비공개 팀은 Space를 통해 멤버를 관리하세요: www.relayax.com/dashboard/teams\x1b[0m');
355
364
  }
356
365
  console.error('');
@@ -377,11 +386,18 @@ function registerPublish(program) {
377
386
  console.error(JSON.stringify({
378
387
  error: 'INVALID_CONFIG',
379
388
  message: 'relay.yaml에 name, slug, description이 필요합니다.',
389
+ fix: 'relay.yaml에 name, slug, description을 확인하세요.',
380
390
  }));
381
391
  process.exit(1);
382
392
  }
383
- // Version bump suggestion for republishing
384
- if (isTTY) {
393
+ // Version bump: --version flag takes priority
394
+ if (opts.version) {
395
+ config.version = opts.version;
396
+ const yamlData = js_yaml_1.default.load(fs_1.default.readFileSync(relayYamlPath, 'utf-8'));
397
+ yamlData.version = opts.version;
398
+ fs_1.default.writeFileSync(relayYamlPath, js_yaml_1.default.dump(yamlData, { lineWidth: 120 }), 'utf-8');
399
+ }
400
+ else if (isTTY) {
385
401
  const { select: promptVersion } = await import('@inquirer/prompts');
386
402
  const [major, minor, patch] = config.version.split('.').map(Number);
387
403
  const bumpPatch = `${major}.${minor}.${patch + 1}`;
@@ -415,6 +431,7 @@ function registerPublish(program) {
415
431
  console.error(JSON.stringify({
416
432
  error: 'EMPTY_PACKAGE',
417
433
  message: '.relay/ 안에 skills/, agents/, rules/, commands/ 중 하나 이상에 파일이 있어야 합니다.',
434
+ fix: '.relay/ 안에 skills/, agents/, rules/, commands/ 중 하나에 파일을 추가하세요.',
418
435
  }));
419
436
  process.exit(1);
420
437
  }
@@ -424,71 +441,110 @@ function registerPublish(program) {
424
441
  console.error(JSON.stringify({
425
442
  error: 'NO_TOKEN',
426
443
  message: '인증이 필요합니다. `relay login`을 먼저 실행하세요.',
444
+ fix: 'relay login 실행 후 재시도하세요.',
427
445
  }));
428
446
  process.exit(1);
429
447
  }
430
448
  // Fetch user's Spaces and select publish target
431
449
  let selectedSpaceId;
432
- let selectedSpaceIsPersonal = true;
450
+ let selectedSpaceSlug;
451
+ let selectedJoinPolicy;
433
452
  try {
434
453
  const { fetchMySpaces } = await import('./spaces.js');
435
454
  const spaces = await fetchMySpaces(token);
436
- const personalSpace = spaces.find((s) => s.is_personal);
437
- const teamSpaces = spaces.filter((s) => !s.is_personal);
438
- if (isTTY) {
455
+ // --space flag: resolve Space by slug
456
+ if (opts.space) {
457
+ const matched = spaces.find((s) => s.slug === opts.space);
458
+ if (matched) {
459
+ selectedSpaceId = matched.id;
460
+ selectedSpaceSlug = matched.slug;
461
+ selectedJoinPolicy = matched.join_policy;
462
+ }
463
+ else {
464
+ if (json) {
465
+ console.error(JSON.stringify({
466
+ error: 'INVALID_SPACE',
467
+ message: `Space '${opts.space}'를 찾을 수 없습니다.`,
468
+ fix: `사용 가능한 Space: ${spaces.map((s) => s.slug).join(', ')}`,
469
+ options: spaces.map((s) => ({ value: s.slug, label: `${s.name} (${s.slug})` })),
470
+ }));
471
+ }
472
+ else {
473
+ console.error(`Space '${opts.space}'를 찾을 수 없습니다.`);
474
+ }
475
+ process.exit(1);
476
+ }
477
+ }
478
+ else if (isTTY) {
439
479
  if (spaces.length === 0) {
440
480
  // No spaces at all — publish without space_id
441
481
  console.error('\x1b[33m⚠ 소속 Space가 없습니다. 개인 계정으로 배포합니다.\x1b[0m\n');
442
482
  }
443
- else if (spaces.length === 1 && personalSpace) {
444
- // Only personal Space — auto-select
445
- selectedSpaceId = personalSpace.id;
446
- selectedSpaceIsPersonal = true;
447
- console.error(`\x1b[2m Space: 개인 스페이스 (${personalSpace.slug})\x1b[0m\n`);
483
+ else if (spaces.length === 1) {
484
+ // Only one Space — auto-select regardless of type
485
+ selectedSpaceId = spaces[0].id;
486
+ selectedSpaceSlug = spaces[0].slug;
487
+ selectedJoinPolicy = spaces[0].join_policy;
488
+ console.error(`\x1b[2m Space: ${spaces[0].name} (${spaces[0].slug})\x1b[0m\n`);
448
489
  }
449
490
  else {
450
491
  // Multiple spaces — prompt user
451
492
  const { select: selectSpace } = await import('@inquirer/prompts');
452
- const spaceChoices = [
453
- ...(personalSpace ? [{ name: `개인 스페이스 (${personalSpace.slug})`, value: personalSpace.id, isPersonal: true }] : []),
454
- ...teamSpaces.map((s) => ({ name: `${s.name} (${s.slug})`, value: s.id, isPersonal: false })),
455
- ];
493
+ const spaceChoices = spaces.map((s) => ({
494
+ name: `${s.name} (${s.slug})`,
495
+ value: s.id,
496
+ slug: s.slug,
497
+ join_policy: s.join_policy,
498
+ }));
456
499
  const chosenId = await selectSpace({
457
500
  message: '어떤 Space에 배포할까요?',
458
501
  choices: spaceChoices.map((c) => ({ name: c.name, value: c.value })),
459
502
  });
460
503
  const chosen = spaceChoices.find((c) => c.value === chosenId);
461
504
  selectedSpaceId = chosenId;
462
- selectedSpaceIsPersonal = chosen?.isPersonal ?? false;
505
+ selectedSpaceSlug = chosen?.slug;
506
+ selectedJoinPolicy = chosen?.join_policy;
463
507
  const chosenLabel = chosen?.name ?? chosenId;
464
508
  console.error(` → Space: ${chosenLabel}\n`);
465
509
  }
466
510
  }
467
- else if (personalSpace) {
468
- selectedSpaceId = personalSpace.id;
469
- selectedSpaceIsPersonal = true;
511
+ else if (spaces.length > 1 && json) {
512
+ // --json 모드 + 여러 Space: 에이전트가 선택할 수 있도록 에러 반환
513
+ console.error(JSON.stringify({
514
+ error: 'MISSING_SPACE',
515
+ message: '배포할 Space를 선택하세요.',
516
+ fix: `relay publish --space <slug> --json`,
517
+ options: spaces.map((s) => ({ value: s.slug, label: `${s.name} (${s.slug})` })),
518
+ }));
519
+ process.exit(1);
520
+ }
521
+ else if (spaces.length > 0) {
522
+ selectedSpaceId = spaces[0].id;
523
+ selectedSpaceSlug = spaces[0].slug;
524
+ selectedJoinPolicy = spaces[0].join_policy;
470
525
  }
471
526
  }
472
527
  catch {
473
528
  // Space 조회 실패 시 무시하고 계속 진행
474
529
  }
475
- // Visibility default based on Space type: personalpublic, team Space private
476
- const defaultVisibility = selectedSpaceIsPersonal ? 'public' : 'private';
477
- const defaultVisLabel = defaultVisibility === 'public'
478
- ? '공개 (개인 Space 기본값)'
479
- : '비공개 (팀 Space 기본값)';
530
+ // Visibility default based on join_policy: approvalprivate, otherwisepublic
531
+ const defaultVisibility = selectedJoinPolicy === 'approval' ? 'private' : 'public';
480
532
  // Visibility validation: must be explicitly set
481
533
  if (!config.visibility) {
482
534
  if (isTTY) {
483
535
  const { select: promptSelect } = await import('@inquirer/prompts');
484
- console.error(`\n\x1b[33m⚠ relay.yaml에 visibility가 설정되지 않았습니다.\x1b[0m (기본값: ${defaultVisLabel})`);
536
+ console.error(`\n\x1b[33m⚠ relay.yaml에 visibility가 설정되지 않았습니다.\x1b[0m (기본값: ${defaultVisibility === 'public' ? '공개' : '비공개'})`);
485
537
  config.visibility = await promptSelect({
486
538
  message: '공개 범위를 선택하세요:',
487
539
  choices: [
488
540
  {
489
- name: `공개 — Space에 누구나 검색·설치${defaultVisibility === 'public' ? ' ✓ 추천' : ''}`,
541
+ name: `공개 — 누구나 설치${defaultVisibility === 'public' ? ' ✓ 추천' : ''}`,
490
542
  value: 'public',
491
543
  },
544
+ {
545
+ name: '링크 공유 — 접근 링크가 있는 사람만 설치',
546
+ value: 'gated',
547
+ },
492
548
  {
493
549
  name: `비공개 — Space 멤버만 접근${defaultVisibility === 'private' ? ' ✓ 추천' : ''}`,
494
550
  value: 'private',
@@ -505,7 +561,13 @@ function registerPublish(program) {
505
561
  else {
506
562
  console.error(JSON.stringify({
507
563
  error: 'MISSING_VISIBILITY',
508
- message: 'relay.yaml에 visibility (public 또는 private)를 설정해주세요.',
564
+ message: 'relay.yaml에 visibility를 설정해주세요.',
565
+ options: [
566
+ { value: 'public', label: '공개 — 누구나 설치' },
567
+ { value: 'gated', label: '링크 공유 — 접근 링크가 있는 사람만 설치' },
568
+ { value: 'private', label: '비공개 — Space 멤버만 접근' },
569
+ ],
570
+ fix: 'relay.yaml의 visibility 필드를 위 옵션 중 하나로 설정하세요.',
509
571
  }));
510
572
  process.exit(1);
511
573
  }
@@ -513,23 +575,36 @@ function registerPublish(program) {
513
575
  // Confirm visibility before publish (재배포 시 변경 기회 제공)
514
576
  if (isTTY) {
515
577
  const { select: promptConfirmVis } = await import('@inquirer/prompts');
516
- const visLabel = config.visibility === 'public'
517
- ? `\x1b[32m공개\x1b[0m (Space에 누구나 검색·설치)${defaultVisibility === 'public' ? ' ✓ 추천' : ''}`
518
- : `\x1b[33m비공개\x1b[0m (Space 멤버만 접근)${defaultVisibility === 'private' ? ' ✓ 추천' : ''}`;
519
- const visAction = await promptConfirmVis({
520
- message: `공개 범위: ${config.visibility === 'public' ? '공개' : '비공개'} — 유지할까요?`,
578
+ const visLabelMap = {
579
+ public: '공개',
580
+ gated: '링크공유',
581
+ private: '비공개',
582
+ };
583
+ const currentVisLabel = visLabelMap[config.visibility ?? 'public'] ?? config.visibility;
584
+ const newVisibility = await promptConfirmVis({
585
+ message: `공개 범위: ${currentVisLabel} — 유지하거나 변경하세요`,
521
586
  choices: [
522
- { name: `유지 — ${visLabel}`, value: 'keep' },
523
- { name: '변경', value: 'change' },
587
+ {
588
+ name: `공개 — 누구나 설치${defaultVisibility === 'public' ? ' ✓ 추천' : ''}`,
589
+ value: 'public',
590
+ },
591
+ {
592
+ name: '링크공유 — 접근 링크가 있는 사람만 설치',
593
+ value: 'gated',
594
+ },
595
+ {
596
+ name: `비공개 — Space 멤버만 접근${defaultVisibility === 'private' ? ' ✓ 추천' : ''}`,
597
+ value: 'private',
598
+ },
524
599
  ],
600
+ default: config.visibility ?? defaultVisibility,
525
601
  });
526
- if (visAction === 'change') {
527
- config.visibility = config.visibility === 'public' ? 'private' : 'public';
602
+ if (newVisibility !== config.visibility) {
603
+ config.visibility = newVisibility;
528
604
  const yamlData = js_yaml_1.default.load(fs_1.default.readFileSync(relayYamlPath, 'utf-8'));
529
605
  yamlData.visibility = config.visibility;
530
606
  fs_1.default.writeFileSync(relayYamlPath, js_yaml_1.default.dump(yamlData, { lineWidth: 120 }), 'utf-8');
531
- const newLabel = config.visibility === 'public' ? '공개' : '비공개';
532
- console.error(` → relay.yaml에 visibility: ${config.visibility} 저장됨 (${newLabel})\n`);
607
+ console.error(` → relay.yaml에 visibility: ${config.visibility} 저장됨 (${visLabelMap[config.visibility]})\n`);
533
608
  }
534
609
  }
535
610
  // Profile hint
@@ -564,6 +639,7 @@ function registerPublish(program) {
564
639
  agent_details: detectedAgents,
565
640
  skill_details: detectedSkills,
566
641
  ...(selectedSpaceId ? { space_id: selectedSpaceId } : {}),
642
+ ...(selectedSpaceSlug ? { space_slug: selectedSpaceSlug } : {}),
567
643
  };
568
644
  if (!json) {
569
645
  console.error(`패키지 생성 중... (${config.name} v${config.version})`);
@@ -573,6 +649,10 @@ function registerPublish(program) {
573
649
  console.error('\x1b[33m⚠ GUIDE.html은 더 이상 지원되지 않습니다. 상세페이지가 가이드 역할을 합니다.\x1b[0m');
574
650
  console.error(' long_description을 활용하거나 relayax.com에서 팀 정보를 편집하세요.\n');
575
651
  }
652
+ // Generate guide.md (consumer install guide)
653
+ const { generateGuide } = await import('../lib/guide.js');
654
+ const guideContent = generateGuide(config, detectedCommands, config.requires);
655
+ fs_1.default.writeFileSync(path_1.default.join(relayDir, 'guide.md'), guideContent);
576
656
  // Generate bin/relay-preamble.sh (self-contained tracking + update check)
577
657
  (0, preamble_js_1.generatePreambleBin)(relayDir, config.slug, config_js_1.API_URL);
578
658
  // Generate entry command (commands/{author}-{name}.md)
@@ -636,33 +716,28 @@ function registerPublish(program) {
636
716
  }
637
717
  // Show shareable onboarding guide as a plain copyable block
638
718
  if (isTTY) {
639
- const guideLines = [
640
- 'npm install -g relayax-cli',
641
- `/relay:relay-install ${result.slug}`,
642
- ];
643
- // Type-based usage hint
644
- const teamType = config.type ?? 'hybrid';
645
- if (teamType === 'passive') {
646
- guideLines.push('', '# 설치하면 자동 적용됩니다');
647
- }
648
- else if (detectedCommands.length > 0) {
649
- const usageCmd = teamType === 'hybrid'
650
- ? `# 자동 적용 + /${detectedCommands[0].name} 으로 추가 기능`
651
- : `# 사용법: /${detectedCommands[0].name}`;
652
- guideLines.push('', usageCmd);
653
- }
719
+ const detailSlug = result.slug.startsWith('@') ? result.slug.slice(1) : result.slug;
720
+ const guideUrl = `https://relayax.com/api/registry/${detailSlug}/guide.md`;
654
721
  console.log(`\n \x1b[90m주변인에게 공유하세요:\x1b[0m\n`);
655
722
  console.log('```');
656
- guideLines.forEach((line) => console.log(line));
723
+ console.log('아래 가이드를 읽고 그대로 따라해줘:');
724
+ console.log(guideUrl);
657
725
  console.log('```');
658
- const detailSlug = result.slug.startsWith('@') ? result.slug.slice(1) : result.slug;
726
+ console.log(`\n \x1b[90mCLI 설치된 사용자용 (짧은 버전):\x1b[0m`);
727
+ console.log(` /relay:relay-install ${result.slug}`);
728
+ if (config.visibility !== 'private') {
729
+ console.log(`\n \x1b[90m유료 판매하려면:\x1b[0m`);
730
+ console.log(` 1. 가시성을 "링크 공유"로 변경: \x1b[36mrelayax.com/dashboard\x1b[0m`);
731
+ console.log(` 2. API 키 발급: \x1b[36mrelayax.com/dashboard/keys\x1b[0m`);
732
+ console.log(` 3. 웹훅 연동 가이드: \x1b[36mrelayax.com/docs/webhook-guide.md\x1b[0m`);
733
+ }
659
734
  console.log(`\n \x1b[90m상세페이지: \x1b[36mrelayax.com/@${detailSlug}\x1b[0m`);
660
735
  }
661
736
  }
662
737
  }
663
738
  catch (err) {
664
739
  const message = err instanceof Error ? err.message : String(err);
665
- console.error(JSON.stringify({ error: 'PUBLISH_FAILED', message }));
740
+ console.error(JSON.stringify({ error: 'PUBLISH_FAILED', message, fix: message }));
666
741
  process.exit(1);
667
742
  }
668
743
  finally {
@@ -48,7 +48,7 @@ function registerSearch(program) {
48
48
  }
49
49
  catch (err) {
50
50
  const message = err instanceof Error ? err.message : String(err);
51
- console.error(JSON.stringify({ error: 'SEARCH_FAILED', message }));
51
+ console.error(JSON.stringify({ error: 'SEARCH_FAILED', message, fix: '검색어를 변경하거나 잠시 후 재시도하세요.' }));
52
52
  process.exit(1);
53
53
  }
54
54
  });
@@ -4,7 +4,7 @@ export interface SpaceInfo {
4
4
  slug: string;
5
5
  name: string;
6
6
  description: string | null;
7
- is_personal: boolean;
7
+ join_policy?: string;
8
8
  role: string;
9
9
  }
10
10
  export declare function fetchMySpaces(token: string): Promise<SpaceInfo[]>;