relayax-cli 0.2.36 → 0.2.38

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.
@@ -129,7 +129,7 @@ function registerCreate(program) {
129
129
  if (globalInstalled) {
130
130
  console.log(`\n \x1b[36mUser 커맨드 (글로벌)\x1b[0m — 설치됨`);
131
131
  }
132
- console.log(`\n 다음 단계: \x1b[33m/relay-publish\x1b[0m로 마켓플레이스에 배포`);
132
+ console.log(`\n 다음 단계: \x1b[33m/relay-publish\x1b[0m로 Space에 배포`);
133
133
  console.log(' IDE를 재시작하면 슬래시 커맨드가 활성화됩니다.\n');
134
134
  }
135
135
  });
@@ -122,7 +122,6 @@ function registerInit(program) {
122
122
  .command('init')
123
123
  .description('에이전트 CLI에 relay 슬래시 커맨드를 설치합니다')
124
124
  .option('--tools <tools>', '설치할 에이전트 CLI 지정 (all 또는 쉼표 구분)')
125
- .option('--update', '이미 설치된 슬래시 커맨드를 최신 버전으로 업데이트')
126
125
  .option('--auto', '대화형 프롬프트 없이 자동으로 모든 감지된 CLI에 설치')
127
126
  .action(async (opts) => {
128
127
  const json = program.opts().json ?? false;
@@ -135,9 +134,9 @@ function registerInit(program) {
135
134
  // ── 1. 글로벌 User 커맨드 설치 ──
136
135
  let globalStatus = 'already';
137
136
  let globalTools = [];
138
- if (opts.update || !hasGlobalUserCommands()) {
137
+ {
139
138
  const result = installGlobalUserCommands();
140
- globalStatus = opts.update ? 'updated' : 'installed';
139
+ globalStatus = hasGlobalUserCommands() ? 'updated' : 'installed';
141
140
  globalTools = result.tools;
142
141
  // Register relay-core in installed.json
143
142
  const installed = (0, config_js_1.loadInstalled)();
@@ -155,17 +154,7 @@ function registerInit(program) {
155
154
  if (isBuilder && command_adapter_js_1.BUILDER_COMMANDS.length > 0) {
156
155
  // 도구 선택
157
156
  let targetToolIds;
158
- if (opts.update) {
159
- const installed = detected.filter((tool) => {
160
- const cmdDir = path_1.default.join(projectPath, tool.skillsDir, 'commands', 'relay');
161
- return fs_1.default.existsSync(cmdDir);
162
- });
163
- if (installed.length === 0 && !json) {
164
- console.error('로컬에 설치된 relay Builder 커맨드가 없습니다.');
165
- }
166
- targetToolIds = installed.map((t) => t.value);
167
- }
168
- else if (opts.tools) {
157
+ if (opts.tools) {
169
158
  targetToolIds = resolveTools(opts.tools);
170
159
  }
171
160
  else if (!autoMode) {
@@ -240,9 +229,9 @@ function registerInit(program) {
240
229
  }));
241
230
  }
242
231
  else {
243
- console.log(`\n\x1b[32m✓ relay ${opts.update ? '업데이트' : '초기화'} 완료\x1b[0m\n`);
232
+ console.log(`\n\x1b[32m✓ relay 초기화 완료\x1b[0m\n`);
244
233
  // 글로벌
245
- if (globalStatus !== 'already') {
234
+ {
246
235
  const toolNames = globalTools.length > 0 ? globalTools.join(', ') : 'Claude Code';
247
236
  console.log(` \x1b[36mUser 커맨드 (글로벌)\x1b[0m — ${globalStatus === 'updated' ? '업데이트됨' : '설치됨'}`);
248
237
  console.log(` 감지된 CLI: \x1b[36m${toolNames}\x1b[0m`);
@@ -251,10 +240,6 @@ function registerInit(program) {
251
240
  }
252
241
  console.log();
253
242
  }
254
- else {
255
- console.log(` \x1b[36mUser 커맨드 (글로벌)\x1b[0m — 이미 설치됨`);
256
- console.log();
257
- }
258
243
  // 로컬 Builder
259
244
  if (localResults.length > 0) {
260
245
  console.log(` \x1b[36mBuilder 커맨드 (로컬)\x1b[0m`);
@@ -99,8 +99,7 @@ function registerInstall(program) {
99
99
  }
100
100
  else {
101
101
  // Normal registry install
102
- const resolveInput = slugInput;
103
- parsed = await (0, slug_js_1.resolveSlug)(resolveInput);
102
+ parsed = await (0, slug_js_1.resolveSlug)(slugInput);
104
103
  slug = parsed.full;
105
104
  try {
106
105
  team = await (0, api_js_1.fetchTeamInfo)(slug);
@@ -108,20 +107,86 @@ function registerInstall(program) {
108
107
  catch (fetchErr) {
109
108
  const fetchMsg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
110
109
  if (fetchMsg.includes('403')) {
111
- if (json) {
112
- console.error(JSON.stringify({
113
- error: 'SPACE_ONLY',
114
- message: '이 팀은 Space 멤버만 설치 가능합니다.',
115
- slug,
116
- }));
110
+ // Parse join_policy and membership_status from error body if available
111
+ let joinPolicy;
112
+ let membershipStatus;
113
+ try {
114
+ const errBody = JSON.parse(fetchMsg.replace(/^.*?(\{)/, '{'));
115
+ joinPolicy = typeof errBody.join_policy === 'string' ? errBody.join_policy : undefined;
116
+ membershipStatus = typeof errBody.membership_status === 'string' ? errBody.membership_status : undefined;
117
+ }
118
+ catch { /* ignore parse errors */ }
119
+ if (joinPolicy === 'auto') {
120
+ // Auto-join the Space then retry install
121
+ if (!json) {
122
+ console.error(`\x1b[33m⚙ Space에 자동 가입합니다...\x1b[0m`);
123
+ }
124
+ try {
125
+ const spaceSlug = parsed.owner;
126
+ await (0, join_js_1.joinSpace)(spaceSlug, '');
127
+ if (!json) {
128
+ console.error(`\x1b[32m✓ Space에 가입했습니다\x1b[0m`);
129
+ }
130
+ team = await (0, api_js_1.fetchTeamInfo)(slug);
131
+ }
132
+ catch (joinErr) {
133
+ const joinMsg = joinErr instanceof Error ? joinErr.message : String(joinErr);
134
+ if (json) {
135
+ console.error(JSON.stringify({ error: 'JOIN_FAILED', message: joinMsg, slug }));
136
+ }
137
+ else {
138
+ console.error(`\x1b[31mSpace 가입 실패: ${joinMsg}\x1b[0m`);
139
+ }
140
+ process.exit(1);
141
+ }
142
+ }
143
+ else if (joinPolicy === 'approval') {
144
+ const spaceSlug = parsed.owner;
145
+ if (json) {
146
+ console.error(JSON.stringify({
147
+ error: 'APPROVAL_REQUIRED',
148
+ message: `가입 신청이 필요합니다. \`relay join @${spaceSlug}\`로 가입하세요.`,
149
+ slug,
150
+ spaceSlug,
151
+ }));
152
+ }
153
+ else {
154
+ console.error(`\x1b[33m가입 신청이 필요합니다. \`relay join @${spaceSlug}\`로 가입하세요.\x1b[0m`);
155
+ }
156
+ process.exit(1);
157
+ }
158
+ else if (membershipStatus === 'member') {
159
+ // Member but no access to this specific team
160
+ if (json) {
161
+ console.error(JSON.stringify({
162
+ error: 'NO_ACCESS',
163
+ message: '이 팀에 대한 접근 권한이 없습니다.',
164
+ slug,
165
+ }));
166
+ }
167
+ else {
168
+ console.error('\x1b[31m이 팀에 대한 접근 권한이 없습니다.\x1b[0m');
169
+ }
170
+ process.exit(1);
117
171
  }
118
172
  else {
119
- console.error('\x1b[31m이 팀은 Space 멤버만 설치 가능합니다.\x1b[0m');
120
- console.error('\x1b[33mSpace 관리자에게 초대 코드를 요청하세요.\x1b[0m');
173
+ if (json) {
174
+ console.error(JSON.stringify({
175
+ error: 'SPACE_ONLY',
176
+ message: '이 팀은 Space 멤버만 설치 가능합니다.',
177
+ slug,
178
+ }));
179
+ }
180
+ else {
181
+ console.error('\x1b[31m이 팀은 Space 멤버만 설치 가능합니다.\x1b[0m');
182
+ console.error('\x1b[33mSpace 관리자에게 초대 코드를 요청하세요.\x1b[0m');
183
+ }
184
+ process.exit(1);
121
185
  }
122
- process.exit(1);
123
186
  }
124
- throw fetchErr;
187
+ else {
188
+ throw fetchErr;
189
+ }
125
190
  }
126
191
  }
127
192
  const teamDir = path_1.default.join(projectPath, '.relay', 'teams', parsed.owner, parsed.name);
@@ -20,10 +20,10 @@ function registerList(program) {
20
20
  program
21
21
  .command('list')
22
22
  .description('설치된 에이전트 팀 목록')
23
- .option('--space <slug>', 'Space 마켓플레이스의 팀 목록 조회')
23
+ .option('--space <slug>', 'Space 팀 목록 조회')
24
24
  .action(async (opts) => {
25
25
  const json = program.opts().json ?? false;
26
- // --space 옵션: Space 마켓플레이스 팀 목록
26
+ // --space 옵션: Space 팀 목록
27
27
  if (opts.space) {
28
28
  const spaceSlug = opts.space;
29
29
  const token = await (0, config_js_1.getValidToken)();
@@ -279,7 +279,7 @@ async function publishToApi(token, tarPath, metadata) {
279
279
  function registerPublish(program) {
280
280
  program
281
281
  .command('publish')
282
- .description('현재 팀 패키지를 마켓플레이스에 배포합니다 (relay.yaml 필요)')
282
+ .description('현재 팀 패키지를 Space에 배포합니다 (relay.yaml 필요)')
283
283
  .option('--token <token>', '인증 토큰')
284
284
  .action(async (opts) => {
285
285
  const json = program.opts().json ?? false;
@@ -404,36 +404,97 @@ function registerPublish(program) {
404
404
  console.error(` → relay.yaml에 version: ${newVersion} 저장됨\n`);
405
405
  }
406
406
  }
407
+ // Validate structure (콘텐츠는 .relay/ 안에 있음)
408
+ const hasDirs = VALID_DIRS.some((d) => {
409
+ const dirPath = path_1.default.join(relayDir, d);
410
+ if (!fs_1.default.existsSync(dirPath))
411
+ return false;
412
+ return fs_1.default.readdirSync(dirPath).filter((f) => !f.startsWith('.')).length > 0;
413
+ });
414
+ if (!hasDirs) {
415
+ console.error(JSON.stringify({
416
+ error: 'EMPTY_PACKAGE',
417
+ message: '.relay/ 안에 skills/, agents/, rules/, commands/ 중 하나 이상에 파일이 있어야 합니다.',
418
+ }));
419
+ process.exit(1);
420
+ }
421
+ // Get token (checked before tarball creation)
422
+ const token = opts.token ?? process.env.RELAY_TOKEN ?? await (0, config_js_1.getValidToken)();
423
+ if (!token) {
424
+ console.error(JSON.stringify({
425
+ error: 'NO_TOKEN',
426
+ message: '인증이 필요합니다. `relay login`을 먼저 실행하세요.',
427
+ }));
428
+ process.exit(1);
429
+ }
430
+ // Fetch user's Spaces and select publish target
431
+ let selectedSpaceId;
432
+ let selectedSpaceIsPersonal = true;
433
+ try {
434
+ const { fetchMySpaces } = await import('./spaces.js');
435
+ 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) {
439
+ if (spaces.length === 0) {
440
+ // No spaces at all — publish without space_id
441
+ console.error('\x1b[33m⚠ 소속 Space가 없습니다. 개인 계정으로 배포합니다.\x1b[0m\n');
442
+ }
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`);
448
+ }
449
+ else {
450
+ // Multiple spaces — prompt user
451
+ 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
+ ];
456
+ const chosenId = await selectSpace({
457
+ message: '어떤 Space에 배포할까요?',
458
+ choices: spaceChoices.map((c) => ({ name: c.name, value: c.value })),
459
+ });
460
+ const chosen = spaceChoices.find((c) => c.value === chosenId);
461
+ selectedSpaceId = chosenId;
462
+ selectedSpaceIsPersonal = chosen?.isPersonal ?? false;
463
+ const chosenLabel = chosen?.name ?? chosenId;
464
+ console.error(` → Space: ${chosenLabel}\n`);
465
+ }
466
+ }
467
+ else if (personalSpace) {
468
+ selectedSpaceId = personalSpace.id;
469
+ selectedSpaceIsPersonal = true;
470
+ }
471
+ }
472
+ catch {
473
+ // Space 조회 실패 시 무시하고 계속 진행
474
+ }
475
+ // Visibility default based on Space type: personal → public, team Space → private
476
+ const defaultVisibility = selectedSpaceIsPersonal ? 'public' : 'private';
477
+ const defaultVisLabel = defaultVisibility === 'public'
478
+ ? '공개 (개인 Space 기본값)'
479
+ : '비공개 (팀 Space 기본값)';
407
480
  // Visibility validation: must be explicitly set
408
481
  if (!config.visibility) {
409
482
  if (isTTY) {
410
483
  const { select: promptSelect } = await import('@inquirer/prompts');
411
- console.error('\n\x1b[33m⚠ relay.yaml에 visibility가 설정되지 않았습니다.\x1b[0m');
412
- // Show user's spaces to help decide
413
- try {
414
- const { fetchMySpaces } = await import('./spaces.js');
415
- const spaceToken = opts.token ?? process.env.RELAY_TOKEN ?? await (0, config_js_1.getValidToken)();
416
- if (spaceToken) {
417
- const spaces = await fetchMySpaces(spaceToken);
418
- const nonPersonal = spaces.filter((s) => !s.is_personal);
419
- if (nonPersonal.length > 0) {
420
- console.error(`\n 내 Space:`);
421
- for (const s of nonPersonal) {
422
- console.error(` \x1b[36m${s.slug}\x1b[0m — ${s.name}`);
423
- }
424
- console.error(`\n 비공개로 설정하면 위 Space 멤버만 접근할 수 있습니다.\n`);
425
- }
426
- }
427
- }
428
- catch {
429
- // Space 조회 실패는 무시 — visibility 선택은 계속 진행
430
- }
484
+ console.error(`\n\x1b[33m⚠ relay.yaml에 visibility가 설정되지 않았습니다.\x1b[0m (기본값: ${defaultVisLabel})`);
431
485
  config.visibility = await promptSelect({
432
486
  message: '공개 범위를 선택하세요:',
433
487
  choices: [
434
- { name: '공개 — 마켓플레이스에 누구나 검색·설치', value: 'public' },
435
- { name: '비공개 — Space 멤버만 접근', value: 'private' },
488
+ {
489
+ name: `공개 — Space 누구나 검색·설치${defaultVisibility === 'public' ? ' ✓ 추천' : ''}`,
490
+ value: 'public',
491
+ },
492
+ {
493
+ name: `비공개 — Space 멤버만 접근${defaultVisibility === 'private' ? ' ✓ 추천' : ''}`,
494
+ value: 'private',
495
+ },
436
496
  ],
497
+ default: defaultVisibility,
437
498
  });
438
499
  // Save back to relay.yaml
439
500
  const yamlData = js_yaml_1.default.load(yamlContent);
@@ -453,8 +514,8 @@ function registerPublish(program) {
453
514
  if (isTTY) {
454
515
  const { select: promptConfirmVis } = await import('@inquirer/prompts');
455
516
  const visLabel = config.visibility === 'public'
456
- ? '\x1b[32m공개\x1b[0m (마켓플레이스에 누구나 검색·설치)'
457
- : '\x1b[33m비공개\x1b[0m (Space 멤버만 접근)';
517
+ ? `\x1b[32m공개\x1b[0m (Space에 누구나 검색·설치)${defaultVisibility === 'public' ? ' ✓ 추천' : ''}`
518
+ : `\x1b[33m비공개\x1b[0m (Space 멤버만 접근)${defaultVisibility === 'private' ? ' ✓ 추천' : ''}`;
458
519
  const visAction = await promptConfirmVis({
459
520
  message: `공개 범위: ${config.visibility === 'public' ? '공개' : '비공개'} — 유지할까요?`,
460
521
  choices: [
@@ -475,29 +536,6 @@ function registerPublish(program) {
475
536
  if (isTTY) {
476
537
  console.error('💡 프로필에 연락처를 설정하면 설치 시 명함이 전달됩니다: www.relayax.com/dashboard/profile');
477
538
  }
478
- // Validate structure (콘텐츠는 .relay/ 안에 있음)
479
- const hasDirs = VALID_DIRS.some((d) => {
480
- const dirPath = path_1.default.join(relayDir, d);
481
- if (!fs_1.default.existsSync(dirPath))
482
- return false;
483
- return fs_1.default.readdirSync(dirPath).filter((f) => !f.startsWith('.')).length > 0;
484
- });
485
- if (!hasDirs) {
486
- console.error(JSON.stringify({
487
- error: 'EMPTY_PACKAGE',
488
- message: '.relay/ 안에 skills/, agents/, rules/, commands/ 중 하나 이상에 파일이 있어야 합니다.',
489
- }));
490
- process.exit(1);
491
- }
492
- // Get token (checked before tarball creation)
493
- const token = opts.token ?? process.env.RELAY_TOKEN ?? await (0, config_js_1.getValidToken)();
494
- if (!token) {
495
- console.error(JSON.stringify({
496
- error: 'NO_TOKEN',
497
- message: '인증이 필요합니다. `relay login`을 먼저 실행하세요.',
498
- }));
499
- process.exit(1);
500
- }
501
539
  const detectedCommands = detectCommands(relayDir);
502
540
  const components = {
503
541
  agents: countDir(relayDir, 'agents'),
@@ -525,6 +563,7 @@ function registerPublish(program) {
525
563
  type: config.type ?? 'hybrid',
526
564
  agent_details: detectedAgents,
527
565
  skill_details: detectedSkills,
566
+ ...(selectedSpaceId ? { space_id: selectedSpaceId } : {}),
528
567
  };
529
568
  if (!json) {
530
569
  console.error(`패키지 생성 중... (${config.name} v${config.version})`);
@@ -617,7 +656,8 @@ function registerPublish(program) {
617
656
  console.log('```');
618
657
  guideLines.forEach((line) => console.log(line));
619
658
  console.log('```');
620
- console.log(`\n \x1b[90m상세페이지: \x1b[36mrelayax.com/teams/${result.slug}\x1b[0m`);
659
+ const detailSlug = result.slug.startsWith('@') ? result.slug.slice(1) : result.slug;
660
+ console.log(`\n \x1b[90m상세페이지: \x1b[36mrelayax.com/@${detailSlug}\x1b[0m`);
621
661
  }
622
662
  }
623
663
  }
@@ -26,19 +26,24 @@ function formatTable(results) {
26
26
  function registerSearch(program) {
27
27
  program
28
28
  .command('search <keyword>')
29
- .description('에이전트 팀 검색')
29
+ .description('Space에서 에이전트 팀 검색 (공개 팀 + 내 Space 팀)')
30
30
  .option('--tag <tag>', '태그로 필터링')
31
+ .option('--space <space>', '특정 Space 내에서 검색')
31
32
  .action(async (keyword, opts) => {
32
33
  const json = program.opts().json ?? false;
33
34
  try {
34
- const results = await (0, api_js_1.searchTeams)(keyword, opts.tag);
35
+ const results = await (0, api_js_1.searchTeams)(keyword, opts.tag, opts.space);
35
36
  if (json) {
36
37
  console.log(JSON.stringify({ results }));
37
38
  }
38
39
  else {
39
- console.log(`\n검색어: \x1b[36m${keyword}\x1b[0m${opts.tag ? ` 태그: \x1b[33m${opts.tag}\x1b[0m` : ''}\n`);
40
+ const spaceSuffix = opts.space ? ` Space: \x1b[35m@${opts.space}\x1b[0m` : '';
41
+ console.log(`\n검색어: \x1b[36m${keyword}\x1b[0m${opts.tag ? ` 태그: \x1b[33m${opts.tag}\x1b[0m` : ''}${spaceSuffix}\n`);
40
42
  console.log(formatTable(results));
41
43
  console.log(`\n총 ${results.length}건`);
44
+ if (!opts.space && results.length === 0) {
45
+ console.log('\x1b[33m💡 내 Space에서 검색하려면: relay search <keyword> --space <space-slug>\x1b[0m');
46
+ }
42
47
  }
43
48
  }
44
49
  catch (err) {
package/dist/lib/api.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { TeamRegistryInfo, SearchResult } from '../types.js';
2
2
  export declare function fetchTeamInfo(slug: string): Promise<TeamRegistryInfo>;
3
- export declare function searchTeams(query: string, tag?: string): Promise<SearchResult[]>;
3
+ export declare function searchTeams(query: string, tag?: string, space?: string): Promise<SearchResult[]>;
4
4
  export interface TeamVersionInfo {
5
5
  version: string;
6
6
  changelog: string | null;
package/dist/lib/api.js CHANGED
@@ -19,10 +19,12 @@ async function fetchTeamInfo(slug) {
19
19
  }
20
20
  return res.json();
21
21
  }
22
- async function searchTeams(query, tag) {
22
+ async function searchTeams(query, tag, space) {
23
23
  const params = new URLSearchParams({ q: query });
24
24
  if (tag)
25
25
  params.set('tag', tag);
26
+ if (space)
27
+ params.set('space', space);
26
28
  const url = `${config_js_1.API_URL}/api/registry/search?${params.toString()}`;
27
29
  const res = await fetch(url);
28
30
  if (!res.ok) {
@@ -111,32 +111,32 @@ JSON 결과 예시:
111
111
  exports.USER_COMMANDS = [
112
112
  {
113
113
  id: 'relay-install',
114
- description: 'relay 마켓플레이스에서 에이전트 팀을 설치합니다',
115
- body: `요청된 에이전트 팀을 relay 마켓플레이스에서 다운로드하고, 현재 에이전트 환경에 맞게 구성합니다.
114
+ description: 'relay Space에서 에이전트 팀을 설치합니다',
115
+ body: `요청된 에이전트 팀을 relay Space에서 다운로드하고, 현재 에이전트 환경에 맞게 구성합니다.
116
116
  인자 없이 호출하면 인터랙티브 탐색 모드로 진입합니다.
117
117
 
118
118
  ## 인터랙션 플로우
119
119
 
120
120
  이 커맨드는 3단계 인터랙션으로 진행됩니다. 각 단계에서 반드시 AskUserQuestion 도구를 사용하세요.
121
121
 
122
- ### Step 1. 공간 선택 & 팀 탐색 (slug가 없을 때만)
122
+ ### Step 1. Space 선택 & 팀 탐색 (slug가 없을 때만)
123
123
 
124
124
  slug가 직접 주어지면 (\`/relay-install @alice/doc-writer\`) 이 단계를 건너뛰고 Step 2로 갑니다.
125
125
 
126
- #### 1-1. 공간 선택
126
+ #### 1-1. Space 선택
127
127
  \`relay spaces --json\` 을 실행하여 사용자의 Space 목록을 가져옵니다.
128
128
 
129
129
  **AskUserQuestion 호출:**
130
130
  - question: "어디서 팀을 찾을까요?"
131
- - options: Space가 있으면 \`["마켓플레이스 (공개)", "<space1_name>", "<space2_name>", ...]\`, 없으면 이 단계를 건너뛰고 바로 마켓 검색으로 진행
131
+ - options: Space가 있으면 \`["<space1_name> (개인)", "<space2_name>", ...]\`, 없으면 이 단계를 건너뛰고 바로 Space 탐색으로 진행
132
132
 
133
133
  **응답 처리:**
134
- - "마켓플레이스 (공개)" → 1-2. 마켓 검색으로 진행
135
- - Space 이름 선택 → 1-3. Space 팀 목록으로 진행
134
+ - Space 이름 선택 → 1-2. Space 탐색으로 진행
136
135
 
137
- #### 1-2. 마켓 검색
138
- 사용자의 요청에서 키워드를 추출합니다. 명시적 키워드가 없으면 현재 프로젝트를 분석하여 적절한 검색어를 판단합니다.
136
+ #### 1-2. Space 팀 탐색
137
+ 선택된 Space에서 팀을 검색합니다.
139
138
  \`relay search <keyword>\` 명령어를 실행합니다 (필요하면 여러 키워드로 반복).
139
+ 또는 \`relay list --space <space-slug> --json\`으로 전체 목록을 가져옵니다.
140
140
 
141
141
  검색 결과를 번호 리스트로 보여줍니다:
142
142
 
@@ -155,12 +155,13 @@ slug가 직접 주어지면 (\`/relay-install @alice/doc-writer\`) 이 단계를
155
155
 
156
156
  **AskUserQuestion 호출:**
157
157
  - question: "어떤 팀을 설치할까요?"
158
- - options: \`["1", "2", "3", "다시 검색"]\`
158
+ - options: \`["1", "2", "3", "다시 검색", "돌아가기"]\`
159
159
 
160
160
  "다시 검색" → 새 키워드로 1-2 반복
161
+ "돌아가기" → 1-1로 돌아감
161
162
  번호 선택 → 해당 팀의 slug로 설치 진행
162
163
 
163
- #### 1-3. Space 팀 목록
164
+ #### 1-3. Space 팀 목록 (전체 보기)
164
165
  \`relay list --space <space-slug> --json\` 을 실행합니다.
165
166
 
166
167
  팀 목록을 번호 리스트로 보여줍니다 (1-2와 동일 형식).
@@ -175,9 +176,9 @@ slug가 직접 주어지면 (\`/relay-install @alice/doc-writer\`) 이 단계를
175
176
  ### Step 2. 설치 & 배치 범위 선택
176
177
 
177
178
  #### 2-1. 패키지 다운로드
178
- \`relay install <@author/slug> --json\` 명령어를 실행합니다.
179
- - Public 마켓 팀: \`relay install <@author/slug> --json\`
180
- - Space 팀: \`relay install @spaces/<space-slug>/<team-slug> --json\`
179
+ \`relay install <@space/team> --json\` 명령어를 실행합니다.
180
+ - 공개 팀: \`relay install <@space/team> --json\`
181
+ - Space (비공개): \`relay install @<space-slug>/<team-slug> --json\`
181
182
  - Space 가입이 필요하면: \`relay join <space-slug> --code <invite-code>\` 를 먼저 실행합니다.
182
183
  - 또는 \`--join-code <code>\`로 가입+설치를 한번에 할 수 있습니다.
183
184
  - CLI가 init과 login을 자동으로 처리합니다 (사용자가 별도 실행할 필요 없음).
@@ -311,9 +312,11 @@ npm install -g relayax-cli
311
312
  relay login
312
313
  relay install {slug}
313
314
 
314
- 상세페이지: relayax.com/teams/{slug}
315
+ 상세페이지: relayax.com/@{owner}/{team}
315
316
  \`\`\`
316
317
 
318
+ - \`{slug}\`가 \`@owner/team\` 형식이면 상세페이지는 \`relayax.com/@{owner}/{team}\`으로 치환합니다.
319
+
317
320
  - type 기반 사용법도 포함:
318
321
  - command/hybrid: \`# 사용법: /{첫번째 커맨드}\`
319
322
  - passive: \`# 설치하면 자동 적용됩니다\`
@@ -330,8 +333,8 @@ relay install {slug}
330
333
 
331
334
  ### 인터랙티브 모드 (/relay-install)
332
335
  → relay spaces --json 실행
333
- → AskUserQuestion: "어디서 팀을 찾을까요?" → ["마켓플레이스 (공개)", "Acme Corp"]
334
- → "마켓플레이스" 선택 → "어떤 팀을 찾고 계세요?"
336
+ → AskUserQuestion: "어디서 팀을 찾을까요?" → ["개인 Space (alice)", "Acme Corp"]
337
+ → "개인 Space" 선택 → "어떤 팀을 찾고 계세요?"
335
338
  → relay search "문서" 실행 → 결과 리스트 표시
336
339
  → AskUserQuestion: "어떤 팀을 설치할까요?" → ["1", "2", "3", "다시 검색"]
337
340
  → "1" 선택 (@alice/doc-writer)
@@ -464,8 +467,8 @@ ${LOGIN_JIT_GUIDE}
464
467
  },
465
468
  {
466
469
  id: 'relay-publish',
467
- description: '현재 팀 패키지를 relay 마켓플레이스에 배포합니다',
468
- body: `현재 디렉토리의 에이전트 팀(.relay/)을 분석하고, 보안 점검 및 requirements를 구성한 뒤, 사용가이드를 생성하고 relay 마켓플레이스에 배포합니다.
470
+ description: '현재 팀 패키지를 relay Space에 배포합니다',
471
+ body: `현재 디렉토리의 에이전트 팀(.relay/)을 분석하고, 보안 점검 및 requirements를 구성한 뒤, 사용가이드를 생성하고 relay Space에 배포합니다.
469
472
 
470
473
  ## 사전 준비 (자동)
471
474
 
@@ -484,7 +487,7 @@ ${LOGIN_JIT_GUIDE}
484
487
  - 기본값으로 현재 디렉토리명을 제안합니다.
485
488
 
486
489
  3. **AskUserQuestion 호출:**
487
- - question: "팀을 한 줄로 설명해주세요 (마켓플레이스에 표시됩니다)"
490
+ - question: "팀을 한 줄로 설명해주세요 (Space에 표시됩니다)"
488
491
  - 기본값으로 적절한 값을 만들어줍니다.
489
492
 
490
493
  4. 자동 처리:
@@ -540,15 +543,18 @@ relay.yaml의 \`visibility\` 설정을 확인합니다.
540
543
 
541
544
  **AskUserQuestion 호출:**
542
545
  - question: "어디에 배포할까요?"
543
- - options: \`["공개 (마켓플레이스)", "비공개 (Space 전용)"]\`
546
+ - options: \`["공개 (Space 공개)", "비공개 (Space 전용)"]\`
544
547
 
545
548
  **응답 처리:**
546
549
  - "공개" → relay.yaml에 \`visibility: public\` 저장
547
550
  - "비공개" → \`relay spaces --json\` 실행 후 Space 목록 표시
551
+ - \`is_personal: true\`인 개인 Space는 **제외**합니다. 비공개 배포는 팀 Space에만 가능합니다.
552
+ - 팀 Space가 0개이면: "비공개 배포하려면 팀 Space가 필요합니다. www.relayax.com/spaces 에서 Space를 생성하세요."라고 안내하고 중단합니다.
548
553
 
549
- **AskUserQuestion 호출:**
554
+ **AskUserQuestion 호출 (Space가 1개여도 반드시 호출):**
550
555
  - question: "어떤 Space에 배포할까요?"
551
556
  - options: \`["<space1_name>", "<space2_name>", ...]\`
557
+ - **중요: Space가 1개라도 자동 선택하지 말고 반드시 사용자에게 확인받으세요.**
552
558
 
553
559
  → relay.yaml에 \`visibility: private\`, \`space: <selected_slug>\` 저장
554
560
 
@@ -557,7 +563,7 @@ relay.yaml의 \`visibility\` 설정을 확인합니다.
557
563
  현재 설정을 확인합니다:
558
564
 
559
565
  **AskUserQuestion 호출:**
560
- - question: 공개일 때 "현재 **공개** 설정입니다. 마켓플레이스에 노출됩니다. 유지할까요?", 비공개일 때 "현재 **비공개** 설정입니다 (Space: {name}). 유지할까요?"
566
+ - question: 공개일 때 "현재 **공개** 설정입니다. Space에 공개됩니다. 유지할까요?", 비공개일 때 "현재 **비공개** 설정입니다 (Space: {name}). 유지할까요?"
561
567
  - options: \`["유지", "변경"]\`
562
568
 
563
569
  "변경" → 신규 배포와 동일한 플로우
@@ -651,7 +657,7 @@ requires:
651
657
  배포 요약
652
658
 
653
659
  팀: my-team v1.0.0
654
- 공개: 마켓플레이스 (공개)
660
+ 공개: Space 공개
655
661
  Skills: 3개, Commands: 5개
656
662
  requires: env 2개, cli 1개
657
663
  \`\`\`
@@ -665,7 +671,7 @@ requires: env 2개, cli 1개
665
671
  - "취소" → 중단
666
672
 
667
673
  #### 3-3. 배포 완료 & 온보딩 가이드
668
- - 배포 결과와 마켓플레이스 URL을 보여줍니다.
674
+ - 배포 결과와 Space URL을 보여줍니다.
669
675
  - \`relay publish\` 출력 끝에 코드블록 형태의 온보딩 가이드가 포함됩니다.
670
676
  - 이 코드블록을 사용자에게 그대로 보여줍니다.
671
677
  - 출력에 코드블록이 없으면 아래 형태로 직접 생성합니다:
@@ -684,7 +690,7 @@ ${BUSINESS_CARD_FORMAT}
684
690
 
685
691
  사용자: /relay-publish
686
692
  → 인증 확인 ✓, 팀 구조 분석 (skills 3개, commands 5개)
687
- → AskUserQuestion: "어디에 배포할까요?" → ["공개 (마켓플레이스)", "비공개 (Space 전용)"]
693
+ → AskUserQuestion: "어디에 배포할까요?" → ["공개 (Space 공개)", "비공개 (Space 전용)"]
688
694
  → "공개" 선택
689
695
  → 보안 스캔 ✓ 시크릿 없음 → requires 분석 결과 표시
690
696
  → AskUserQuestion: "requires 설정이 맞나요?" → ["확인", "수정"]
@@ -692,7 +698,7 @@ ${BUSINESS_CARD_FORMAT}
692
698
  → 배포 요약 표시
693
699
  → AskUserQuestion: "이대로 배포할까요?" → ["배포", "취소"]
694
700
  → "배포" → relay publish 실행
695
- → "배포 완료! URL: https://relayax.com/teams/my-team"
701
+ → "배포 완료! URL: https://relayax.com/@my-space/my-team"
696
702
  → 온보딩 가이드 코드블록 표시`,
697
703
  },
698
704
  ];
@@ -121,6 +121,19 @@ async function getValidToken() {
121
121
  return undefined;
122
122
  }
123
123
  }
124
+ /**
125
+ * `@spaces/{spaceSlug}/{teamSlug}` 레거시 키를 `@{spaceSlug}/{teamSlug}`로 정규화한다.
126
+ * installed.json 로드 시 자동 적용되어 모든 소비자가 일관된 키를 사용한다.
127
+ */
128
+ function normalizeInstalledRegistry(raw) {
129
+ const normalized = {};
130
+ for (const [key, value] of Object.entries(raw)) {
131
+ const m = key.match(/^@spaces\/([a-z0-9][a-z0-9-]*)\/([a-z0-9][a-z0-9-]*)$/);
132
+ const normalizedKey = m ? `@${m[1]}/${m[2]}` : key;
133
+ normalized[normalizedKey] = value;
134
+ }
135
+ return normalized;
136
+ }
124
137
  /** 프로젝트 로컬 installed.json 읽기 */
125
138
  function loadInstalled() {
126
139
  const file = path_1.default.join(process.cwd(), '.relay', 'installed.json');
@@ -128,7 +141,7 @@ function loadInstalled() {
128
141
  return {};
129
142
  }
130
143
  try {
131
- return JSON.parse(fs_1.default.readFileSync(file, 'utf-8'));
144
+ return normalizeInstalledRegistry(JSON.parse(fs_1.default.readFileSync(file, 'utf-8')));
132
145
  }
133
146
  catch {
134
147
  return {};
@@ -147,7 +160,7 @@ function loadGlobalInstalled() {
147
160
  if (!fs_1.default.existsSync(file))
148
161
  return {};
149
162
  try {
150
- return JSON.parse(fs_1.default.readFileSync(file, 'utf-8'));
163
+ return normalizeInstalledRegistry(JSON.parse(fs_1.default.readFileSync(file, 'utf-8')));
151
164
  }
152
165
  catch {
153
166
  return {};
@@ -17,7 +17,11 @@ const PREAMBLE_END = '<!-- RELAY_PREAMBLE_END -->';
17
17
  * relay CLI가 있으면 사용, 없으면 curl fallback.
18
18
  */
19
19
  function generatePreambleScript(slug, apiUrl) {
20
- const registrySlug = slug.startsWith('@') ? slug.slice(1) : slug;
20
+ // slug format: @space/team space=space, team=team
21
+ const stripped = slug.startsWith('@') ? slug.slice(1) : slug;
22
+ const slashIdx = stripped.indexOf('/');
23
+ const spacePart = slashIdx !== -1 ? stripped.slice(0, slashIdx) : stripped;
24
+ const teamPart = slashIdx !== -1 ? stripped.slice(slashIdx + 1) : stripped;
21
25
  return `#!/usr/bin/env bash
22
26
  # relay-preamble.sh — auto-generated by relay publish
23
27
  set +e
@@ -28,7 +32,7 @@ DEVICE_HASH=$(echo "$HOSTNAME:$USER" | shasum -a 256 | cut -d' ' -f1)
28
32
  if command -v relay &>/dev/null; then
29
33
  relay ping "${slug}" --quiet 2>/dev/null &
30
34
  else
31
- curl -sf -X POST "${apiUrl}/api/registry/${registrySlug}/ping" \\
35
+ curl -sf -X POST "${apiUrl}/api/spaces/${spacePart}/teams/${teamPart}/ping" \\
32
36
  -H "Content-Type: application/json" \\
33
37
  -d "{\\"device_hash\\":\\"$DEVICE_HASH\\"}" \\
34
38
  2>/dev/null &
@@ -15,5 +15,6 @@ export declare function isSimpleSlug(input: string): boolean;
15
15
  /**
16
16
  * Scoped 또는 단순 slug를 받아 ParsedSlug를 반환한다.
17
17
  * 단순 slug는 서버에 resolve를 요청한다.
18
+ * `@spaces/{spaceSlug}/{teamSlug}` 형식은 `@{spaceSlug}/{teamSlug}`로 정규화된다.
18
19
  */
19
20
  export declare function resolveSlug(input: string): Promise<ParsedSlug>;
package/dist/lib/slug.js CHANGED
@@ -28,18 +28,22 @@ function isSimpleSlug(input) {
28
28
  /**
29
29
  * Scoped 또는 단순 slug를 받아 ParsedSlug를 반환한다.
30
30
  * 단순 slug는 서버에 resolve를 요청한다.
31
+ * `@spaces/{spaceSlug}/{teamSlug}` 형식은 `@{spaceSlug}/{teamSlug}`로 정규화된다.
31
32
  */
32
33
  async function resolveSlug(input) {
34
+ // @spaces/{spaceSlug}/{teamSlug} → @{spaceSlug}/{teamSlug} 정규화
35
+ const spacesPrefix = input.match(/^@spaces\/([a-z0-9][a-z0-9-]*)\/([a-z0-9][a-z0-9-]*)$/);
36
+ const normalized = spacesPrefix ? `@${spacesPrefix[1]}/${spacesPrefix[2]}` : input;
33
37
  // scoped slug면 바로 파싱
34
- const parsed = parseSlug(input);
38
+ const parsed = parseSlug(normalized);
35
39
  if (parsed)
36
40
  return parsed;
37
41
  // 단순 slug인지 검증
38
- if (!isSimpleSlug(input)) {
42
+ if (!isSimpleSlug(normalized)) {
39
43
  throw new Error(`잘못된 slug 형식입니다: '${input}'. @owner/name 또는 name 형태로 입력하세요.`);
40
44
  }
41
45
  // 서버에 resolve 요청
42
- const results = await (0, api_js_1.resolveSlugFromServer)(input);
46
+ const results = await (0, api_js_1.resolveSlugFromServer)(normalized);
43
47
  if (results.length === 0) {
44
48
  throw new Error(`'${input}' 팀을 찾을 수 없습니다.`);
45
49
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relayax-cli",
3
- "version": "0.2.36",
3
+ "version": "0.2.38",
4
4
  "description": "RelayAX Agent Team Marketplace CLI - Install and manage agent teams",
5
5
  "main": "dist/index.js",
6
6
  "bin": {