relayax-cli 0.2.38 → 0.2.40

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,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function registerAccess(program: Command): void;
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerAccess = registerAccess;
4
+ const commander_1 = require("commander");
5
+ const config_js_1 = require("../lib/config.js");
6
+ async function claimAccess(slug, code) {
7
+ const token = await (0, config_js_1.getValidToken)();
8
+ if (!token) {
9
+ throw new Error('LOGIN_REQUIRED');
10
+ }
11
+ const res = await fetch(`${config_js_1.API_URL}/api/teams/${slug}/claim-access`, {
12
+ method: 'POST',
13
+ headers: {
14
+ 'Content-Type': 'application/json',
15
+ Authorization: `Bearer ${token}`,
16
+ },
17
+ body: JSON.stringify({ code }),
18
+ signal: AbortSignal.timeout(10000),
19
+ });
20
+ const body = (await res.json().catch(() => ({})));
21
+ if (!res.ok) {
22
+ const errCode = body.error ?? String(res.status);
23
+ switch (errCode) {
24
+ case 'INVALID_LINK':
25
+ throw new Error('초대 링크가 유효하지 않거나 만료되었습니다.');
26
+ case 'NOT_FOUND':
27
+ throw new Error('팀을 찾을 수 없습니다.');
28
+ case 'UNAUTHORIZED':
29
+ throw new Error('LOGIN_REQUIRED');
30
+ default:
31
+ throw new Error(body.message ?? `접근 권한 요청 실패 (${res.status})`);
32
+ }
33
+ }
34
+ return body;
35
+ }
36
+ function registerAccess(program) {
37
+ program
38
+ .command('access <slug>')
39
+ .description('초대 코드로 팀에 접근 권한을 얻고 바로 설치합니다')
40
+ .requiredOption('--code <code>', '팀 초대 코드')
41
+ .action(async (slug, opts) => {
42
+ const json = program.opts().json ?? false;
43
+ try {
44
+ const result = await claimAccess(slug, opts.code);
45
+ if (!result.success || !result.team) {
46
+ throw new Error('서버 응답이 올바르지 않습니다.');
47
+ }
48
+ const teamSlug = result.team.slug;
49
+ if (json) {
50
+ console.log(JSON.stringify({ status: 'ok', team: result.team }));
51
+ }
52
+ else {
53
+ console.log(`\x1b[32m접근 권한이 부여되었습니다: ${result.team.name}\x1b[0m`);
54
+ console.log(`\x1b[33m팀을 설치합니다: relay install ${teamSlug}\x1b[0m\n`);
55
+ }
56
+ // Automatically install the team
57
+ const { registerInstall } = await import('./install.js');
58
+ const subProgram = new commander_1.Command();
59
+ subProgram.option('--json', '구조화된 JSON 출력');
60
+ if (json)
61
+ subProgram.setOptionValue('json', true);
62
+ registerInstall(subProgram);
63
+ await subProgram.parseAsync(['node', 'relay', 'install', teamSlug]);
64
+ }
65
+ catch (err) {
66
+ const message = err instanceof Error ? err.message : String(err);
67
+ if (message === 'LOGIN_REQUIRED') {
68
+ if (json) {
69
+ console.error(JSON.stringify({
70
+ error: 'LOGIN_REQUIRED',
71
+ message: '로그인이 필요합니다. relay login을 먼저 실행하세요.',
72
+ fix: 'relay login 실행 후 재시도하세요.',
73
+ }));
74
+ }
75
+ else {
76
+ console.error('\x1b[31m오류: 로그인이 필요합니다.\x1b[0m');
77
+ console.error(' relay login을 먼저 실행하세요.');
78
+ }
79
+ process.exit(1);
80
+ }
81
+ if (json) {
82
+ console.error(JSON.stringify({ error: 'ACCESS_FAILED', message, fix: '접근 링크 코드를 확인하거나 팀 제작자에게 문의하세요.' }));
83
+ }
84
+ else {
85
+ console.error(`\x1b[31m오류: ${message}\x1b[0m`);
86
+ }
87
+ process.exit(1);
88
+ }
89
+ });
90
+ }
@@ -24,7 +24,10 @@ function registerCreate(program) {
24
24
  program
25
25
  .command('create <name>')
26
26
  .description('새 에이전트 팀 프로젝트를 생성합니다')
27
- .action(async (name) => {
27
+ .option('--description <desc>', '팀 설명')
28
+ .option('--tags <tags>', '태그 (쉼표 구분)')
29
+ .option('--visibility <visibility>', '공개 범위 (public, gated, private)')
30
+ .action(async (name, opts) => {
28
31
  const json = program.opts().json ?? false;
29
32
  const projectPath = process.cwd();
30
33
  const relayDir = path_1.default.join(projectPath, '.relay');
@@ -33,7 +36,7 @@ function registerCreate(program) {
33
36
  // 1. .relay/relay.yaml 이미 존재하면 에러
34
37
  if (fs_1.default.existsSync(relayYamlPath)) {
35
38
  if (json) {
36
- console.error(JSON.stringify({ error: 'ALREADY_EXISTS', message: '.relay/relay.yaml이 이미 존재합니다.' }));
39
+ console.error(JSON.stringify({ error: 'ALREADY_EXISTS', message: '.relay/relay.yaml이 이미 존재합니다.', fix: '기존 .relay/relay.yaml을 확인하세요. 새로 시작하려면 삭제 후 재시도.' }));
37
40
  }
38
41
  else {
39
42
  console.error('.relay/relay.yaml이 이미 존재합니다. 기존 팀 프로젝트에서는 `relay init`을 사용하세요.');
@@ -42,28 +45,73 @@ function registerCreate(program) {
42
45
  }
43
46
  // 2. 메타데이터 수집
44
47
  const defaultSlug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
45
- let description = '';
46
- let tags = [];
47
- let visibility = 'public';
48
- if (isTTY) {
48
+ let description = opts.description ?? '';
49
+ let tags = opts.tags ? opts.tags.split(',').map((t) => t.trim()).filter(Boolean) : [];
50
+ let visibility = opts.visibility ?? 'public';
51
+ if (json) {
52
+ // --json 모드: 필수 값 부족 시 에러 반환 (프롬프트 없음)
53
+ if (!opts.description) {
54
+ console.error(JSON.stringify({
55
+ error: 'MISSING_FIELD',
56
+ message: '팀 설명이 필요합니다.',
57
+ fix: `relay create ${name} --description <설명> --json`,
58
+ field: 'description',
59
+ }));
60
+ process.exit(1);
61
+ }
62
+ if (!opts.visibility) {
63
+ console.error(JSON.stringify({
64
+ error: 'MISSING_VISIBILITY',
65
+ message: '공개 범위를 선택하세요.',
66
+ fix: `relay create ${name} --description "${description}" --visibility <visibility> --json`,
67
+ options: [
68
+ { value: 'public', label: '공개 — 누구나 설치' },
69
+ { value: 'gated', label: '링크 공유 — 접근 링크가 있는 사람만' },
70
+ { value: 'private', label: '비공개 — Space 멤버만' },
71
+ ],
72
+ }));
73
+ process.exit(1);
74
+ }
75
+ if (!['public', 'gated', 'private'].includes(opts.visibility)) {
76
+ console.error(JSON.stringify({
77
+ error: 'INVALID_FIELD',
78
+ message: `유효하지 않은 visibility 값: ${opts.visibility}`,
79
+ fix: `visibility는 public, gated, private 중 하나여야 합니다.`,
80
+ options: [
81
+ { value: 'public', label: '공개' },
82
+ { value: 'gated', label: '링크 공유' },
83
+ { value: 'private', label: '비공개' },
84
+ ],
85
+ }));
86
+ process.exit(1);
87
+ }
88
+ }
89
+ else if (isTTY) {
49
90
  const { input: promptInput, select: promptSelect } = await import('@inquirer/prompts');
50
91
  console.log(`\n \x1b[33m⚡\x1b[0m \x1b[1mrelay create\x1b[0m — 새 팀 프로젝트\n`);
51
- description = await promptInput({
52
- message: '팀 설명:',
53
- validate: (v) => v.trim().length > 0 ? true : '설명을 입력해주세요.',
54
- });
55
- const tagsRaw = await promptInput({
56
- message: '태그 (쉼표로 구분, 선택):',
57
- default: '',
58
- });
59
- tags = tagsRaw.split(',').map((t) => t.trim()).filter(Boolean);
60
- visibility = await promptSelect({
61
- message: '공개 범위:',
62
- choices: [
63
- { name: '공개', value: 'public' },
64
- { name: '비공개 (Space 멤버만)', value: 'private' },
65
- ],
66
- });
92
+ if (!description) {
93
+ description = await promptInput({
94
+ message: ' 설명:',
95
+ validate: (v) => v.trim().length > 0 ? true : '설명을 입력해주세요.',
96
+ });
97
+ }
98
+ if (!opts.tags) {
99
+ const tagsRaw = await promptInput({
100
+ message: '태그 (쉼표로 구분, 선택):',
101
+ default: '',
102
+ });
103
+ tags = tagsRaw.split(',').map((t) => t.trim()).filter(Boolean);
104
+ }
105
+ if (!opts.visibility) {
106
+ visibility = await promptSelect({
107
+ message: '공개 범위:',
108
+ choices: [
109
+ { name: '공개', value: 'public' },
110
+ { name: '링크 공유 (접근 링크 필요)', value: 'gated' },
111
+ { name: '비공개 (Space 멤버만)', value: 'private' },
112
+ ],
113
+ });
114
+ }
67
115
  }
68
116
  // 3. .relay/relay.yaml 생성
69
117
  fs_1.default.mkdirSync(relayDir, { recursive: true });
@@ -15,7 +15,7 @@ function registerFollow(program) {
15
15
  if (!token) {
16
16
  const msg = '로그인이 필요합니다. `relay login`을 먼저 실행하세요.';
17
17
  if (json) {
18
- console.log(JSON.stringify({ error: 'NO_TOKEN', message: msg }));
18
+ console.log(JSON.stringify({ error: 'NO_TOKEN', message: msg, fix: 'relay login 실행 후 재시도하세요.' }));
19
19
  }
20
20
  else {
21
21
  console.error(msg);
@@ -34,7 +34,7 @@ function registerFollow(program) {
34
34
  catch (err) {
35
35
  const message = err instanceof Error ? err.message : String(err);
36
36
  if (json) {
37
- console.log(JSON.stringify({ error: 'FOLLOW_FAILED', message }));
37
+ console.log(JSON.stringify({ error: 'FOLLOW_FAILED', message, fix: 'username을 확인하거나 잠시 후 재시도하세요.' }));
38
38
  }
39
39
  else {
40
40
  console.error(`팔로우 실패: ${message}`);
@@ -121,16 +121,31 @@ function registerInit(program) {
121
121
  program
122
122
  .command('init')
123
123
  .description('에이전트 CLI에 relay 슬래시 커맨드를 설치합니다')
124
- .option('--tools <tools>', '설치할 에이전트 CLI 지정 (all 또는 쉼표 구분)')
124
+ .option('--tools <tools>', '설치할 에이전트 CLI 지정 (쉼표 구분)')
125
+ .option('--all', '감지된 모든 에이전트 CLI에 설치')
125
126
  .option('--auto', '대화형 프롬프트 없이 자동으로 모든 감지된 CLI에 설치')
126
127
  .action(async (opts) => {
127
128
  const json = program.opts().json ?? false;
128
- // auto mode: --auto flag, --json flag, or stdin is not a TTY
129
- const autoMode = opts.auto === true || json || !process.stdin.isTTY;
129
+ // auto mode: --auto flag, --all flag, or stdin is not a TTY (but NOT --json alone)
130
+ const autoMode = opts.auto === true || opts.all === true || !process.stdin.isTTY;
130
131
  const projectPath = process.cwd();
131
132
  const detected = (0, ai_tools_js_1.detectAgentCLIs)(projectPath);
132
133
  const detectedIds = new Set(detected.map((t) => t.value));
133
134
  const isBuilder = isTeamProject(projectPath);
135
+ // ── 0. --json 모드에서 --tools/--all 없으면 MISSING_TOOLS 에러 ──
136
+ if (json && !opts.tools && !opts.all && !opts.auto) {
137
+ const detectedOptions = detected.map((t) => ({ value: t.value, label: t.name }));
138
+ if (detectedOptions.length === 0) {
139
+ detectedOptions.push(...ai_tools_js_1.AI_TOOLS.slice(0, 5).map((t) => ({ value: t.value, label: t.name })));
140
+ }
141
+ console.error(JSON.stringify({
142
+ error: 'MISSING_TOOLS',
143
+ message: '설치할 에이전트 CLI를 선택하세요.',
144
+ fix: `relay init --tools <도구1,도구2> --json 또는 relay init --all --json`,
145
+ options: detectedOptions,
146
+ }));
147
+ process.exit(1);
148
+ }
134
149
  // ── 1. 글로벌 User 커맨드 설치 ──
135
150
  let globalStatus = 'already';
136
151
  let globalTools = [];
@@ -15,18 +15,21 @@ const preamble_js_1 = require("../lib/preamble.js");
15
15
  const join_js_1 = require("./join.js");
16
16
  const init_js_1 = require("./init.js");
17
17
  /**
18
- * slugInput이 "@spaces/{spaceSlug}/{teamSlug}" 형식이면 파싱해 반환.
18
+ * slugInput이 "@spaces/{spaceSlug}/{teamSlug}" 또는 "@{spaceSlug}/{teamSlug}" 형식이면 파싱해 반환.
19
19
  * 아니면 null.
20
20
  */
21
21
  function parseSpaceTarget(slugInput) {
22
- const m = slugInput.match(/^@spaces\/([a-z0-9][a-z0-9-]*)\/([a-z0-9][a-z0-9-]*)$/);
23
- if (!m)
24
- return null;
25
- return {
26
- spaceSlug: m[1],
27
- rawTeamSlug: m[2],
28
- teamSlug: `@${m[1]}/${m[2]}`,
29
- };
22
+ // @spaces/{spaceSlug}/{teamSlug} 형식 (기존)
23
+ const m1 = slugInput.match(/^@spaces\/([a-z0-9][a-z0-9-]*)\/([a-z0-9][a-z0-9-]*)$/);
24
+ if (m1) {
25
+ return { spaceSlug: m1[1], rawTeamSlug: m1[2], teamSlug: `@${m1[1]}/${m1[2]}` };
26
+ }
27
+ // @{spaceSlug}/{teamSlug} 형식 (신규)
28
+ const m2 = slugInput.match(/^@([a-z0-9][a-z0-9-]*)\/([a-z0-9][a-z0-9-]*)$/);
29
+ if (m2) {
30
+ return { spaceSlug: m2[1], rawTeamSlug: m2[2], teamSlug: `@${m2[1]}/${m2[2]}` };
31
+ }
32
+ return null;
30
33
  }
31
34
  function registerInstall(program) {
32
35
  program
@@ -69,20 +72,29 @@ function registerInstall(program) {
69
72
  let team;
70
73
  let slug;
71
74
  let parsed;
75
+ // Whether the spaceTarget was matched via the ambiguous @{slug}/{team} pattern
76
+ // (i.e. NOT the explicit @spaces/... prefix). Used for 404 fallback below.
77
+ const isAmbiguousSpaceTarget = spaceTarget !== null && !slugInput.startsWith('@spaces/');
72
78
  if (spaceTarget) {
73
79
  // Space 팀: POST /api/spaces/{spaceSlug}/teams/{teamSlug}/install
74
80
  // This verifies membership, increments install count, and returns metadata.
81
+ let usedSpacePath = true;
75
82
  try {
76
83
  team = await (0, api_js_1.installSpaceTeam)(spaceTarget.spaceSlug, spaceTarget.rawTeamSlug);
77
84
  }
78
85
  catch (fetchErr) {
79
86
  const fetchMsg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
80
- if (fetchMsg.includes('403')) {
87
+ if (fetchMsg.includes('404') && isAmbiguousSpaceTarget) {
88
+ // Space not found — @{owner}/{team} is actually a normal registry slug, fall back
89
+ usedSpacePath = false;
90
+ }
91
+ else if (fetchMsg.includes('403')) {
81
92
  if (json) {
82
93
  console.error(JSON.stringify({
83
94
  error: 'SPACE_ONLY',
84
95
  message: '이 팀은 Space 멤버만 설치 가능합니다.',
85
96
  spaceSlug: spaceTarget.spaceSlug,
97
+ fix: `relay join ${spaceTarget.spaceSlug} --code <초대코드>로 Space에 가입한 후 재시도하세요.`,
86
98
  }));
87
99
  }
88
100
  else {
@@ -91,11 +103,22 @@ function registerInstall(program) {
91
103
  }
92
104
  process.exit(1);
93
105
  }
94
- throw fetchErr;
106
+ else {
107
+ throw fetchErr;
108
+ }
109
+ }
110
+ if (!usedSpacePath) {
111
+ // Fallback: treat as normal registry install
112
+ parsed = await (0, slug_js_1.resolveSlug)(slugInput);
113
+ slug = parsed.full;
114
+ team = await (0, api_js_1.fetchTeamInfo)(slug);
115
+ }
116
+ else {
117
+ // slug from server is "@spaces/{spaceSlug}/{teamSlug}" — derive local path parts
118
+ // team is guaranteed assigned by installSpaceTeam above (usedSpacePath === true)
119
+ parsed = { owner: spaceTarget.spaceSlug, name: spaceTarget.rawTeamSlug, full: team.slug };
120
+ slug = team.slug;
95
121
  }
96
- // slug from server is "@spaces/{spaceSlug}/{teamSlug}" — derive local path parts
97
- parsed = { owner: spaceTarget.spaceSlug, name: spaceTarget.rawTeamSlug, full: team.slug };
98
- slug = team.slug;
99
122
  }
100
123
  else {
101
124
  // Normal registry install
@@ -107,15 +130,44 @@ function registerInstall(program) {
107
130
  catch (fetchErr) {
108
131
  const fetchMsg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
109
132
  if (fetchMsg.includes('403')) {
110
- // Parse join_policy and membership_status from error body if available
133
+ // Parse error body for join_policy, membership_status, visibility, purchase_info
111
134
  let joinPolicy;
112
135
  let membershipStatus;
136
+ let errorVisibility;
137
+ let purchaseInfo;
113
138
  try {
114
139
  const errBody = JSON.parse(fetchMsg.replace(/^.*?(\{)/, '{'));
115
140
  joinPolicy = typeof errBody.join_policy === 'string' ? errBody.join_policy : undefined;
116
141
  membershipStatus = typeof errBody.membership_status === 'string' ? errBody.membership_status : undefined;
142
+ errorVisibility = typeof errBody.visibility === 'string' ? errBody.visibility : undefined;
143
+ if (errBody.purchase_info && typeof errBody.purchase_info === 'object') {
144
+ purchaseInfo = errBody.purchase_info;
145
+ }
117
146
  }
118
147
  catch { /* ignore parse errors */ }
148
+ // Gated team: show purchase info + relay access hint
149
+ if (errorVisibility === 'gated' || purchaseInfo) {
150
+ if (json) {
151
+ console.error(JSON.stringify({
152
+ error: 'GATED_ACCESS_REQUIRED',
153
+ message: '이 팀은 접근 권한이 필요합니다.',
154
+ slug,
155
+ purchase_info: purchaseInfo ?? null,
156
+ fix: '접근 링크 코드가 있으면: relay access <slug> --code <코드>',
157
+ }));
158
+ }
159
+ else {
160
+ console.error('\x1b[31m🔒 이 팀은 접근 권한이 필요합니다.\x1b[0m');
161
+ if (purchaseInfo?.message) {
162
+ console.error(`\n \x1b[36m${purchaseInfo.message}\x1b[0m`);
163
+ }
164
+ if (purchaseInfo?.url) {
165
+ console.error(` \x1b[36m${purchaseInfo.url}\x1b[0m`);
166
+ }
167
+ console.error(`\n\x1b[33m접근 링크 코드가 있으면: relay access ${slugInput} --code <코드>\x1b[0m`);
168
+ }
169
+ process.exit(1);
170
+ }
119
171
  if (joinPolicy === 'auto') {
120
172
  // Auto-join the Space then retry install
121
173
  if (!json) {
@@ -132,7 +184,7 @@ function registerInstall(program) {
132
184
  catch (joinErr) {
133
185
  const joinMsg = joinErr instanceof Error ? joinErr.message : String(joinErr);
134
186
  if (json) {
135
- console.error(JSON.stringify({ error: 'JOIN_FAILED', message: joinMsg, slug }));
187
+ console.error(JSON.stringify({ error: 'JOIN_FAILED', message: joinMsg, slug, fix: 'Space slug와 초대 코드를 확인 후 재시도하세요.' }));
136
188
  }
137
189
  else {
138
190
  console.error(`\x1b[31mSpace 가입 실패: ${joinMsg}\x1b[0m`);
@@ -145,9 +197,10 @@ function registerInstall(program) {
145
197
  if (json) {
146
198
  console.error(JSON.stringify({
147
199
  error: 'APPROVAL_REQUIRED',
148
- message: `가입 신청이 필요합니다. \`relay join @${spaceSlug}\`로 가입하세요.`,
200
+ message: `가입 신청이 필요합니다.`,
149
201
  slug,
150
202
  spaceSlug,
203
+ fix: `relay join @${spaceSlug} --code <초대코드>로 Space에 가입한 후 재시도하세요. 초대 코드는 Space 관리자에게 요청하세요.`,
151
204
  }));
152
205
  }
153
206
  else {
@@ -162,6 +215,7 @@ function registerInstall(program) {
162
215
  error: 'NO_ACCESS',
163
216
  message: '이 팀에 대한 접근 권한이 없습니다.',
164
217
  slug,
218
+ fix: '이 팀의 접근 링크 코드가 있으면 `relay access ' + slugInput + ' --code <코드>`로 접근 권한을 얻으세요. 없으면 팀 제작자에게 문의하세요.',
165
219
  }));
166
220
  }
167
221
  else {
@@ -175,6 +229,7 @@ function registerInstall(program) {
175
229
  error: 'SPACE_ONLY',
176
230
  message: '이 팀은 Space 멤버만 설치 가능합니다.',
177
231
  slug,
232
+ fix: 'Space 관리자에게 초대 코드를 요청한 후 `relay join <space-slug> --code <코드>`로 가입하세요.',
178
233
  }));
179
234
  }
180
235
  else {
@@ -189,6 +244,8 @@ function registerInstall(program) {
189
244
  }
190
245
  }
191
246
  }
247
+ if (!team)
248
+ throw new Error('팀 정보를 가져오지 못했습니다.');
192
249
  const teamDir = path_1.default.join(projectPath, '.relay', 'teams', parsed.owner, parsed.name);
193
250
  // 2. Visibility check + auto-login
194
251
  const visibility = team.visibility ?? 'public';
@@ -210,6 +267,7 @@ function registerInstall(program) {
210
267
  visibility,
211
268
  slug,
212
269
  message: '이 팀은 로그인이 필요합니다. relay login을 먼저 실행하세요.',
270
+ fix: 'relay login 실행 후 재시도하세요.',
213
271
  }));
214
272
  }
215
273
  else {
@@ -219,8 +277,30 @@ function registerInstall(program) {
219
277
  }
220
278
  }
221
279
  }
222
- // 3. Download package
223
- const tarPath = await (0, storage_js_1.downloadPackage)(team.package_url, tempDir);
280
+ // 3. Download package (retry once if signed URL expired)
281
+ let tarPath;
282
+ try {
283
+ tarPath = await (0, storage_js_1.downloadPackage)(team.package_url, tempDir);
284
+ }
285
+ catch (dlErr) {
286
+ const dlMsg = dlErr instanceof Error ? dlErr.message : String(dlErr);
287
+ if (dlMsg.includes('403') || dlMsg.includes('expired')) {
288
+ // Signed URL expired — re-fetch team info for new URL and retry
289
+ if (!json) {
290
+ console.error('\x1b[33m⚙ 다운로드 URL 만료, 재시도 중...\x1b[0m');
291
+ }
292
+ if (spaceTarget) {
293
+ team = await (0, api_js_1.installSpaceTeam)(spaceTarget.spaceSlug, spaceTarget.rawTeamSlug);
294
+ }
295
+ else {
296
+ team = await (0, api_js_1.fetchTeamInfo)(slug);
297
+ }
298
+ tarPath = await (0, storage_js_1.downloadPackage)(team.package_url, tempDir);
299
+ }
300
+ else {
301
+ throw dlErr;
302
+ }
303
+ }
224
304
  // 4. Extract to .relay/teams/<slug>/
225
305
  if (fs_1.default.existsSync(teamDir)) {
226
306
  fs_1.default.rmSync(teamDir, { recursive: true, force: true });
@@ -326,7 +406,7 @@ function registerInstall(program) {
326
406
  }
327
407
  catch (err) {
328
408
  const message = err instanceof Error ? err.message : String(err);
329
- console.error(JSON.stringify({ error: 'INSTALL_FAILED', message }));
409
+ console.error(JSON.stringify({ error: 'INSTALL_FAILED', message, fix: message }));
330
410
  process.exit(1);
331
411
  }
332
412
  finally {
@@ -57,7 +57,7 @@ function registerJoin(program) {
57
57
  console.error('\x1b[33m⚠ relay init이 실행되지 않았습니다. 먼저 relay init을 실행하세요.\x1b[0m');
58
58
  }
59
59
  else {
60
- console.error(JSON.stringify({ error: 'NOT_INITIALIZED', message: 'relay init을 먼저 실행하세요.' }));
60
+ console.error(JSON.stringify({ error: 'NOT_INITIALIZED', message: 'relay init을 먼저 실행하세요.', fix: 'relay init 실행하세요.' }));
61
61
  }
62
62
  process.exit(1);
63
63
  }
@@ -92,7 +92,8 @@ function registerJoin(program) {
92
92
  const desc = t.description ? ` \x1b[90m— ${t.description}\x1b[0m` : '';
93
93
  console.log(` \x1b[36m•\x1b[0m \x1b[1m${t.slug}\x1b[0m${desc}`);
94
94
  }
95
- console.log(`\n\x1b[33m💡 설치: relay install @spaces/${slug}/<팀슬러그>\x1b[0m`);
95
+ console.log(`\n\x1b[33m💡 전체 설치: relay install @spaces/${slug}/<팀슬러그>\x1b[0m`);
96
+ console.log(`\x1b[33m💡 가이드 URL 공유: https://relayax.com/api/spaces/${slug}/guide.md\x1b[0m`);
96
97
  }
97
98
  }
98
99
  }
@@ -117,6 +118,7 @@ function registerJoin(program) {
117
118
  console.error(JSON.stringify({
118
119
  error: 'LOGIN_REQUIRED',
119
120
  message: '로그인이 필요합니다. relay login 을 먼저 실행하세요.',
121
+ fix: 'relay login 실행 후 재시도하세요.',
120
122
  }));
121
123
  }
122
124
  else {
@@ -126,7 +128,7 @@ function registerJoin(program) {
126
128
  process.exit(1);
127
129
  }
128
130
  if (json) {
129
- console.error(JSON.stringify({ error: 'JOIN_FAILED', message }));
131
+ console.error(JSON.stringify({ error: 'JOIN_FAILED', message, fix: 'Space slug와 초대 코드를 확인 후 재시도하세요.' }));
130
132
  }
131
133
  else {
132
134
  console.error(`\x1b[31m오류: ${message}\x1b[0m`);
@@ -29,7 +29,7 @@ function registerList(program) {
29
29
  const token = await (0, config_js_1.getValidToken)();
30
30
  if (!token) {
31
31
  if (json) {
32
- console.error(JSON.stringify({ error: 'LOGIN_REQUIRED', message: '로그인이 필요합니다. relay login을 먼저 실행하세요.' }));
32
+ console.error(JSON.stringify({ error: 'LOGIN_REQUIRED', message: '로그인이 필요합니다. relay login을 먼저 실행하세요.', fix: 'relay login 실행 후 재시도하세요.' }));
33
33
  }
34
34
  else {
35
35
  console.error('\x1b[31m오류: 로그인이 필요합니다.\x1b[0m');
@@ -59,7 +59,7 @@ function registerList(program) {
59
59
  catch (err) {
60
60
  const message = err instanceof Error ? err.message : String(err);
61
61
  if (json) {
62
- console.error(JSON.stringify({ error: 'FETCH_FAILED', message }));
62
+ console.error(JSON.stringify({ error: 'FETCH_FAILED', message, fix: '네트워크 연결을 확인하거나 잠시 후 재시도하세요.' }));
63
63
  }
64
64
  else {
65
65
  console.error(`\x1b[31m오류: ${message}\x1b[0m`);
@@ -148,7 +148,7 @@ function registerLogin(program) {
148
148
  catch (err) {
149
149
  const msg = err instanceof Error ? err.message : '로그인 실패';
150
150
  if (json) {
151
- console.error(JSON.stringify({ error: 'LOGIN_FAILED', message: msg }));
151
+ console.error(JSON.stringify({ error: 'LOGIN_FAILED', message: msg, fix: '브라우저에서 로그인을 완료하고 relay login을 재시도하세요.' }));
152
152
  }
153
153
  else {
154
154
  console.error(`\x1b[31m오류: ${msg}\x1b[0m`);
@@ -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'
@@ -281,6 +282,8 @@ function registerPublish(program) {
281
282
  .command('publish')
282
283
  .description('현재 팀 패키지를 Space에 배포합니다 (relay.yaml 필요)')
283
284
  .option('--token <token>', '인증 토큰')
285
+ .option('--space <slug>', '배포할 Space 지정')
286
+ .option('--version <version>', '배포 버전 지정 (relay.yaml 업데이트)')
284
287
  .action(async (opts) => {
285
288
  const json = program.opts().json ?? false;
286
289
  const teamDir = process.cwd();
@@ -317,6 +320,7 @@ function registerPublish(program) {
317
320
  console.error(JSON.stringify({
318
321
  error: 'NOT_INITIALIZED',
319
322
  message: '.relay/relay.yaml이 없습니다. 먼저 `relay create`를 실행하세요.',
323
+ fix: 'relay create 또는 .relay/relay.yaml을 생성하세요.',
320
324
  }));
321
325
  process.exit(1);
322
326
  }
@@ -345,12 +349,16 @@ function registerPublish(program) {
345
349
  const visibility = await promptSelect({
346
350
  message: '공개 범위:',
347
351
  choices: [
348
- { name: '공개', value: 'public' },
349
- { name: '비공개 (Space 멤버만)', value: 'private' },
352
+ { name: '공개 — 누구나 설치', value: 'public' },
353
+ { name: '링크 공유 — 접근 링크가 있는 사람만 설치', value: 'gated' },
354
+ { name: '비공개 — Space 멤버만', value: 'private' },
350
355
  ],
351
356
  });
352
357
  console.error('\n\x1b[2m💡 프로필에 연락처를 설정하면 설치 시 명함이 전달됩니다: www.relayax.com/dashboard/profile\x1b[0m');
353
- if (visibility === 'private') {
358
+ if (visibility === 'gated') {
359
+ console.error('\x1b[2m💡 링크 공유 팀은 웹 대시보드에서 접근 링크와 구매 안내를 설정하세요: www.relayax.com/dashboard\x1b[0m');
360
+ }
361
+ else if (visibility === 'private') {
354
362
  console.error('\x1b[2m💡 비공개 팀은 Space를 통해 멤버를 관리하세요: www.relayax.com/dashboard/teams\x1b[0m');
355
363
  }
356
364
  console.error('');
@@ -377,11 +385,18 @@ function registerPublish(program) {
377
385
  console.error(JSON.stringify({
378
386
  error: 'INVALID_CONFIG',
379
387
  message: 'relay.yaml에 name, slug, description이 필요합니다.',
388
+ fix: 'relay.yaml에 name, slug, description을 확인하세요.',
380
389
  }));
381
390
  process.exit(1);
382
391
  }
383
- // Version bump suggestion for republishing
384
- if (isTTY) {
392
+ // Version bump: --version flag takes priority
393
+ if (opts.version) {
394
+ config.version = opts.version;
395
+ const yamlData = js_yaml_1.default.load(fs_1.default.readFileSync(relayYamlPath, 'utf-8'));
396
+ yamlData.version = opts.version;
397
+ fs_1.default.writeFileSync(relayYamlPath, js_yaml_1.default.dump(yamlData, { lineWidth: 120 }), 'utf-8');
398
+ }
399
+ else if (isTTY) {
385
400
  const { select: promptVersion } = await import('@inquirer/prompts');
386
401
  const [major, minor, patch] = config.version.split('.').map(Number);
387
402
  const bumpPatch = `${major}.${minor}.${patch + 1}`;
@@ -415,6 +430,7 @@ function registerPublish(program) {
415
430
  console.error(JSON.stringify({
416
431
  error: 'EMPTY_PACKAGE',
417
432
  message: '.relay/ 안에 skills/, agents/, rules/, commands/ 중 하나 이상에 파일이 있어야 합니다.',
433
+ fix: '.relay/ 안에 skills/, agents/, rules/, commands/ 중 하나에 파일을 추가하세요.',
418
434
  }));
419
435
  process.exit(1);
420
436
  }
@@ -424,71 +440,110 @@ function registerPublish(program) {
424
440
  console.error(JSON.stringify({
425
441
  error: 'NO_TOKEN',
426
442
  message: '인증이 필요합니다. `relay login`을 먼저 실행하세요.',
443
+ fix: 'relay login 실행 후 재시도하세요.',
427
444
  }));
428
445
  process.exit(1);
429
446
  }
430
447
  // Fetch user's Spaces and select publish target
431
448
  let selectedSpaceId;
432
- let selectedSpaceIsPersonal = true;
449
+ let selectedSpaceSlug;
450
+ let selectedJoinPolicy;
433
451
  try {
434
452
  const { fetchMySpaces } = await import('./spaces.js');
435
453
  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) {
454
+ // --space flag: resolve Space by slug
455
+ if (opts.space) {
456
+ const matched = spaces.find((s) => s.slug === opts.space);
457
+ if (matched) {
458
+ selectedSpaceId = matched.id;
459
+ selectedSpaceSlug = matched.slug;
460
+ selectedJoinPolicy = matched.join_policy;
461
+ }
462
+ else {
463
+ if (json) {
464
+ console.error(JSON.stringify({
465
+ error: 'INVALID_SPACE',
466
+ message: `Space '${opts.space}'를 찾을 수 없습니다.`,
467
+ fix: `사용 가능한 Space: ${spaces.map((s) => s.slug).join(', ')}`,
468
+ options: spaces.map((s) => ({ value: s.slug, label: `${s.name} (${s.slug})` })),
469
+ }));
470
+ }
471
+ else {
472
+ console.error(`Space '${opts.space}'를 찾을 수 없습니다.`);
473
+ }
474
+ process.exit(1);
475
+ }
476
+ }
477
+ else if (isTTY) {
439
478
  if (spaces.length === 0) {
440
479
  // No spaces at all — publish without space_id
441
480
  console.error('\x1b[33m⚠ 소속 Space가 없습니다. 개인 계정으로 배포합니다.\x1b[0m\n');
442
481
  }
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`);
482
+ else if (spaces.length === 1) {
483
+ // Only one Space — auto-select regardless of type
484
+ selectedSpaceId = spaces[0].id;
485
+ selectedSpaceSlug = spaces[0].slug;
486
+ selectedJoinPolicy = spaces[0].join_policy;
487
+ console.error(`\x1b[2m Space: ${spaces[0].name} (${spaces[0].slug})\x1b[0m\n`);
448
488
  }
449
489
  else {
450
490
  // Multiple spaces — prompt user
451
491
  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
- ];
492
+ const spaceChoices = spaces.map((s) => ({
493
+ name: `${s.name} (${s.slug})`,
494
+ value: s.id,
495
+ slug: s.slug,
496
+ join_policy: s.join_policy,
497
+ }));
456
498
  const chosenId = await selectSpace({
457
499
  message: '어떤 Space에 배포할까요?',
458
500
  choices: spaceChoices.map((c) => ({ name: c.name, value: c.value })),
459
501
  });
460
502
  const chosen = spaceChoices.find((c) => c.value === chosenId);
461
503
  selectedSpaceId = chosenId;
462
- selectedSpaceIsPersonal = chosen?.isPersonal ?? false;
504
+ selectedSpaceSlug = chosen?.slug;
505
+ selectedJoinPolicy = chosen?.join_policy;
463
506
  const chosenLabel = chosen?.name ?? chosenId;
464
507
  console.error(` → Space: ${chosenLabel}\n`);
465
508
  }
466
509
  }
467
- else if (personalSpace) {
468
- selectedSpaceId = personalSpace.id;
469
- selectedSpaceIsPersonal = true;
510
+ else if (spaces.length > 1 && json) {
511
+ // --json 모드 + 여러 Space: 에이전트가 선택할 수 있도록 에러 반환
512
+ console.error(JSON.stringify({
513
+ error: 'MISSING_SPACE',
514
+ message: '배포할 Space를 선택하세요.',
515
+ fix: `relay publish --space <slug> --json`,
516
+ options: spaces.map((s) => ({ value: s.slug, label: `${s.name} (${s.slug})` })),
517
+ }));
518
+ process.exit(1);
519
+ }
520
+ else if (spaces.length > 0) {
521
+ selectedSpaceId = spaces[0].id;
522
+ selectedSpaceSlug = spaces[0].slug;
523
+ selectedJoinPolicy = spaces[0].join_policy;
470
524
  }
471
525
  }
472
526
  catch {
473
527
  // Space 조회 실패 시 무시하고 계속 진행
474
528
  }
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 기본값)';
529
+ // Visibility default based on join_policy: approvalprivate, otherwisepublic
530
+ const defaultVisibility = selectedJoinPolicy === 'approval' ? 'private' : 'public';
480
531
  // Visibility validation: must be explicitly set
481
532
  if (!config.visibility) {
482
533
  if (isTTY) {
483
534
  const { select: promptSelect } = await import('@inquirer/prompts');
484
- console.error(`\n\x1b[33m⚠ relay.yaml에 visibility가 설정되지 않았습니다.\x1b[0m (기본값: ${defaultVisLabel})`);
535
+ console.error(`\n\x1b[33m⚠ relay.yaml에 visibility가 설정되지 않았습니다.\x1b[0m (기본값: ${defaultVisibility === 'public' ? '공개' : '비공개'})`);
485
536
  config.visibility = await promptSelect({
486
537
  message: '공개 범위를 선택하세요:',
487
538
  choices: [
488
539
  {
489
- name: `공개 — Space에 누구나 검색·설치${defaultVisibility === 'public' ? ' ✓ 추천' : ''}`,
540
+ name: `공개 — 누구나 설치${defaultVisibility === 'public' ? ' ✓ 추천' : ''}`,
490
541
  value: 'public',
491
542
  },
543
+ {
544
+ name: '링크 공유 — 접근 링크가 있는 사람만 설치',
545
+ value: 'gated',
546
+ },
492
547
  {
493
548
  name: `비공개 — Space 멤버만 접근${defaultVisibility === 'private' ? ' ✓ 추천' : ''}`,
494
549
  value: 'private',
@@ -505,7 +560,13 @@ function registerPublish(program) {
505
560
  else {
506
561
  console.error(JSON.stringify({
507
562
  error: 'MISSING_VISIBILITY',
508
- message: 'relay.yaml에 visibility (public 또는 private)를 설정해주세요.',
563
+ message: 'relay.yaml에 visibility를 설정해주세요.',
564
+ options: [
565
+ { value: 'public', label: '공개 — 누구나 설치' },
566
+ { value: 'gated', label: '링크 공유 — 접근 링크가 있는 사람만 설치' },
567
+ { value: 'private', label: '비공개 — Space 멤버만 접근' },
568
+ ],
569
+ fix: 'relay.yaml의 visibility 필드를 위 옵션 중 하나로 설정하세요.',
509
570
  }));
510
571
  process.exit(1);
511
572
  }
@@ -513,23 +574,36 @@ function registerPublish(program) {
513
574
  // Confirm visibility before publish (재배포 시 변경 기회 제공)
514
575
  if (isTTY) {
515
576
  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' ? '공개' : '비공개'} — 유지할까요?`,
577
+ const visLabelMap = {
578
+ public: '공개',
579
+ gated: '링크공유',
580
+ private: '비공개',
581
+ };
582
+ const currentVisLabel = visLabelMap[config.visibility ?? 'public'] ?? config.visibility;
583
+ const newVisibility = await promptConfirmVis({
584
+ message: `공개 범위: ${currentVisLabel} — 유지하거나 변경하세요`,
521
585
  choices: [
522
- { name: `유지 — ${visLabel}`, value: 'keep' },
523
- { name: '변경', value: 'change' },
586
+ {
587
+ name: `공개 — 누구나 설치${defaultVisibility === 'public' ? ' ✓ 추천' : ''}`,
588
+ value: 'public',
589
+ },
590
+ {
591
+ name: '링크공유 — 접근 링크가 있는 사람만 설치',
592
+ value: 'gated',
593
+ },
594
+ {
595
+ name: `비공개 — Space 멤버만 접근${defaultVisibility === 'private' ? ' ✓ 추천' : ''}`,
596
+ value: 'private',
597
+ },
524
598
  ],
599
+ default: config.visibility ?? defaultVisibility,
525
600
  });
526
- if (visAction === 'change') {
527
- config.visibility = config.visibility === 'public' ? 'private' : 'public';
601
+ if (newVisibility !== config.visibility) {
602
+ config.visibility = newVisibility;
528
603
  const yamlData = js_yaml_1.default.load(fs_1.default.readFileSync(relayYamlPath, 'utf-8'));
529
604
  yamlData.visibility = config.visibility;
530
605
  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`);
606
+ console.error(` → relay.yaml에 visibility: ${config.visibility} 저장됨 (${visLabelMap[config.visibility]})\n`);
533
607
  }
534
608
  }
535
609
  // Profile hint
@@ -564,6 +638,7 @@ function registerPublish(program) {
564
638
  agent_details: detectedAgents,
565
639
  skill_details: detectedSkills,
566
640
  ...(selectedSpaceId ? { space_id: selectedSpaceId } : {}),
641
+ ...(selectedSpaceSlug ? { space_slug: selectedSpaceSlug } : {}),
567
642
  };
568
643
  if (!json) {
569
644
  console.error(`패키지 생성 중... (${config.name} v${config.version})`);
@@ -636,34 +711,22 @@ function registerPublish(program) {
636
711
  }
637
712
  // Show shareable onboarding guide as a plain copyable block
638
713
  if (isTTY) {
639
- const guideLines = [
640
- 'npm install -g relayax-cli',
641
- 'relay login',
642
- `relay install ${result.slug}`,
643
- ];
644
- // Type-based usage hint
645
- const teamType = config.type ?? 'hybrid';
646
- if (teamType === 'passive') {
647
- guideLines.push('', '# 설치하면 자동 적용됩니다');
648
- }
649
- else if (detectedCommands.length > 0) {
650
- const usageCmd = teamType === 'hybrid'
651
- ? `# 자동 적용 + /${detectedCommands[0].name} 으로 추가 기능`
652
- : `# 사용법: /${detectedCommands[0].name}`;
653
- guideLines.push('', usageCmd);
654
- }
714
+ const detailSlug = result.slug.startsWith('@') ? result.slug.slice(1) : result.slug;
715
+ const guideUrl = `https://relayax.com/api/registry/${detailSlug}/guide.md`;
655
716
  console.log(`\n \x1b[90m주변인에게 공유하세요:\x1b[0m\n`);
656
717
  console.log('```');
657
- guideLines.forEach((line) => console.log(line));
718
+ console.log('아래 가이드를 읽고 그대로 따라해줘:');
719
+ console.log(guideUrl);
658
720
  console.log('```');
659
- const detailSlug = result.slug.startsWith('@') ? result.slug.slice(1) : result.slug;
721
+ console.log(`\n \x1b[90mCLI 설치된 사용자용 (짧은 버전):\x1b[0m`);
722
+ console.log(` /relay:relay-install ${result.slug}`);
660
723
  console.log(`\n \x1b[90m상세페이지: \x1b[36mrelayax.com/@${detailSlug}\x1b[0m`);
661
724
  }
662
725
  }
663
726
  }
664
727
  catch (err) {
665
728
  const message = err instanceof Error ? err.message : String(err);
666
- console.error(JSON.stringify({ error: 'PUBLISH_FAILED', message }));
729
+ console.error(JSON.stringify({ error: 'PUBLISH_FAILED', message, fix: message }));
667
730
  process.exit(1);
668
731
  }
669
732
  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,8 @@ export interface SpaceInfo {
4
4
  slug: string;
5
5
  name: string;
6
6
  description: string | null;
7
- is_personal: boolean;
7
+ space_type?: string;
8
+ join_policy?: string;
8
9
  role: string;
9
10
  }
10
11
  export declare function fetchMySpaces(token: string): Promise<SpaceInfo[]>;
@@ -22,7 +22,7 @@ function registerSpaces(program) {
22
22
  const token = await (0, config_js_1.getValidToken)();
23
23
  if (!token) {
24
24
  if (json) {
25
- console.error(JSON.stringify({ error: 'LOGIN_REQUIRED', message: '로그인이 필요합니다. relay login을 먼저 실행하세요.' }));
25
+ console.error(JSON.stringify({ error: 'LOGIN_REQUIRED', message: '로그인이 필요합니다. relay login을 먼저 실행하세요.', fix: 'relay login 실행 후 재시도하세요.' }));
26
26
  }
27
27
  else {
28
28
  console.error('\x1b[31m오류: 로그인이 필요합니다.\x1b[0m');
@@ -36,29 +36,21 @@ function registerSpaces(program) {
36
36
  console.log(JSON.stringify({ spaces }));
37
37
  return;
38
38
  }
39
- const personal = spaces.find((s) => s.is_personal);
40
- const others = spaces.filter((s) => !s.is_personal);
41
- if (personal) {
42
- console.log(`\n\x1b[90m개인 스페이스:\x1b[0m`);
43
- console.log(` \x1b[36m${personal.slug}\x1b[0m \x1b[1m${personal.name}\x1b[0m`);
39
+ if (spaces.length === 0) {
40
+ console.log('\nSpace가 없습니다.');
41
+ console.log('\x1b[33m💡 Space를 만들려면: www.relayax.com/spaces/new\x1b[0m');
44
42
  }
45
- if (others.length > 0) {
46
- console.log(`\n\x1b[1m내 Space\x1b[0m (${others.length}개):\n`);
47
- for (const s of others) {
43
+ else {
44
+ console.log(`\n\x1b[1m내 Space\x1b[0m (${spaces.length}개):\n`);
45
+ for (const s of spaces) {
48
46
  const role = s.role === 'owner' ? '\x1b[33m소유자\x1b[0m'
49
- : s.role === 'admin' ? '\x1b[36m관리자\x1b[0m'
47
+ : s.role === 'builder' ? '\x1b[36m빌더\x1b[0m'
50
48
  : '\x1b[90m멤버\x1b[0m';
51
49
  const desc = s.description
52
50
  ? ` \x1b[90m${s.description.length > 40 ? s.description.slice(0, 40) + '...' : s.description}\x1b[0m`
53
51
  : '';
54
52
  console.log(` \x1b[36m${s.slug}\x1b[0m \x1b[1m${s.name}\x1b[0m ${role}${desc}`);
55
53
  }
56
- }
57
- if (!personal && others.length === 0) {
58
- console.log('\nSpace가 없습니다.');
59
- console.log('\x1b[33m💡 Space를 만들려면: www.relayax.com/spaces/new\x1b[0m');
60
- }
61
- if (others.length > 0) {
62
54
  console.log(`\n\x1b[33m💡 Space 팀 목록: relay list --space <slug>\x1b[0m`);
63
55
  console.log(`\x1b[33m💡 비공개 배포: relay.yaml에 visibility: private 설정 후 relay publish\x1b[0m`);
64
56
  }
@@ -66,7 +58,7 @@ function registerSpaces(program) {
66
58
  catch (err) {
67
59
  const message = err instanceof Error ? err.message : String(err);
68
60
  if (json) {
69
- console.error(JSON.stringify({ error: 'FETCH_FAILED', message }));
61
+ console.error(JSON.stringify({ error: 'FETCH_FAILED', message, fix: '네트워크 연결을 확인하거나 잠시 후 재시도하세요.' }));
70
62
  }
71
63
  else {
72
64
  console.error(`\x1b[31m오류: ${message}\x1b[0m`);
@@ -133,7 +133,7 @@ function registerUpdate(program) {
133
133
  }
134
134
  catch (err) {
135
135
  const message = err instanceof Error ? err.message : String(err);
136
- console.error(JSON.stringify({ error: 'UPDATE_FAILED', message }));
136
+ console.error(JSON.stringify({ error: 'UPDATE_FAILED', message, fix: 'npm update -g relayax-cli로 수동 업데이트하세요.' }));
137
137
  process.exit(1);
138
138
  }
139
139
  finally {
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ const join_js_1 = require("./commands/join.js");
20
20
  const spaces_js_1 = require("./commands/spaces.js");
21
21
  const deploy_record_js_1 = require("./commands/deploy-record.js");
22
22
  const ping_js_1 = require("./commands/ping.js");
23
+ const access_js_1 = require("./commands/access.js");
23
24
  // eslint-disable-next-line @typescript-eslint/no-var-requires
24
25
  const pkg = require('../package.json');
25
26
  const program = new commander_1.Command();
@@ -46,4 +47,5 @@ program
46
47
  (0, spaces_js_1.registerSpaces)(program);
47
48
  (0, deploy_record_js_1.registerDeployRecord)(program);
48
49
  (0, ping_js_1.registerPing)(program);
50
+ (0, access_js_1.registerAccess)(program);
49
51
  program.parse();
@@ -57,13 +57,46 @@ function getGlobalCommandPathForTool(skillsDir, commandId) {
57
57
  function formatCommandFile(content) {
58
58
  return `---\ndescription: ${content.description}\n---\n\n${content.body}\n`;
59
59
  }
60
- // ─── Login JIT 공통 안내 ───
61
- const LOGIN_JIT_GUIDE = `
62
- ### 인증 오류 처리
63
- - 커맨드 실행 결과에 \`LOGIN_REQUIRED\` 에러가 포함되면:
64
- 1. \`relay login\` 실행 (timeout 300초)
65
- - 브라우저가 자동으로 열리고, 사용자가 로그인을 완료하면 토큰이 자동 저장됩니다.
66
- 2. 완료 후 \`relay status --json\`으로 로그인 성공을 확인하고, 원래 커맨드를 재실행합니다.`;
60
+ // ─── 에러 처리 가이드 (결정적 실행 + 비결정적 조율) ───
61
+ const ERROR_HANDLING_GUIDE = `
62
+ ### 에러 처리 가이드
63
+
64
+ CLI 명령 실행 JSON 에러가 반환되면 아래 기준에 따라 처리합니다.
65
+ **원칙: "되돌릴 없는 영향이 있는가?"로 판단합니다.**
66
+
67
+ #### 1. 자동 해결 (사용자에게 물어보지 않음)
68
+ 되돌릴 수 있고, 부작용 없는 에러:
69
+
70
+ | 에러 코드 | 행동 |
71
+ |-----------|------|
72
+ | \`LOGIN_REQUIRED\` / \`NO_TOKEN\` | \`relay login\` 실행 (timeout 300초, 브라우저 자동 열림) → 성공 후 원래 명령 재시도 |
73
+ | \`NOT_INITIALIZED\` | \`relay init --all --json\` 실행 → 원래 명령 재시도 |
74
+ | \`FETCH_FAILED\` | 3초 대기 후 원래 명령 재시도 (최대 2회). 2회 실패 시 사용자에게 안내 |
75
+
76
+ #### 2. 사용자에게 선택지 제시 (AskUserQuestion)
77
+ \`options\` 필드가 있는 에러:
78
+
79
+ | 에러 코드 | 행동 |
80
+ |-----------|------|
81
+ | \`MISSING_VISIBILITY\` | options의 label을 선택지로 AskUserQuestion 호출 |
82
+ | \`MISSING_FIELD\` | fix 안내 + 사용자에게 값 입력 요청 |
83
+ | \`MISSING_TOOLS\` | options의 감지된 도구 목록을 선택지로 AskUserQuestion 호출 |
84
+ | \`MISSING_SPACE\` | options의 Space 목록을 선택지로 AskUserQuestion 호출 |
85
+
86
+ 사용자가 선택하면, 선택된 값을 CLI 플래그에 반영하여 명령을 재호출합니다.
87
+
88
+ #### 3. 사용자에게 안내 (되돌릴 수 없는 에러)
89
+ 구매, 접근 권한, 보안 관련:
90
+
91
+ | 에러 코드 | 행동 |
92
+ |-----------|------|
93
+ | \`GATED_ACCESS_REQUIRED\` | purchase_info의 message/url 표시 → "접근 코드가 있으신가요?" AskUserQuestion |
94
+ | \`SPACE_ONLY\` | Space 가입 필요 안내 → "초대 코드가 있으신가요?" AskUserQuestion |
95
+ | \`APPROVAL_REQUIRED\` | 승인 대기 안내 |
96
+ | \`NO_ACCESS\` | 접근 방법 안내 |
97
+
98
+ #### 4. 그 외 에러
99
+ \`fix\` 필드의 메시지를 사용자에게 전달하고, 필요하면 다음 행동을 제안합니다.`;
67
100
  // ─── 명함 표시 포맷 ───
68
101
  const BUSINESS_CARD_FORMAT = `
69
102
  ### 빌더 명함 표시
@@ -128,7 +161,7 @@ slug가 직접 주어지면 (\`/relay-install @alice/doc-writer\`) 이 단계를
128
161
 
129
162
  **AskUserQuestion 호출:**
130
163
  - question: "어디서 팀을 찾을까요?"
131
- - options: Space가 있으면 \`["<space1_name> (개인)", "<space2_name>", ...]\`, 없으면 이 단계를 건너뛰고 바로 내 Space 탐색으로 진행
164
+ - options: Space가 있으면 \`["<space1_name>", "<space2_name>", ...]\`, 없으면 이 단계를 건너뛰고 바로 내 Space 탐색으로 진행
132
165
 
133
166
  **응답 처리:**
134
167
  - Space 이름 선택 → 1-2. Space 팀 탐색으로 진행
@@ -177,13 +210,24 @@ slug가 직접 주어지면 (\`/relay-install @alice/doc-writer\`) 이 단계를
177
210
 
178
211
  #### 2-1. 패키지 다운로드
179
212
  \`relay install <@space/team> --json\` 명령어를 실행합니다.
180
- - 공개 팀: \`relay install <@space/team> --json\`
181
- - Space 팀 (비공개): \`relay install @<space-slug>/<team-slug> --json\`
213
+ - 공개 (public): \`relay install <@space/team> --json\`
214
+ - 링크 공유 팀 (gated): \`relay install <slug> --json\`
215
+ - 접근 권한이 없으면 CLI가 **purchase_info** (구매 안내 메시지 + URL)를 표시합니다.
216
+ - 접근 링크 코드가 있으면: \`relay access <slug> --code <code>\` 로 접근 부여 + 자동 설치를 한번에 수행합니다.
217
+ - Space 멤버이면 접근 확인 없이 바로 설치됩니다.
218
+ - Space 팀 (비공개/private): \`relay install @<space-slug>/<team-slug> --json\`
182
219
  - Space 가입이 필요하면: \`relay join <space-slug> --code <invite-code>\` 를 먼저 실행합니다.
183
220
  - 또는 \`--join-code <code>\`로 가입+설치를 한번에 할 수 있습니다.
184
221
  - CLI가 init과 login을 자동으로 처리합니다 (사용자가 별도 실행할 필요 없음).
185
222
  - JSON 출력에서 \`install_path\` (패키지 경로)를 확인합니다.
186
223
 
224
+ **gated 팀 접근 거부 처리:**
225
+ - CLI가 403 + \`GATED_ACCESS_REQUIRED\` 에러를 반환하면:
226
+ 1. purchase_info의 message와 url을 사용자에게 표시합니다.
227
+ 2. "접근 링크 코드가 있으신가요?"라고 물어봅니다.
228
+ 3. 코드가 있으면 \`relay access <slug> --code <code>\`를 실행합니다.
229
+ 4. 코드가 없으면 purchase_info의 url로 구매 안내합니다.
230
+
187
231
  #### 2-2. 배치 범위 선택 (추천 포함)
188
232
 
189
233
  팀의 성격을 분석하여 글로벌/로컬 중 적합한 쪽을 추천합니다.
@@ -283,7 +327,7 @@ requires 섹션이 없거나 비어있으면 이 단계를 건너뜁니다.
283
327
  \`\`\`
284
328
  ⚠️ 필수 요구사항이 충족되지 않았습니다. 팀 기능이 제한될 수 있습니다.
285
329
  \`\`\`
286
- ${LOGIN_JIT_GUIDE}
330
+ ${ERROR_HANDLING_GUIDE}
287
331
 
288
332
  ### Step 3. 완료 & 팔로우 제안
289
333
 
@@ -308,18 +352,12 @@ ${BUSINESS_CARD_FORMAT}
308
352
  \`\`\`
309
353
  주변인에게 공유하세요:
310
354
 
311
- npm install -g relayax-cli
312
- relay login
313
- relay install {slug}
314
-
315
- 상세페이지: relayax.com/@{owner}/{team}
355
+ 아래 가이드를 읽고 그대로 따라해줘:
356
+ https://relayax.com/api/registry/{owner}/{slug}/guide.md
316
357
  \`\`\`
317
358
 
318
- - \`{slug}\`가 \`@owner/team\` 형식이면 상세페이지는 \`relayax.com/@{owner}/{team}\`으로 치환합니다.
319
-
320
- - type 기반 사용법도 포함:
321
- - command/hybrid: \`# 사용법: /{첫번째 커맨드}\`
322
- - passive: \`# 설치하면 자동 적용됩니다\`
359
+ - \`{slug}\`가 \`@owner/team\` 형식이면 \`{owner}\`과 \`{slug}\`를 분리하여 URL을 구성합니다.
360
+ - CLI가 이미 설치된 사용자를 위한 짧은 버전도 함께 표시합니다: \`/relay:relay-install {slug}\`
323
361
 
324
362
  #### 3-4. 사용 제안
325
363
  - "바로 사용해볼까요?" 제안
@@ -333,8 +371,8 @@ relay install {slug}
333
371
 
334
372
  ### 인터랙티브 모드 (/relay-install)
335
373
  → relay spaces --json 실행
336
- → AskUserQuestion: "어디서 팀을 찾을까요?" → ["개인 Space (alice)", "Acme Corp"]
337
- → "개인 Space" 선택 → "어떤 팀을 찾고 계세요?"
374
+ → AskUserQuestion: "어디서 팀을 찾을까요?" → ["Alice's Space (alice)", "Acme Corp"]
375
+ → "Alice's Space" 선택 → "어떤 팀을 찾고 계세요?"
338
376
  → relay search "문서" 실행 → 결과 리스트 표시
339
377
  → AskUserQuestion: "어떤 팀을 설치할까요?" → ["1", "2", "3", "다시 검색"]
340
378
  → "1" 선택 (@alice/doc-writer)
@@ -401,7 +439,6 @@ relay install {slug}
401
439
  "slug": "my-space",
402
440
  "name": "내 스페이스",
403
441
  "description": "설명",
404
- "is_personal": false,
405
442
  "role": "owner"
406
443
  }
407
444
  ]
@@ -409,9 +446,8 @@ relay install {slug}
409
446
  \`\`\`
410
447
 
411
448
  **표시:**
412
- - \`is_personal: true\`개인 스페이스로 분류
413
- - \`role\`: owner → 소유자, admin → 관리자, member → 멤버
414
- ${LOGIN_JIT_GUIDE}
449
+ - \`role\`: owner소유자, builder → 빌더, member → 멤버
450
+ ${ERROR_HANDLING_GUIDE}
415
451
  - Spaces 조회 실패해도 설치된 팀 목록은 정상 표시합니다 (로컬 데이터).
416
452
 
417
453
  ### 3. Space 팀 목록 (옵션)
@@ -542,14 +578,14 @@ relay.yaml의 \`visibility\` 설정을 확인합니다.
542
578
  #### 신규 배포 (visibility 미설정)
543
579
 
544
580
  **AskUserQuestion 호출:**
545
- - question: "어디에 배포할까요?"
546
- - options: \`["공개 (Space 공개)", "비공개 (Space 전용)"]\`
581
+ - question: "공개 범위를 선택하세요"
582
+ - options: \`["공개 누구나 설치", "링크 공유 — 접근 링크가 있는 사람만 설치", "비공개 Space 멤버만"]\`
547
583
 
548
584
  **응답 처리:**
549
585
  - "공개" → relay.yaml에 \`visibility: public\` 저장
586
+ - "링크 공유" → relay.yaml에 \`visibility: gated\` 저장. 배포 후 웹 대시보드(/dashboard)에서 접근 링크를 생성하고 구매 안내를 설정할 수 있다고 안내.
550
587
  - "비공개" → \`relay spaces --json\` 실행 후 Space 목록 표시
551
- - \`is_personal: true\`인 개인 Space **제외**합니다. 비공개 배포는 Space에만 가능합니다.
552
- - 팀 Space가 0개이면: "비공개 배포하려면 팀 Space가 필요합니다. www.relayax.com/spaces 에서 Space를 생성하세요."라고 안내하고 중단합니다.
588
+ - Space가 0개이면: "비공개 배포하려면 Space 필요합니다. www.relayax.com/spaces 에서 Space 생성하세요."라고 안내하고 중단합니다.
553
589
 
554
590
  **AskUserQuestion 호출 (Space가 1개여도 반드시 호출):**
555
591
  - question: "어떤 Space에 배포할까요?"
@@ -563,7 +599,7 @@ relay.yaml의 \`visibility\` 설정을 확인합니다.
563
599
  현재 설정을 확인합니다:
564
600
 
565
601
  **AskUserQuestion 호출:**
566
- - question: 공개일 때 "현재 **공개** 설정입니다. Space에 공개됩니다. 유지할까요?", 비공개일 때 "현재 **비공개** 설정입니다 (Space: {name}). 유지할까요?"
602
+ - question: 공개일 때 "현재 **공개** 설정입니다. 유지할까요?", 링크공유일 때 "현재 **링크 공유** 설정입니다. 접근 링크가 있는 사람만 설치 가능합니다. 유지할까요?", 비공개일 때 "현재 **비공개** 설정입니다 (Space: {name}). 유지할까요?"
567
603
  - options: \`["유지", "변경"]\`
568
604
 
569
605
  "변경" → 신규 배포와 동일한 플로우
@@ -677,13 +713,13 @@ requires: env 2개, cli 1개
677
713
  - 출력에 코드블록이 없으면 아래 형태로 직접 생성합니다:
678
714
 
679
715
  \\\`\\\`\\\`
680
- npm install -g relayax-cli
681
- relay login
682
- relay install <slug>
716
+ 아래 가이드를 읽고 그대로 따라해줘:
717
+ https://relayax.com/api/registry/{owner}/{slug}/guide.md
683
718
  \\\`\\\`\\\`
684
719
 
685
- - \`<slug>\`는 배포된 팀의 실제 슬러그로 치환합니다.
686
- - "이 블록을 팀원에게 공유하면 바로 설치할 있습니다"라고 안내합니다.
720
+ - \`{owner}\`과 \`{slug}\`는 배포된 팀의 실제 슬러그에서 추출합니다 (\`@owner/slug\` → \`owner\`, \`slug\`).
721
+ - "이 블록을 팀원에게 공유하면 Claude가 환경 체크부터 설치까지 자동으로 해줍니다"라고 안내합니다.
722
+ - CLI가 이미 설치된 사용자를 위한 짧은 버전도 함께 표시: \`/relay:relay-install <slug>\`
687
723
  ${BUSINESS_CARD_FORMAT}
688
724
 
689
725
  ## 예시
@@ -699,7 +735,8 @@ ${BUSINESS_CARD_FORMAT}
699
735
  → AskUserQuestion: "이대로 배포할까요?" → ["배포", "취소"]
700
736
  → "배포" → relay publish 실행
701
737
  → "배포 완료! URL: https://relayax.com/@my-space/my-team"
702
- → 온보딩 가이드 코드블록 표시`,
738
+ → 온보딩 가이드 코드블록 표시
739
+ ${ERROR_HANDLING_GUIDE}`,
703
740
  },
704
741
  ];
705
742
  // ─── Builder Commands (로컬 설치) ───
package/dist/types.d.ts CHANGED
@@ -51,7 +51,7 @@ export interface TeamRegistryInfo {
51
51
  tags?: string[];
52
52
  install_count?: number;
53
53
  requires?: Record<string, unknown>;
54
- visibility?: "public" | "private";
54
+ visibility?: "public" | "gated" | "private";
55
55
  welcome?: string | null;
56
56
  contact?: Record<string, string> | null;
57
57
  author?: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relayax-cli",
3
- "version": "0.2.38",
3
+ "version": "0.2.40",
4
4
  "description": "RelayAX Agent Team Marketplace CLI - Install and manage agent teams",
5
5
  "main": "dist/index.js",
6
6
  "bin": {