relayax-cli 0.2.23 → 0.2.25

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.
package/README.md CHANGED
@@ -9,7 +9,7 @@ Agent Team Marketplace CLI - 에이전트 팀을 검색하고 설치하세요.
9
9
  npx relay-cli install contents-team
10
10
 
11
11
  # 또는 글로벌 설치
12
- npm install -g relay-cli
12
+ npm install -g relayax-cli
13
13
  relay install contents-team
14
14
  ```
15
15
 
@@ -3,7 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerCheckUpdate = registerCheckUpdate;
4
4
  const version_check_js_1 = require("../lib/version-check.js");
5
5
  const slug_js_1 = require("../lib/slug.js");
6
- const config_js_1 = require("../lib/config.js");
7
6
  function registerCheckUpdate(program) {
8
7
  program
9
8
  .command('check-update [slug]')
@@ -32,19 +31,12 @@ function registerCheckUpdate(program) {
32
31
  scopedSlug = slug;
33
32
  }
34
33
  else {
35
- const installed = (0, config_js_1.loadInstalled)();
36
- const found = (0, slug_js_1.findInstalledByName)(installed, slug);
37
- if (found) {
38
- scopedSlug = found;
34
+ try {
35
+ const parsed = await (0, slug_js_1.resolveSlug)(slug);
36
+ scopedSlug = parsed.full;
39
37
  }
40
- else {
41
- try {
42
- const parsed = await (0, slug_js_1.resolveSlug)(slug);
43
- scopedSlug = parsed.full;
44
- }
45
- catch {
46
- scopedSlug = slug; // fallback to original
47
- }
38
+ catch {
39
+ scopedSlug = slug;
48
40
  }
49
41
  }
50
42
  const teamResult = await (0, version_check_js_1.checkTeamVersion)(scopedSlug, force);
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function registerDeployRecord(program: Command): void;
@@ -0,0 +1,93 @@
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.registerDeployRecord = registerDeployRecord;
7
+ const path_1 = __importDefault(require("path"));
8
+ const config_js_1 = require("../lib/config.js");
9
+ const slug_js_1 = require("../lib/slug.js");
10
+ function registerDeployRecord(program) {
11
+ program
12
+ .command('deploy-record <slug>')
13
+ .description('에이전트가 배치한 파일 정보를 installed.json에 기록합니다')
14
+ .requiredOption('--scope <scope>', '배치 범위 (global 또는 local)')
15
+ .option('--files <paths...>', '배치된 파일 경로 목록')
16
+ .action((slugInput, opts) => {
17
+ const json = program.opts().json ?? false;
18
+ const scope = opts.scope;
19
+ if (scope !== 'global' && scope !== 'local') {
20
+ const msg = { error: 'INVALID_SCOPE', message: '--scope는 global 또는 local이어야 합니다.' };
21
+ if (json) {
22
+ console.error(JSON.stringify(msg));
23
+ }
24
+ else {
25
+ console.error(`\x1b[31m오류:\x1b[0m ${msg.message}`);
26
+ }
27
+ process.exit(1);
28
+ }
29
+ const files = opts.files ?? [];
30
+ // Resolve absolute paths
31
+ const resolvedFiles = files.map((f) => f.startsWith('/') || f.startsWith('~')
32
+ ? f
33
+ : path_1.default.resolve(f));
34
+ // Find the team in the appropriate registry
35
+ const localRegistry = (0, config_js_1.loadInstalled)();
36
+ const globalRegistry = (0, config_js_1.loadGlobalInstalled)();
37
+ // Resolve slug — check both registries for short name match
38
+ let slug;
39
+ if ((0, slug_js_1.isScopedSlug)(slugInput)) {
40
+ slug = slugInput;
41
+ }
42
+ else {
43
+ const allKeys = [...Object.keys(localRegistry), ...Object.keys(globalRegistry)];
44
+ const match = allKeys.find((key) => {
45
+ const parsed = (0, slug_js_1.parseSlug)(key);
46
+ return parsed && parsed.name === slugInput;
47
+ });
48
+ slug = match ?? slugInput;
49
+ }
50
+ // Check if team exists in either registry
51
+ const entry = localRegistry[slug] ?? globalRegistry[slug];
52
+ if (!entry) {
53
+ const msg = { error: 'NOT_INSTALLED', message: `'${slugInput}'는 설치되어 있지 않습니다.` };
54
+ if (json) {
55
+ console.error(JSON.stringify(msg));
56
+ }
57
+ else {
58
+ console.error(`\x1b[31m오류:\x1b[0m ${msg.message}`);
59
+ }
60
+ process.exit(1);
61
+ }
62
+ // Update deploy info
63
+ entry.deploy_scope = scope;
64
+ entry.deployed_files = resolvedFiles;
65
+ // Save to the correct registry based on scope
66
+ if (scope === 'global') {
67
+ globalRegistry[slug] = entry;
68
+ (0, config_js_1.saveGlobalInstalled)(globalRegistry);
69
+ // Also update local registry if entry exists there
70
+ if (localRegistry[slug]) {
71
+ localRegistry[slug] = entry;
72
+ (0, config_js_1.saveInstalled)(localRegistry);
73
+ }
74
+ }
75
+ else {
76
+ localRegistry[slug] = entry;
77
+ (0, config_js_1.saveInstalled)(localRegistry);
78
+ }
79
+ const result = {
80
+ status: 'ok',
81
+ slug,
82
+ deploy_scope: scope,
83
+ deployed_files: resolvedFiles.length,
84
+ };
85
+ if (json) {
86
+ console.log(JSON.stringify(result));
87
+ }
88
+ else {
89
+ const scopeLabel = scope === 'global' ? '글로벌' : '로컬';
90
+ console.log(`\x1b[32m✓ ${slug} 배치 정보 기록 완료\x1b[0m (${scopeLabel}, ${resolvedFiles.length}개 파일)`);
91
+ }
92
+ });
93
+ }
@@ -1,4 +1,12 @@
1
1
  import { Command } from 'commander';
2
+ /**
3
+ * 글로벌 User 커맨드를 ~/.claude/commands/relay/에 설치한다.
4
+ * 기존 파일 중 현재 커맨드 목록에 없는 것은 제거한다.
5
+ */
6
+ export declare function installGlobalUserCommands(): {
7
+ installed: boolean;
8
+ commands: string[];
9
+ };
2
10
  /**
3
11
  * 글로벌 User 커맨드가 이미 설치되어 있는지 확인한다.
4
12
  */
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.installGlobalUserCommands = installGlobalUserCommands;
6
7
  exports.hasGlobalUserCommands = hasGlobalUserCommands;
7
8
  exports.registerInit = registerInit;
8
9
  const fs_1 = __importDefault(require("fs"));
@@ -38,14 +38,12 @@ function registerInstall(program) {
38
38
  const json = program.opts().json ?? false;
39
39
  const projectPath = process.cwd();
40
40
  const tempDir = (0, storage_js_1.makeTempDir)();
41
+ // Auto-init: 글로벌 커맨드가 없으면 자동 설치
41
42
  if (!(0, init_js_1.hasGlobalUserCommands)()) {
42
43
  if (!json) {
43
- console.error('\x1b[33m relay init이 실행되지 않았습니다. 먼저 relay init을 실행하세요.\x1b[0m');
44
+ console.error('\x1b[33m 글로벌 커맨드를 자동 설치합니다...\x1b[0m');
44
45
  }
45
- else {
46
- console.error(JSON.stringify({ error: 'NOT_INITIALIZED', message: 'relay init을 먼저 실행하세요.' }));
47
- }
48
- process.exit(1);
46
+ (0, init_js_1.installGlobalUserCommands)();
49
47
  }
50
48
  try {
51
49
  // 0. @spaces/{spaceSlug}/{teamSlug} 형식 감지 및 파싱
@@ -128,23 +126,33 @@ function registerInstall(program) {
128
126
  }
129
127
  }
130
128
  const teamDir = path_1.default.join(projectPath, '.relay', 'teams', parsed.owner, parsed.name);
131
- // 2. Visibility check
129
+ // 2. Visibility check + auto-login
132
130
  const visibility = team.visibility ?? 'public';
133
131
  if (visibility === 'private') {
134
- const token = await (0, config_js_1.getValidToken)();
132
+ let token = await (0, config_js_1.getValidToken)();
135
133
  if (!token) {
136
- if (json) {
137
- console.error(JSON.stringify({
138
- error: 'LOGIN_REQUIRED',
139
- visibility,
140
- slug,
141
- message: '이 팀은 Space 멤버만 설치할 수 있습니다. 로그인이 필요합니다.',
142
- }));
134
+ const isTTY = Boolean(process.stdin.isTTY);
135
+ if (isTTY && !json) {
136
+ // Auto-login: TTY 환경에서 자동으로 login 플로우 트리거
137
+ console.error('\x1b[33m⚙ 이 팀은 로그인이 필요합니다. 로그인을 시작합니다...\x1b[0m');
138
+ const { runLogin } = await import('./login.js');
139
+ await runLogin();
140
+ token = await (0, config_js_1.getValidToken)();
143
141
  }
144
- else {
145
- console.error('\x1b[31m이 팀은 Space 멤버만 설치할 수 있습니다. relay login 을 먼저 실행하세요.\x1b[0m');
142
+ if (!token) {
143
+ if (json) {
144
+ console.error(JSON.stringify({
145
+ error: 'LOGIN_REQUIRED',
146
+ visibility,
147
+ slug,
148
+ message: '이 팀은 로그인이 필요합니다. relay login을 먼저 실행하세요.',
149
+ }));
150
+ }
151
+ else {
152
+ console.error('\x1b[31m이 팀은 로그인이 필요합니다. relay login 을 먼저 실행하세요.\x1b[0m');
153
+ }
154
+ process.exit(1);
146
155
  }
147
- process.exit(1);
148
156
  }
149
157
  }
150
158
  // 3. Download package
@@ -252,7 +260,7 @@ function registerInstall(program) {
252
260
  else {
253
261
  console.log(`\n\x1b[33m💡 설치 완료! AI 에이전트에서 사용할 수 있습니다.\x1b[0m`);
254
262
  }
255
- console.log('\n 에이전트가 /relay-install로 환경을 구성합니다.');
263
+ console.log('\n \x1b[90m에이전트가 /relay-install로 환경을 구성합니다.\x1b[0m');
256
264
  }
257
265
  }
258
266
  catch (err) {
@@ -68,27 +68,53 @@ function registerList(program) {
68
68
  }
69
69
  return;
70
70
  }
71
- // 기본 동작: 로컬에 설치된 목록
72
- const installed = (0, config_js_1.loadInstalled)();
73
- const entries = Object.entries(installed);
74
- const installedList = entries.map(([slug, info]) => ({
75
- slug,
76
- version: info.version,
77
- installed_at: info.installed_at,
78
- files: info.files.length,
79
- }));
71
+ // 기본 동작: 글로벌 + 로컬 통합 목록
72
+ const { global: globalInstalled, local: localInstalled } = (0, config_js_1.loadMergedInstalled)();
73
+ const allEntries = [];
74
+ const seen = new Set();
75
+ // 글로벌 먼저
76
+ for (const [slug, info] of Object.entries(globalInstalled)) {
77
+ allEntries.push({
78
+ slug,
79
+ version: info.version,
80
+ installed_at: info.installed_at,
81
+ scope: 'global',
82
+ deploy_scope: info.deploy_scope,
83
+ space_slug: info.space_slug,
84
+ });
85
+ seen.add(slug);
86
+ }
87
+ // 로컬 (글로벌과 중복되지 않는 것만)
88
+ for (const [slug, info] of Object.entries(localInstalled)) {
89
+ if (seen.has(slug))
90
+ continue;
91
+ allEntries.push({
92
+ slug,
93
+ version: info.version,
94
+ installed_at: info.installed_at,
95
+ scope: 'local',
96
+ deploy_scope: info.deploy_scope,
97
+ space_slug: info.space_slug,
98
+ });
99
+ }
80
100
  if (json) {
81
- console.log(JSON.stringify({ installed: installedList }));
101
+ console.log(JSON.stringify({ installed: allEntries }));
82
102
  }
83
103
  else {
84
- if (installedList.length === 0) {
104
+ if (allEntries.length === 0) {
85
105
  console.log('\n설치된 팀이 없습니다. `relay install <slug>`로 설치하세요.');
86
106
  return;
87
107
  }
88
- console.log(`\n설치된 팀 (${installedList.length}개):\n`);
89
- for (const item of installedList) {
108
+ console.log(`\n설치된 팀 (${allEntries.length}개):\n`);
109
+ for (const item of allEntries) {
90
110
  const date = new Date(item.installed_at).toLocaleDateString('ko-KR');
91
- console.log(` \x1b[36m${item.slug}\x1b[0m v${item.version} (${date}) 파일 ${item.files}개`);
111
+ const scopeLabel = item.deploy_scope === 'global'
112
+ ? '\x1b[32m글로벌\x1b[0m'
113
+ : item.deploy_scope === 'local'
114
+ ? '\x1b[33m로컬\x1b[0m'
115
+ : '\x1b[90m미배치\x1b[0m';
116
+ const spaceLabel = item.space_slug ? ` \x1b[90m[Space: ${item.space_slug}]\x1b[0m` : '';
117
+ console.log(` \x1b[36m${item.slug}\x1b[0m v${item.version} ${scopeLabel} (${date})${spaceLabel}`);
92
118
  }
93
119
  }
94
120
  });
@@ -1,2 +1,7 @@
1
1
  import { Command } from 'commander';
2
+ /**
3
+ * 대화형 로그인 플로우 실행 (auto-login에서 호출).
4
+ * 로그인 성공 시 토큰 저장, 실패 시 throw.
5
+ */
6
+ export declare function runLogin(): Promise<void>;
2
7
  export declare function registerLogin(program: Command): void;
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.runLogin = runLogin;
6
7
  exports.registerLogin = registerLogin;
7
8
  const http_1 = __importDefault(require("http"));
8
9
  const readline_1 = __importDefault(require("readline"));
@@ -38,9 +39,6 @@ async function verifyToken(token) {
38
39
  return null;
39
40
  }
40
41
  }
41
- function parseFormBody(body) {
42
- return new URLSearchParams(body);
43
- }
44
42
  function collectBody(req) {
45
43
  return new Promise((resolve) => {
46
44
  const chunks = [];
@@ -65,16 +63,9 @@ function waitForToken(port) {
65
63
  }, 5 * 60 * 1000);
66
64
  const server = http_1.default.createServer(async (req, res) => {
67
65
  const url = new URL(req.url ?? '/', `http://localhost:${port}`);
68
- if (url.pathname === '/callback') {
69
- // POST body (secure) 또는 GET query params (하위 호환) 모두 지원
70
- let params;
71
- if (req.method === 'POST') {
72
- const body = await collectBody(req);
73
- params = parseFormBody(body);
74
- }
75
- else {
76
- params = url.searchParams;
77
- }
66
+ if (url.pathname === '/callback' && req.method === 'POST') {
67
+ const body = await collectBody(req);
68
+ const params = new URLSearchParams(body);
78
69
  const token = params.get('token');
79
70
  const refresh_token = params.get('refresh_token') ?? undefined;
80
71
  const expires_at_raw = params.get('expires_at');
@@ -210,6 +201,31 @@ async function selectProvider(json) {
210
201
  return 'email';
211
202
  return 'github';
212
203
  }
204
+ /**
205
+ * 대화형 로그인 플로우 실행 (auto-login에서 호출).
206
+ * 로그인 성공 시 토큰 저장, 실패 시 throw.
207
+ */
208
+ async function runLogin() {
209
+ (0, config_js_1.ensureGlobalRelayDir)();
210
+ const provider = await selectProvider(false);
211
+ let loginResult;
212
+ if (provider === 'email') {
213
+ loginResult = await loginWithEmail(false);
214
+ }
215
+ else if (provider === 'kakao') {
216
+ loginResult = await loginWithBrowser('kakao', false);
217
+ }
218
+ else {
219
+ loginResult = await loginWithBrowser('github', false);
220
+ }
221
+ await verifyToken(loginResult.token);
222
+ (0, config_js_1.saveTokenData)({
223
+ access_token: loginResult.token,
224
+ ...(loginResult.refresh_token ? { refresh_token: loginResult.refresh_token } : {}),
225
+ ...(loginResult.expires_at ? { expires_at: loginResult.expires_at } : {}),
226
+ });
227
+ console.log(`\x1b[32m✓ 로그인 완료\x1b[0m`);
228
+ }
213
229
  function registerLogin(program) {
214
230
  program
215
231
  .command('login')
@@ -646,6 +646,19 @@ function registerPublish(program) {
646
646
  console.log(` \x1b[90m└${'─'.repeat(44)}┘\x1b[0m`);
647
647
  console.log(`\n \x1b[90m명함 수정: \x1b[36mwww.relayax.com/dashboard/profile\x1b[0m`);
648
648
  }
649
+ // Show shareable onboarding guide as a plain copyable block
650
+ if (isTTY) {
651
+ const guideLines = [
652
+ 'npm install -g relayax-cli',
653
+ 'relay login',
654
+ `relay install ${result.slug}`,
655
+ ];
656
+ console.log(`\n \x1b[90m공유용 온보딩 가이드:\x1b[0m\n`);
657
+ console.log('```');
658
+ guideLines.forEach((line) => console.log(line));
659
+ console.log('```');
660
+ console.log(`\n \x1b[90m위 블록을 복사하여 팀원에게 공유하세요\x1b[0m`);
661
+ }
649
662
  }
650
663
  }
651
664
  catch (err) {
@@ -1,6 +1,11 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.registerUninstall = registerUninstall;
7
+ const os_1 = __importDefault(require("os"));
8
+ const path_1 = __importDefault(require("path"));
4
9
  const config_js_1 = require("../lib/config.js");
5
10
  const installer_js_1 = require("../lib/installer.js");
6
11
  const slug_js_1 = require("../lib/slug.js");
@@ -10,17 +15,24 @@ function registerUninstall(program) {
10
15
  .description('에이전트 팀 제거')
11
16
  .action((slugInput) => {
12
17
  const json = program.opts().json ?? false;
13
- const installed = (0, config_js_1.loadInstalled)();
14
- // Resolve slug from installed.json
18
+ const localInstalled = (0, config_js_1.loadInstalled)();
19
+ const globalInstalled = (0, config_js_1.loadGlobalInstalled)();
20
+ // Resolve slug — support short names like "cardnews-team"
15
21
  let slug;
16
22
  if ((0, slug_js_1.isScopedSlug)(slugInput)) {
17
23
  slug = slugInput;
18
24
  }
19
25
  else {
20
- const found = (0, slug_js_1.findInstalledByName)(installed, slugInput);
21
- slug = found ?? slugInput;
26
+ const allKeys = [...Object.keys(localInstalled), ...Object.keys(globalInstalled)];
27
+ const match = allKeys.find((key) => {
28
+ const parsed = (0, slug_js_1.parseSlug)(key);
29
+ return parsed && parsed.name === slugInput;
30
+ });
31
+ slug = match ?? slugInput;
22
32
  }
23
- if (!installed[slug]) {
33
+ const localEntry = localInstalled[slug];
34
+ const globalEntry = globalInstalled[slug];
35
+ if (!localEntry && !globalEntry) {
24
36
  const msg = { error: 'NOT_INSTALLED', message: `'${slugInput}'는 설치되어 있지 않습니다.` };
25
37
  if (json) {
26
38
  console.error(JSON.stringify(msg));
@@ -30,21 +42,55 @@ function registerUninstall(program) {
30
42
  }
31
43
  process.exit(1);
32
44
  }
33
- const { files } = installed[slug];
34
- const removed = (0, installer_js_1.uninstallTeam)(files);
35
- delete installed[slug];
36
- (0, config_js_1.saveInstalled)(installed);
45
+ let totalRemoved = 0;
46
+ // Remove from local registry
47
+ if (localEntry) {
48
+ const removed = (0, installer_js_1.uninstallTeam)(localEntry.files);
49
+ totalRemoved += removed.length;
50
+ // Remove deployed files
51
+ if (localEntry.deployed_files && localEntry.deployed_files.length > 0) {
52
+ const deployedRemoved = (0, installer_js_1.uninstallTeam)(localEntry.deployed_files);
53
+ totalRemoved += deployedRemoved.length;
54
+ // Clean empty parent directories
55
+ const boundary = path_1.default.join(process.cwd(), '.claude');
56
+ for (const f of deployedRemoved) {
57
+ (0, installer_js_1.cleanEmptyParents)(f, boundary);
58
+ }
59
+ }
60
+ delete localInstalled[slug];
61
+ (0, config_js_1.saveInstalled)(localInstalled);
62
+ }
63
+ // Remove from global registry
64
+ if (globalEntry) {
65
+ // Only remove files if not already handled by local entry
66
+ if (!localEntry) {
67
+ const removed = (0, installer_js_1.uninstallTeam)(globalEntry.files);
68
+ totalRemoved += removed.length;
69
+ }
70
+ // Remove globally deployed files
71
+ if (globalEntry.deployed_files && globalEntry.deployed_files.length > 0) {
72
+ const deployedRemoved = (0, installer_js_1.uninstallTeam)(globalEntry.deployed_files);
73
+ totalRemoved += deployedRemoved.length;
74
+ // Clean empty parent directories
75
+ const boundary = path_1.default.join(os_1.default.homedir(), '.claude');
76
+ for (const f of deployedRemoved) {
77
+ (0, installer_js_1.cleanEmptyParents)(f, boundary);
78
+ }
79
+ }
80
+ delete globalInstalled[slug];
81
+ (0, config_js_1.saveGlobalInstalled)(globalInstalled);
82
+ }
37
83
  const result = {
38
84
  status: 'ok',
39
85
  team: slug,
40
- files_removed: removed.length,
86
+ files_removed: totalRemoved,
41
87
  };
42
88
  if (json) {
43
89
  console.log(JSON.stringify(result));
44
90
  }
45
91
  else {
46
92
  console.log(`\n\x1b[32m✓ ${slug} 제거 완료\x1b[0m`);
47
- console.log(` 삭제된 파일: ${removed.length}개`);
93
+ console.log(` 삭제된 파일: ${totalRemoved}개`);
48
94
  }
49
95
  });
50
96
  }
@@ -26,14 +26,8 @@ function registerUpdate(program) {
26
26
  slug = slugInput;
27
27
  }
28
28
  else {
29
- const found = (0, slug_js_1.findInstalledByName)(installed, slugInput);
30
- if (found) {
31
- slug = found;
32
- }
33
- else {
34
- const parsed = await (0, slug_js_1.resolveSlug)(slugInput);
35
- slug = parsed.full;
36
- }
29
+ const parsed = await (0, slug_js_1.resolveSlug)(slugInput);
30
+ slug = parsed.full;
37
31
  }
38
32
  // Check installed.json for current version
39
33
  const currentEntry = installed[slug];
@@ -68,11 +62,17 @@ function registerUpdate(program) {
68
62
  (0, preamble_js_1.injectPreambleToTeam)(extractDir, slug);
69
63
  // Copy files to install_path
70
64
  const files = (0, installer_js_1.installTeam)(extractDir, installPath);
65
+ // Preserve deploy info but clear deployed_files (agent needs to re-deploy)
66
+ const previousDeployScope = currentEntry?.deploy_scope;
67
+ const hadDeployedFiles = (currentEntry?.deployed_files?.length ?? 0) > 0;
71
68
  // Update installed.json with new version
72
69
  installed[slug] = {
73
70
  version: latestVersion,
74
71
  installed_at: new Date().toISOString(),
75
72
  files,
73
+ // Keep deploy_scope so agent knows where to re-deploy
74
+ ...(previousDeployScope ? { deploy_scope: previousDeployScope } : {}),
75
+ // Clear deployed_files — agent must re-deploy and call deploy-record
76
76
  };
77
77
  (0, config_js_1.saveInstalled)(installed);
78
78
  // Report install (non-blocking)
@@ -84,6 +84,7 @@ function registerUpdate(program) {
84
84
  version: latestVersion,
85
85
  files_installed: files.length,
86
86
  install_path: installPath,
87
+ ...(hadDeployedFiles ? { needs_redeploy: true, previous_deploy_scope: previousDeployScope } : {}),
87
88
  };
88
89
  if (json) {
89
90
  console.log(JSON.stringify(result));
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ const follow_js_1 = require("./commands/follow.js");
18
18
  const changelog_js_1 = require("./commands/changelog.js");
19
19
  const join_js_1 = require("./commands/join.js");
20
20
  const spaces_js_1 = require("./commands/spaces.js");
21
+ const deploy_record_js_1 = require("./commands/deploy-record.js");
21
22
  // eslint-disable-next-line @typescript-eslint/no-var-requires
22
23
  const pkg = require('../package.json');
23
24
  const program = new commander_1.Command();
@@ -42,4 +43,5 @@ program
42
43
  (0, changelog_js_1.registerChangelog)(program);
43
44
  (0, join_js_1.registerJoin)(program);
44
45
  (0, spaces_js_1.registerSpaces)(program);
46
+ (0, deploy_record_js_1.registerDeployRecord)(program);
45
47
  program.parse();
@@ -29,5 +29,3 @@ export declare function getGlobalCommandDir(): string;
29
29
  export declare function formatCommandFile(content: CommandContent): string;
30
30
  export declare const USER_COMMANDS: CommandContent[];
31
31
  export declare const BUILDER_COMMANDS: CommandContent[];
32
- /** 하위 호환 — 기존 코드에서 RELAY_COMMANDS를 참조하는 경우 */
33
- export declare const RELAY_COMMANDS: CommandContent[];
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.RELAY_COMMANDS = exports.BUILDER_COMMANDS = exports.USER_COMMANDS = void 0;
6
+ exports.BUILDER_COMMANDS = exports.USER_COMMANDS = void 0;
7
7
  exports.createAdapter = createAdapter;
8
8
  exports.getGlobalCommandPath = getGlobalCommandPath;
9
9
  exports.getGlobalCommandDir = getGlobalCommandDir;
@@ -20,9 +20,7 @@ function createAdapter(tool) {
20
20
  getFilePath(commandId) {
21
21
  return path_1.default.join(tool.skillsDir, 'commands', 'relay', `${commandId}.md`);
22
22
  },
23
- formatFile(content) {
24
- return `---\ndescription: ${content.description}\n---\n\n${content.body}\n`;
25
- },
23
+ formatFile: formatCommandFile,
26
24
  };
27
25
  }
28
26
  /**
@@ -101,44 +99,46 @@ exports.USER_COMMANDS = [
101
99
 
102
100
  ## 실행 방법
103
101
 
104
- ### 0. 업데이트 확인
105
- - 먼저 \`relay check-update\` 명령어를 실행합니다.
106
- - CLI 업데이트가 있으면 사용자에게 안내합니다: "relay v{new} available. Run: npm update -g relayax-cli"
107
- - 업데이트가 있으면 안내합니다.
108
- - 업데이트 여부와 관계없이 설치를 계속 진행합니다.
109
-
110
- ### 1. 팀 패키지 다운로드
111
- - Public 마켓 팀: \`relay install <@author/slug>\` 명령어를 실행합니다.
112
- - Space 팀: \`relay install @spaces/<space-slug>/<team-slug>\` 명령어를 실행합니다.
102
+ ### 1. 패키지 다운로드
103
+ \`relay install <@author/slug> --json\` 명령어를 실행합니다.
104
+ - Public 마켓 팀: \`relay install <@author/slug> --json\`
105
+ - Space 팀: \`relay install @spaces/<space-slug>/<team-slug> --json\`
113
106
  - Space 가입이 필요하면: \`relay join <space-slug> --code <invite-code>\` 를 먼저 실행합니다.
114
- - 또는 \`relay install @spaces/<space-slug>/<team-slug> --join-code <code>\` 가입+설치를 한번에 할 수 있습니다.
115
- - 패키지가 \`.relay/teams/<slug>/\`에 다운로드됩니다.
116
-
117
- ### 2. 패키지 내용 확인
118
- 다운로드된 \`.relay/teams/<slug>/\` 디렉토리를 읽어 구조를 파악합니다:
119
- - skills/ 스킬 파일들
120
- - commands/ — 슬래시 커맨드 파일들
121
- - agents/ 에이전트 설정 파일들
122
- - rules/ 파일들
123
- - relay.yaml — 팀 메타데이터 및 requirements
124
-
125
- ### 3. 기존 파일 충돌 확인
126
- 배치 대상 디렉토리(\`.claude/commands/\`, \`.claude/skills/\` 등)에 **같은 이름의 파일이 이미 존재하는지** 확인합니다.
127
- - 충돌하는 파일이 있으면 사용자에게 반드시 물어봅니다:
128
- - "다음 파일이 이미 존재합니다: {파일 목록}. 덮어쓸까요, 건너뛸까요?"
129
- - 사용자가 선택할 때까지 진행하지 않습니다.
130
- - 충돌이 없으면 그대로 진행합니다.
131
- - **주의**: 팀에 포함되지 않은 기존 파일은 절대 삭제하지 않습니다.
132
-
133
- ### 4. 에이전트 환경에 맞게 배치
134
- 현재 에이전트의 디렉토리 구조에 맞게 파일을 복사합니다:
135
- - Claude Code: \`.relay/teams/<slug>/commands/\` → \`.claude/commands/\`에 복사
136
- - Claude Code: \`.relay/teams/<slug>/skills/\` → \`.claude/skills/\`에 복사
137
- - 다른 에이전트(Cursor, Cline 등): 해당 에이전트의 규칙에 맞는 디렉토리에 복사
138
- - 에이전트 설정이나 룰은 적절한 위치에 배치
107
+ - 또는 \`--join-code <code>\`로 가입+설치를 한번에 할 수 있습니다.
108
+ - CLI가 init과 login을 자동으로 처리합니다 (사용자가 별도 실행할 필요 없음).
109
+ - JSON 출력에서 \`install_path\` (패키지 경로)를 확인합니다.
110
+
111
+ ### 2. 배치 범위 선택
112
+ 사용자에게 설치 범위를 물어봅니다:
113
+
114
+ - **글로벌** (\`~/.claude/\`): 모든 프로젝트에서 사용 가능
115
+ - **로컬** (현재 프로젝트 \`.claude/\`): 이 프로젝트에서만 사용
116
+
117
+ 판단 기준:
118
+ - 범용 도구 (카드뉴스, PDF 생성 등): 글로벌 추천
119
+ - 프로젝트 전용 팀: 로컬 추천
120
+ - Space 비공개 팀: 로컬 추천
121
+
122
+ 사용자가 별도 지정하지 않으면 글로벌로 진행합니다.
123
+
124
+ ### 3. 에이전트 환경에 맞게 배치
125
+ 다운로드된 패키지(\`install_path\`)에서 파일을 읽고 선택된 범위에 배치합니다:
126
+ - Claude Code 글로벌: \`<install_path>/commands/\` → \`~/.claude/commands/\`에 복사
127
+ - Claude Code 글로벌: \`<install_path>/skills/\` \`~/.claude/skills/\`에 복사
128
+ - Claude Code 로컬: \`<install_path>/commands/\` → \`.claude/commands/\`에 복사
129
+ - Claude Code 로컬: \`<install_path>/skills/\` → \`.claude/skills/\`에 복사
130
+ - agents/, rules/ 파일도 같은 방식으로 배치합니다.
131
+ - **충돌 확인**: 같은 이름의 파일이 이미 있으면 사용자에게 덮어쓸지 물어봅니다.
132
+
133
+ ### 4. 배치 정보 기록 (필수)
134
+ 배치 완료 후 반드시 \`relay deploy-record\`를 실행하여 배치 정보를 기록합니다:
135
+ \`\`\`
136
+ relay deploy-record <slug> --scope <global|local> --files <배치된_파일1> <배치된_파일2> ...
137
+ \`\`\`
138
+ 이 정보는 \`relay uninstall\` 시 배치된 파일까지 정리하는 데 사용됩니다.
139
139
 
140
140
  ### 5. Requirements 확인 및 설치
141
- \`.relay/teams/<slug>/relay.yaml\`의 \`requires\` 섹션을 읽고 처리합니다:
141
+ \`<install_path>/relay.yaml\`의 \`requires\` 섹션을 읽고 처리합니다:
142
142
  - **cli**: \`which <name>\`으로 확인 → 없으면 install 명령 실행 또는 안내
143
143
  - **npm**: \`npm list <package>\`로 확인 → 없으면 \`npm install\`
144
144
  - **env**: 환경변수 확인 → required이면 설정 안내, optional이면 알림
@@ -155,21 +155,20 @@ ${BUSINESS_CARD_FORMAT}
155
155
  - 거절하면: 건너뜁니다
156
156
  - "바로 사용해볼까요?" 제안
157
157
 
158
+ ### 7. 업데이트 확인 (설치 완료 후)
159
+ - \`relay check-update\` 명령어를 실행합니다.
160
+ - CLI 업데이트가 있으면 안내합니다: "relay v{new} available. Run: npm update -g relayax-cli"
161
+ - 다른 팀 업데이트가 있으면 안내합니다.
162
+
158
163
  ## 예시
159
164
 
160
165
  사용자: /relay-install @example/contents-team
161
- → relay install @example/contents-team 실행 (패키지 다운로드)
162
- .relay/teams/@example/contents-team/ 내용 확인
163
- commands/cardnews.md .claude/commands/cardnews.md 복사
164
- skills/pdf-gen.md .claude/skills/pdf-gen.md 복사
166
+ → relay install @example/contents-team --json 실행 (패키지 다운로드)
167
+ 사용자에게 "글로벌 vs 로컬" 선택 질문 → 글로벌
168
+ .relay/teams/ 내용을 ~/.claude/에 배치
169
+ relay deploy-record @example/contents-team --scope global --files ~/.claude/commands/cardnews.md ...
165
170
  → requires 확인: ✓ playwright 설치됨, ✓ sharp 설치됨
166
- → "✓ 설치 완료! /cardnews를 사용해볼까요?"
167
-
168
- 사용자: /relay-install @spaces/bobusan/pm-bot
169
- → relay install @spaces/bobusan/pm-bot 실행
170
- → Space 멤버 확인 → 정상
171
- → 패키지 다운로드 및 배치
172
- → "✓ 설치 완료!"`,
171
+ → "✓ 설치 완료! /cardnews를 사용해볼까요?"`,
173
172
  },
174
173
  {
175
174
  id: 'relay-list',
@@ -218,6 +217,9 @@ ${BUSINESS_CARD_FORMAT}
218
217
  ### 특정 팀 업데이트
219
218
  - 사용자가 팀 이름을 지정한 경우: \`relay update <@author/slug> --json\` 실행
220
219
  - 업데이트 결과를 보여줍니다 (이전 버전 → 새 버전)
220
+ - **재배치 필요 확인**: JSON 출력에 \`needs_redeploy: true\`가 있으면:
221
+ 1. \`previous_deploy_scope\`를 참고하여 같은 범위(글로벌/로컬)로 파일을 다시 배치합니다.
222
+ 2. 배치 후 \`relay deploy-record <slug> --scope <scope> --files <...>\`를 실행하여 기록합니다.
221
223
  ${BUSINESS_CARD_FORMAT}
222
224
 
223
225
  ### 전체 업데이트 확인
@@ -235,18 +237,50 @@ ${BUSINESS_CARD_FORMAT}
235
237
  → " @example/contents-team: v1.2.0 → v1.3.0"
236
238
  → "업데이트할까요?"
237
239
  → relay update @example/contents-team --json 실행
240
+ → needs_redeploy: true → 글로벌로 재배치
241
+ → relay deploy-record @example/contents-team --scope global --files ...
238
242
  → "✓ @example/contents-team v1.3.0으로 업데이트 완료"`,
243
+ },
244
+ {
245
+ id: 'relay-spaces',
246
+ description: '내 Space 목록을 확인합니다',
247
+ body: `사용자의 Space 목록을 조회하고 보여줍니다.
248
+
249
+ ## 실행 방법
250
+
251
+ 1. \`relay spaces --json\` 명령어를 실행합니다.
252
+ 2. 결과를 사용자에게 보기 좋게 정리합니다:
253
+ - 개인 스페이스
254
+ - 팀 스페이스 (이름, 역할, 설명)
255
+ 3. Space가 있으면 관련 활용법을 안내합니다:
256
+ - 팀 목록 보기: \`relay list --space <slug>\`
257
+ - 비공개 팀 설치: \`relay install @spaces/<slug>/<team>\`
258
+ - Space 관리: www.relayax.com/spaces/<slug>
259
+ ${LOGIN_JIT_GUIDE}
260
+
261
+ ## 예시
262
+
263
+ 사용자: /relay-spaces
264
+ → relay spaces --json 실행
265
+ → "2개 Space가 있어요:"
266
+ → " bobusan — 보부산 (소유자)"
267
+ → " design-lab — 디자인 랩 (멤버)"
268
+ → "💡 Space 팀 보기: relay list --space bobusan"`,
239
269
  },
240
270
  {
241
271
  id: 'relay-uninstall',
242
272
  description: '설치된 에이전트 팀을 삭제합니다',
243
- body: `설치된 에이전트 팀을 프로젝트에서 제거합니다.
273
+ body: `설치된 에이전트 팀을 제거합니다. CLI가 패키지와 배치된 파일을 모두 정리합니다.
244
274
 
245
275
  ## 실행 방법
246
276
 
247
277
  1. \`relay uninstall <@author/slug> --json\` 명령어를 실행합니다.
248
- 2. 삭제 결과를 보여줍니다 (팀 이름, 제거된 파일 수).
249
- 3. 삭제 완료 후 남아있는 팀 목록을 간단히 안내합니다.
278
+ 2. CLI가 자동으로 처리하는 것:
279
+ - \`.relay/teams/\` 패키지 삭제
280
+ - \`deployed_files\`에 기록된 배치 파일 삭제 (\`~/.claude/\` 또는 \`.claude/\`)
281
+ - 빈 상위 디렉토리 정리
282
+ - installed.json에서 항목 제거 (글로벌/로컬 양쪽)
283
+ 3. 삭제 결과를 보여줍니다 (팀 이름, 제거된 파일 수).
250
284
 
251
285
  ## 예시
252
286
 
@@ -265,9 +299,9 @@ exports.BUILDER_COMMANDS = [
265
299
  ## 실행 단계
266
300
 
267
301
  ### 1. 인증 확인 (가장 먼저)
268
- - \`cat ~/.relay/token\` 또는 환경변수 RELAY_TOKEN으로 토큰 존재 여부를 확인합니다.
269
- - 미인증이면 즉시 안내: "먼저 \`relay login\`으로 로그인이 필요합니다." → 로그인 후 재실행 안내.
270
- - 인증되어 있으면 다음 단계로 진행합니다.
302
+ - \`relay status --json\` 명령어를 실행하여 로그인 상태를 확인합니다.
303
+ - 미인증이면 즉시 \`relay login\`을 실행합니다.
304
+ - 로그인 완료 다음 단계로 진행합니다.
271
305
  ${LOGIN_JIT_GUIDE}
272
306
 
273
307
  ### 2. 팀 구조 분석
@@ -363,8 +397,9 @@ requires:
363
397
  - 각 스킬의 SKILL.md, 에이전트 설정, 커맨드 문서를 분석하여 팀의 파이프라인 흐름을 추론합니다.
364
398
 
365
399
  #### 5-2. GUIDE.html 생성
366
- - \`cli/src/lib/guide.ts\`의 \`GUIDE_HTML_PROMPT\`를 읽고, 해당 프롬프트의 콘텐츠 구조와 디자인 규칙을 따라 GUIDE.html을 생성합니다.
367
- - 5-1에서 분석한 소스 정보를 프롬프트에 반영합니다.
400
+ - 팀의 핵심 기능, 시작 방법, 파이프라인 흐름, Q&A를 포함하는 단일 HTML 가이드를 생성합니다.
401
+ - 디자인: 깔끔한 단일 페이지, 시스템 폰트, 최대 1200px 너비, 라이트 테마.
402
+ - 5-1에서 분석한 팀 소스 정보를 기반으로 콘텐츠를 구성합니다.
368
403
  - 파이프라인이 없는 단순한 팀은 시작 방법 + 기능 설명 + Q&A만 포함합니다.
369
404
 
370
405
  #### 5-3. 미리보기 + 컨펌
@@ -419,6 +454,20 @@ portfolio:
419
454
  ### 9. 배포
420
455
  - \`relay publish\` 명령어를 실행합니다.
421
456
  - 배포 결과와 마켓플레이스 URL을 보여줍니다.
457
+
458
+ ### 10. 공유용 온보딩 가이드 제공
459
+ - \`relay publish\` 출력 끝에 코드블록 형태의 온보딩 가이드가 포함됩니다.
460
+ - 이 코드블록을 사용자에게 그대로 보여줍니다.
461
+ - 출력에 코드블록이 없으면 아래 형태로 직접 생성합니다:
462
+
463
+ \\\`\\\`\\\`
464
+ npm install -g relayax-cli
465
+ relay login
466
+ relay install <slug>
467
+ \\\`\\\`\\\`
468
+
469
+ - \`<slug>\`는 배포된 팀의 실제 슬러그로 치환합니다.
470
+ - "이 블록을 팀원에게 공유하면 바로 설치할 수 있습니다"라고 안내합니다.
422
471
  ${BUSINESS_CARD_FORMAT}
423
472
 
424
473
  ## 예시
@@ -432,8 +481,8 @@ ${BUSINESS_CARD_FORMAT}
432
481
  → GUIDE.html 생성 → 브라우저에서 미리보기 → 빌더 컨펌
433
482
  → GUIDE.html 스크린샷 → gallery 첫 번째 이미지로 등록
434
483
  → relay publish 실행
435
- → "배포 완료! URL: https://relayax.com/teams/my-team"`,
484
+ → "배포 완료! URL: https://relayax.com/teams/my-team"
485
+ → 온보딩 가이드 코드블록 표시
486
+ → "이 블록을 팀원에게 공유하면 바로 설치할 수 있습니다"`,
436
487
  },
437
488
  ];
438
- /** 하위 호환 — 기존 코드에서 RELAY_COMMANDS를 참조하는 경우 */
439
- exports.RELAY_COMMANDS = [...exports.USER_COMMANDS, ...exports.BUILDER_COMMANDS];
@@ -28,12 +28,16 @@ export declare function saveToken(token: string): void;
28
28
  * 4. 갱신 실패 시 undefined (재로그인 필요)
29
29
  */
30
30
  export declare function getValidToken(): Promise<string | undefined>;
31
- /** 프로젝트 로컬 installed.json 읽기 (unscoped 키 자동 마이그레이션) */
31
+ /** 프로젝트 로컬 installed.json 읽기 */
32
32
  export declare function loadInstalled(): InstalledRegistry;
33
- /**
34
- * 비동기 마이그레이션: unscoped 키를 서버 resolve하여 scoped 키로 변환.
35
- * install/update 등 비동기 커맨드에서 호출.
36
- */
37
- export declare function migrateInstalled(): Promise<void>;
38
33
  /** 프로젝트 로컬 installed.json 쓰기 */
39
34
  export declare function saveInstalled(registry: InstalledRegistry): void;
35
+ /** 글로벌 installed.json 읽기 (~/.relay/installed.json) */
36
+ export declare function loadGlobalInstalled(): InstalledRegistry;
37
+ /** 글로벌 installed.json 쓰기 (~/.relay/installed.json) */
38
+ export declare function saveGlobalInstalled(registry: InstalledRegistry): void;
39
+ /** 글로벌 + 로컬 레지스트리 병합 뷰 */
40
+ export declare function loadMergedInstalled(): {
41
+ global: InstalledRegistry;
42
+ local: InstalledRegistry;
43
+ };
@@ -13,13 +13,14 @@ exports.saveTokenData = saveTokenData;
13
13
  exports.saveToken = saveToken;
14
14
  exports.getValidToken = getValidToken;
15
15
  exports.loadInstalled = loadInstalled;
16
- exports.migrateInstalled = migrateInstalled;
17
16
  exports.saveInstalled = saveInstalled;
17
+ exports.loadGlobalInstalled = loadGlobalInstalled;
18
+ exports.saveGlobalInstalled = saveGlobalInstalled;
19
+ exports.loadMergedInstalled = loadMergedInstalled;
18
20
  const fs_1 = __importDefault(require("fs"));
19
21
  const path_1 = __importDefault(require("path"));
20
22
  const os_1 = __importDefault(require("os"));
21
23
  const ai_tools_js_1 = require("./ai-tools.js");
22
- const slug_js_1 = require("./slug.js");
23
24
  exports.API_URL = 'https://www.relayax.com';
24
25
  const GLOBAL_RELAY_DIR = path_1.default.join(os_1.default.homedir(), '.relay');
25
26
  /**
@@ -63,7 +64,6 @@ function loadTokenData() {
63
64
  const raw = fs_1.default.readFileSync(tokenFile, 'utf-8').trim();
64
65
  if (!raw)
65
66
  return undefined;
66
- // JSON 형식 (새 포맷)
67
67
  if (raw.startsWith('{')) {
68
68
  return JSON.parse(raw);
69
69
  }
@@ -121,76 +121,45 @@ async function getValidToken() {
121
121
  return undefined;
122
122
  }
123
123
  }
124
- /** 프로젝트 로컬 installed.json 읽기 (unscoped 키 자동 마이그레이션) */
124
+ /** 프로젝트 로컬 installed.json 읽기 */
125
125
  function loadInstalled() {
126
126
  const file = path_1.default.join(process.cwd(), '.relay', 'installed.json');
127
127
  if (!fs_1.default.existsSync(file)) {
128
128
  return {};
129
129
  }
130
130
  try {
131
- const raw = fs_1.default.readFileSync(file, 'utf-8');
132
- const registry = JSON.parse(raw);
133
- return migrateInstalledKeys(registry);
131
+ return JSON.parse(fs_1.default.readFileSync(file, 'utf-8'));
134
132
  }
135
133
  catch {
136
134
  return {};
137
135
  }
138
136
  }
139
- /**
140
- * unscoped 키를 감지하여 서버 resolve 없이 가능한 마이그레이션을 수행한다.
141
- * 서버 resolve가 필요한 경우는 마이그레이션 보류 (다음 기회에 재시도).
142
- */
143
- function migrateInstalledKeys(registry) {
144
- const unscopedKeys = Object.keys(registry).filter((k) => !(0, slug_js_1.isScopedSlug)(k) && k !== 'relay-core');
145
- if (unscopedKeys.length === 0)
146
- return registry;
147
- // 비동기 서버 resolve 없이는 owner를 알 수 없으므로,
148
- // loadInstalled는 동기 함수 → 마이그레이션은 비동기 migrateInstalled()로 별도 호출
149
- return registry;
150
- }
151
- /**
152
- * 비동기 마이그레이션: unscoped 키를 서버 resolve하여 scoped 키로 변환.
153
- * install/update 등 비동기 커맨드에서 호출.
154
- */
155
- async function migrateInstalled() {
156
- const { resolveSlugFromServer } = await import('./api.js');
157
- const registry = loadInstalled();
158
- const teamsDir = path_1.default.join(process.cwd(), '.relay', 'teams');
159
- let changed = false;
160
- for (const key of Object.keys(registry)) {
161
- if ((0, slug_js_1.isScopedSlug)(key) || key === 'relay-core')
162
- continue;
163
- try {
164
- const results = await resolveSlugFromServer(key);
165
- if (results.length !== 1)
166
- continue;
167
- const { owner, name } = results[0];
168
- const scopedKey = `@${owner}/${name}`;
169
- // installed.json 키 변환
170
- registry[scopedKey] = registry[key];
171
- delete registry[key];
172
- // 디렉토리 이동
173
- const oldDir = path_1.default.join(teamsDir, key);
174
- const newDir = path_1.default.join(teamsDir, owner, name);
175
- if (fs_1.default.existsSync(oldDir)) {
176
- fs_1.default.mkdirSync(path_1.default.dirname(newDir), { recursive: true });
177
- fs_1.default.renameSync(oldDir, newDir);
178
- // files 배열 업데이트
179
- registry[scopedKey].files = registry[scopedKey].files.map((f) => f.replace(`/teams/${key}`, `/teams/${owner}/${name}`));
180
- }
181
- changed = true;
182
- }
183
- catch {
184
- // 네트워크 오류 등 — 다음 기회에 재시도
185
- }
186
- }
187
- if (changed) {
188
- saveInstalled(registry);
189
- }
190
- }
191
137
  /** 프로젝트 로컬 installed.json 쓰기 */
192
138
  function saveInstalled(registry) {
193
139
  ensureProjectRelayDir();
194
140
  const file = path_1.default.join(process.cwd(), '.relay', 'installed.json');
195
141
  fs_1.default.writeFileSync(file, JSON.stringify(registry, null, 2));
196
142
  }
143
+ // ─── 글로벌 레지스트리 ───
144
+ /** 글로벌 installed.json 읽기 (~/.relay/installed.json) */
145
+ function loadGlobalInstalled() {
146
+ const file = path_1.default.join(GLOBAL_RELAY_DIR, 'installed.json');
147
+ if (!fs_1.default.existsSync(file))
148
+ return {};
149
+ try {
150
+ return JSON.parse(fs_1.default.readFileSync(file, 'utf-8'));
151
+ }
152
+ catch {
153
+ return {};
154
+ }
155
+ }
156
+ /** 글로벌 installed.json 쓰기 (~/.relay/installed.json) */
157
+ function saveGlobalInstalled(registry) {
158
+ ensureGlobalRelayDir();
159
+ const file = path_1.default.join(GLOBAL_RELAY_DIR, 'installed.json');
160
+ fs_1.default.writeFileSync(file, JSON.stringify(registry, null, 2));
161
+ }
162
+ /** 글로벌 + 로컬 레지스트리 병합 뷰 */
163
+ function loadMergedInstalled() {
164
+ return { global: loadGlobalInstalled(), local: loadInstalled() };
165
+ }
@@ -1,2 +1,7 @@
1
1
  export declare function installTeam(extractedDir: string, installPath: string): string[];
2
2
  export declare function uninstallTeam(files: string[]): string[];
3
+ /**
4
+ * 빈 상위 디렉토리를 boundary까지 정리한다.
5
+ * 예: /home/.claude/skills/cardnews/ 가 비었으면 삭제, /home/.claude/skills/는 유지
6
+ */
7
+ export declare function cleanEmptyParents(filePath: string, boundary: string): void;
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.installTeam = installTeam;
7
7
  exports.uninstallTeam = uninstallTeam;
8
+ exports.cleanEmptyParents = cleanEmptyParents;
8
9
  const fs_1 = __importDefault(require("fs"));
9
10
  const path_1 = __importDefault(require("path"));
10
11
  const COPY_DIRS = ['skills', 'agents', 'rules', 'commands'];
@@ -39,10 +40,16 @@ function uninstallTeam(files) {
39
40
  const removed = [];
40
41
  for (const file of files) {
41
42
  try {
42
- if (fs_1.default.existsSync(file)) {
43
+ if (!fs_1.default.existsSync(file))
44
+ continue;
45
+ const stat = fs_1.default.statSync(file);
46
+ if (stat.isDirectory()) {
47
+ fs_1.default.rmSync(file, { recursive: true, force: true });
48
+ }
49
+ else {
43
50
  fs_1.default.unlinkSync(file);
44
- removed.push(file);
45
51
  }
52
+ removed.push(file);
46
53
  }
47
54
  catch {
48
55
  // best-effort removal
@@ -50,3 +57,22 @@ function uninstallTeam(files) {
50
57
  }
51
58
  return removed;
52
59
  }
60
+ /**
61
+ * 빈 상위 디렉토리를 boundary까지 정리한다.
62
+ * 예: /home/.claude/skills/cardnews/ 가 비었으면 삭제, /home/.claude/skills/는 유지
63
+ */
64
+ function cleanEmptyParents(filePath, boundary) {
65
+ let dir = path_1.default.dirname(filePath);
66
+ while (dir.length > boundary.length && dir.startsWith(boundary)) {
67
+ try {
68
+ const entries = fs_1.default.readdirSync(dir);
69
+ if (entries.length > 0)
70
+ break;
71
+ fs_1.default.rmdirSync(dir);
72
+ dir = path_1.default.dirname(dir);
73
+ }
74
+ catch {
75
+ break;
76
+ }
77
+ }
78
+ }
@@ -5,7 +5,9 @@ export declare function generatePreamble(slug: string): string;
5
5
  */
6
6
  export declare function injectPreamble(filePath: string, slug: string): void;
7
7
  /**
8
- * 팀의 루트 SKILL.md와 commands/*.md에 preamble을 주입한다.
9
- * 서브 스킬의 SKILL.md는 건드리지 않는다 (frontmatter 검색에 영향).
8
+ * 팀의 사용자 진입점 파일에 preamble을 주입한다.
9
+ * - 루트 SKILL.md
10
+ * - user-invocable: true인 서브 스킬 SKILL.md
11
+ * - commands/*.md
10
12
  */
11
13
  export declare function injectPreambleToTeam(teamDir: string, slug: string): number;
@@ -54,18 +54,40 @@ function injectPreamble(filePath, slug) {
54
54
  fs_1.default.writeFileSync(filePath, cleaned);
55
55
  }
56
56
  /**
57
- * 팀의 루트 SKILL.md와 commands/*.md에 preamble을 주입한다.
58
- * 서브 스킬의 SKILL.md는 건드리지 않는다 (frontmatter 검색에 영향).
57
+ * 팀의 사용자 진입점 파일에 preamble을 주입한다.
58
+ * - 루트 SKILL.md
59
+ * - user-invocable: true인 서브 스킬 SKILL.md
60
+ * - commands/*.md
59
61
  */
60
62
  function injectPreambleToTeam(teamDir, slug) {
61
63
  let count = 0;
62
- // 1. 루트 SKILL.md (skills/<slug>/SKILL.md 레벨)
64
+ // 1. 루트 SKILL.md
63
65
  const rootSkill = path_1.default.join(teamDir, 'SKILL.md');
64
66
  if (fs_1.default.existsSync(rootSkill)) {
65
67
  injectPreamble(rootSkill, slug);
66
68
  count++;
67
69
  }
68
- // 2. commands/*.md
70
+ // 2. user-invocable 서브 스킬 SKILL.md만 (skills/**/SKILL.md)
71
+ const skillsDir = path_1.default.join(teamDir, 'skills');
72
+ if (fs_1.default.existsSync(skillsDir)) {
73
+ function walkSkills(dir) {
74
+ for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
75
+ const fullPath = path_1.default.join(dir, entry.name);
76
+ if (entry.isDirectory()) {
77
+ walkSkills(fullPath);
78
+ }
79
+ else if (entry.name === 'SKILL.md') {
80
+ const content = fs_1.default.readFileSync(fullPath, 'utf-8');
81
+ if (/user-invocable:\s*true/i.test(content)) {
82
+ injectPreamble(fullPath, slug);
83
+ count++;
84
+ }
85
+ }
86
+ }
87
+ }
88
+ walkSkills(skillsDir);
89
+ }
90
+ // 3. commands/*.md
69
91
  const commandsDir = path_1.default.join(teamDir, 'commands');
70
92
  if (fs_1.default.existsSync(commandsDir)) {
71
93
  for (const entry of fs_1.default.readdirSync(commandsDir, { withFileTypes: true })) {
@@ -17,8 +17,3 @@ export declare function isSimpleSlug(input: string): boolean;
17
17
  * 단순 slug는 서버에 resolve를 요청한다.
18
18
  */
19
19
  export declare function resolveSlug(input: string): Promise<ParsedSlug>;
20
- /**
21
- * installed.json에서 단순 slug로 매칭되는 scoped 키를 찾는다.
22
- * 네트워크 없이 로컬에서만 동작.
23
- */
24
- export declare function findInstalledByName(installed: Record<string, unknown>, name: string): string | null;
package/dist/lib/slug.js CHANGED
@@ -4,7 +4,6 @@ exports.parseSlug = parseSlug;
4
4
  exports.isScopedSlug = isScopedSlug;
5
5
  exports.isSimpleSlug = isSimpleSlug;
6
6
  exports.resolveSlug = resolveSlug;
7
- exports.findInstalledByName = findInstalledByName;
8
7
  const api_js_1 = require("./api.js");
9
8
  const SCOPED_SLUG_RE = /^@([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\/([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)$/;
10
9
  const SIMPLE_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
@@ -52,20 +51,3 @@ async function resolveSlug(input) {
52
51
  const list = results.map((r) => ` ${r.full}`).join('\n');
53
52
  throw new Error(`'${input}'에 해당하는 팀이 여러 개입니다. 전체 slug를 지정해주세요:\n${list}`);
54
53
  }
55
- /**
56
- * installed.json에서 단순 slug로 매칭되는 scoped 키를 찾는다.
57
- * 네트워크 없이 로컬에서만 동작.
58
- */
59
- function findInstalledByName(installed, name) {
60
- // 정확한 키가 있으면 그대로 (하위 호환)
61
- if (name in installed)
62
- return name;
63
- // scoped 키 중 name 부분이 매칭되는 것 찾기
64
- const matches = Object.keys(installed).filter((key) => {
65
- const parsed = parseSlug(key);
66
- return parsed && parsed.name === name;
67
- });
68
- if (matches.length === 1)
69
- return matches[0];
70
- return null;
71
- }
package/dist/types.d.ts CHANGED
@@ -10,6 +10,10 @@ export interface InstalledTeam {
10
10
  type?: 'team' | 'system';
11
11
  /** Space 소속 팀인 경우 Space slug */
12
12
  space_slug?: string;
13
+ /** 배치 범위 — 에이전트가 relay deploy-record로 기록 */
14
+ deploy_scope?: 'global' | 'local';
15
+ /** 배치된 파일 절대경로 목록 — 에이전트가 relay deploy-record로 기록 */
16
+ deployed_files?: string[];
13
17
  }
14
18
  /** 키는 scoped slug 포맷: "@owner/name" */
15
19
  export interface InstalledRegistry {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relayax-cli",
3
- "version": "0.2.23",
3
+ "version": "0.2.25",
4
4
  "description": "RelayAX Agent Team Marketplace CLI - Install and manage agent teams",
5
5
  "main": "dist/index.js",
6
6
  "bin": {