relayax-cli 0.2.35 → 0.2.36

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.
@@ -72,6 +72,7 @@ function registerCreate(program) {
72
72
  slug: defaultSlug,
73
73
  description,
74
74
  version: '1.0.0',
75
+ type: 'hybrid',
75
76
  tags,
76
77
  visibility,
77
78
  };
@@ -32,7 +32,6 @@ function registerInstall(program) {
32
32
  program
33
33
  .command('install <slug>')
34
34
  .description('에이전트 팀 패키지를 .relay/teams/에 다운로드합니다')
35
- .option('--no-guide', 'GUIDE.html 브라우저 자동 오픈을 비활성화합니다')
36
35
  .option('--join-code <code>', 'Space 초대 코드 (Space 팀 설치 시 자동 가입)')
37
36
  .action(async (slugInput, opts) => {
38
37
  const json = program.opts().json ?? false;
@@ -209,7 +208,6 @@ function registerInstall(program) {
209
208
  contact_links: team.author.contact_links ?? [],
210
209
  } : null,
211
210
  welcome: team.welcome ?? null,
212
- latest_post: team.latest_post ?? null,
213
211
  };
214
212
  if (json) {
215
213
  console.log(JSON.stringify(result));
@@ -242,29 +240,18 @@ function registerInstall(program) {
242
240
  if (authorUsername) {
243
241
  console.log(` \x1b[90m│\x1b[0m 👤 relayax.com/@${authorUsername}`);
244
242
  }
245
- if (team.latest_post && authorUsername) {
246
- console.log(` \x1b[90m│\x1b[0m 📝 relayax.com/@${authorUsername}/posts/${team.latest_post.slug}`);
247
- }
248
243
  console.log(` \x1b[90m└${'─'.repeat(44)}┘\x1b[0m`);
249
244
  }
250
- // Open GUIDE.html in browser if present
251
- const guideHtmlPath = path_1.default.join(teamDir, 'GUIDE.html');
252
- if (opts.guide !== false && fs_1.default.existsSync(guideHtmlPath)) {
253
- const isTTY = Boolean(process.stdin.isTTY);
254
- if (isTTY) {
255
- const { exec } = await import('child_process');
256
- const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start ""' : 'xdg-open';
257
- exec(`${openCmd} "${guideHtmlPath}"`);
258
- console.log(`\n 📖 사용가이드를 브라우저에서 열었습니다`);
259
- }
260
- else {
261
- console.log(`\n 📖 사용가이드: ${guideHtmlPath}`);
262
- }
245
+ // Usage hint (type-aware)
246
+ const teamType = team.type;
247
+ if (teamType === 'passive') {
248
+ console.log(`\n\x1b[33m💡 자동 적용됩니다. 별도 실행 없이 동작합니다.\x1b[0m`);
249
+ }
250
+ else if (teamType === 'hybrid' && team.commands && team.commands.length > 0) {
251
+ console.log(`\n\x1b[33m💡 자동 적용 + \x1b[1m/${team.commands[0].name}\x1b[0m\x1b[33m 으로 추가 기능을 사용할 있습니다.\x1b[0m`);
263
252
  }
264
- // Usage hint
265
- if (team.commands && team.commands.length > 0) {
266
- console.log(`\n\x1b[33m💡 시작하려면 채팅에 아래 명령어를 입력하세요:\x1b[0m`);
267
- console.log(` \x1b[1m/${team.commands[0].name}\x1b[0m`);
253
+ else if (team.commands && team.commands.length > 0) {
254
+ console.log(`\n\x1b[33m💡 사용법: \x1b[1m/${team.commands[0].name}\x1b[0m`);
268
255
  }
269
256
  else {
270
257
  console.log(`\n\x1b[33m💡 설치 완료! AI 에이전트에서 사용할 수 있습니다.\x1b[0m`);
@@ -1,7 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  /**
3
3
  * 대화형 로그인 플로우 실행 (auto-login에서 호출).
4
- * 로그인 성공 토큰 저장, 실패 시 throw.
4
+ * 브라우저에서 로그인 페이지를 열고 토큰을 받아 저장.
5
5
  */
6
6
  export declare function runLogin(): Promise<void>;
7
7
  export declare function registerLogin(program: Command): void;
@@ -6,7 +6,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.runLogin = runLogin;
7
7
  exports.registerLogin = registerLogin;
8
8
  const http_1 = __importDefault(require("http"));
9
- const readline_1 = __importDefault(require("readline"));
10
9
  const child_process_1 = require("child_process");
11
10
  const config_js_1 = require("../lib/config.js");
12
11
  function openBrowser(url) {
@@ -104,120 +103,22 @@ function findAvailablePort() {
104
103
  });
105
104
  });
106
105
  }
107
- function promptLine(rl, question) {
108
- return new Promise((resolve) => rl.question(question, resolve));
109
- }
110
- function promptPassword(question) {
111
- return new Promise((resolve) => {
112
- const rl = readline_1.default.createInterface({ input: process.stdin, output: process.stdout });
113
- process.stdout.write(question);
114
- // Hide input
115
- const stdin = process.stdin;
116
- if (stdin.isTTY)
117
- stdin.setRawMode(true);
118
- let input = '';
119
- process.stdin.resume();
120
- process.stdin.setEncoding('utf8');
121
- const onData = (ch) => {
122
- if (ch === '\n' || ch === '\r' || ch === '\u0004') {
123
- if (stdin.isTTY)
124
- stdin.setRawMode(false);
125
- process.stdout.write('\n');
126
- process.stdin.removeListener('data', onData);
127
- rl.close();
128
- resolve(input);
129
- }
130
- else if (ch === '\u0003') {
131
- process.exit(1);
132
- }
133
- else if (ch === '\u007f') {
134
- input = input.slice(0, -1);
135
- }
136
- else {
137
- input += ch;
138
- }
139
- };
140
- process.stdin.on('data', onData);
141
- });
142
- }
143
- async function loginWithBrowser(provider, json) {
106
+ async function loginWithBrowser(json) {
144
107
  const port = await findAvailablePort();
145
- const loginUrl = `${config_js_1.API_URL}/auth/cli-login?port=${port}&provider=${provider}`;
108
+ const loginUrl = `${config_js_1.API_URL}/auth/cli-login?port=${port}`;
146
109
  if (!json) {
147
- const providerName = provider === 'github' ? 'GitHub' : '카카오';
148
- console.error(`브라우저에서 ${providerName} 로그인을 진행합니다...`);
110
+ console.error(`브라우저에서 로그인 페이지를 엽니다...`);
149
111
  }
150
112
  openBrowser(loginUrl);
151
113
  return waitForToken(port);
152
114
  }
153
- async function loginWithEmail(json) {
154
- const rl = readline_1.default.createInterface({ input: process.stdin, output: process.stdout });
155
- if (!json) {
156
- console.error('이메일로 로그인합니다.');
157
- }
158
- let email;
159
- let password;
160
- try {
161
- email = (await promptLine(rl, '이메일: ')).trim();
162
- rl.close();
163
- password = await promptPassword('비밀번호: ');
164
- }
165
- catch {
166
- rl.close();
167
- throw new Error('입력이 취소되었습니다');
168
- }
169
- const res = await fetch(`${config_js_1.API_URL}/api/auth/email-login`, {
170
- method: 'POST',
171
- headers: { 'Content-Type': 'application/json' },
172
- body: JSON.stringify({ email, password }),
173
- });
174
- if (!res.ok) {
175
- const body = (await res.json().catch(() => ({})));
176
- const msg = body.error ?? '이메일 또는 비밀번호가 올바르지 않습니다.';
177
- throw new Error(msg);
178
- }
179
- const data = (await res.json());
180
- return {
181
- token: data.access_token,
182
- refresh_token: data.refresh_token,
183
- expires_at: data.expires_at,
184
- };
185
- }
186
- async function selectProvider(json) {
187
- if (json)
188
- return 'github';
189
- const rl = readline_1.default.createInterface({ input: process.stdin, output: process.stdout });
190
- console.error('');
191
- console.error('로그인 방법을 선택하세요:');
192
- console.error(' 1) GitHub');
193
- console.error(' 2) 카카오');
194
- console.error(' 3) 이메일 / 비밀번호');
195
- console.error('');
196
- const answer = (await promptLine(rl, '선택 (기본값: 1): ')).trim();
197
- rl.close();
198
- if (answer === '2')
199
- return 'kakao';
200
- if (answer === '3')
201
- return 'email';
202
- return 'github';
203
- }
204
115
  /**
205
116
  * 대화형 로그인 플로우 실행 (auto-login에서 호출).
206
- * 로그인 성공 토큰 저장, 실패 시 throw.
117
+ * 브라우저에서 로그인 페이지를 열고 토큰을 받아 저장.
207
118
  */
208
119
  async function runLogin() {
209
120
  (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
- }
121
+ const loginResult = await loginWithBrowser(false);
221
122
  await verifyToken(loginResult.token);
222
123
  (0, config_js_1.saveTokenData)({
223
124
  access_token: loginResult.token,
@@ -231,7 +132,6 @@ function registerLogin(program) {
231
132
  .command('login')
232
133
  .description('RelayAX 계정에 로그인합니다')
233
134
  .option('--token <token>', '직접 토큰 입력 (브라우저 없이)')
234
- .option('--provider <provider>', '로그인 제공자 (github | kakao | email)')
235
135
  .action(async (opts) => {
236
136
  const json = program.opts().json ?? false;
237
137
  (0, config_js_1.ensureGlobalRelayDir)();
@@ -240,20 +140,7 @@ function registerLogin(program) {
240
140
  let expiresAt;
241
141
  if (!accessToken) {
242
142
  try {
243
- let provider = opts.provider;
244
- if (!provider) {
245
- provider = await selectProvider(json);
246
- }
247
- let loginResult;
248
- if (provider === 'email') {
249
- loginResult = await loginWithEmail(json);
250
- }
251
- else if (provider === 'kakao') {
252
- loginResult = await loginWithBrowser('kakao', json);
253
- }
254
- else {
255
- loginResult = await loginWithBrowser('github', json);
256
- }
143
+ const loginResult = await loginWithBrowser(json);
257
144
  accessToken = loginResult.token;
258
145
  refreshToken = loginResult.refresh_token;
259
146
  expiresAt = loginResult.expires_at;
@@ -16,44 +16,21 @@ const version_check_js_1 = require("../lib/version-check.js");
16
16
  // eslint-disable-next-line @typescript-eslint/no-var-requires
17
17
  const cliPkg = require('../../package.json');
18
18
  const VALID_DIRS = ['skills', 'agents', 'rules', 'commands', 'bin'];
19
- const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.webp'];
20
- /** 개별 포트폴리오 이미지 최대 크기 (2 MB) */
21
- const MAX_IMAGE_SIZE = 2 * 1024 * 1024;
22
- /** 전체 업로드 최대 크기 (10 MB) */
23
- const MAX_TOTAL_UPLOAD_SIZE = 10 * 1024 * 1024;
24
19
  function parseRelayYaml(content) {
25
20
  const raw = js_yaml_1.default.load(content) ?? {};
26
21
  const tags = Array.isArray(raw.tags)
27
22
  ? raw.tags.map((t) => String(t))
28
23
  : [];
29
- // Parse portfolio slots structure
30
- const rawPortfolio = raw.portfolio;
31
- const portfolio = {};
32
- if (rawPortfolio && typeof rawPortfolio === 'object' && !Array.isArray(rawPortfolio)) {
33
- // Slot-based format: { demo: {...}, gallery: [...] }
34
- if (rawPortfolio.demo && typeof rawPortfolio.demo === 'object') {
35
- const d = rawPortfolio.demo;
36
- portfolio.demo = {
37
- type: String(d.type ?? 'gif'),
38
- path: d.path ? String(d.path) : undefined,
39
- url: d.url ? String(d.url) : undefined,
40
- };
41
- }
42
- if (Array.isArray(rawPortfolio.gallery)) {
43
- portfolio.gallery = rawPortfolio.gallery
44
- .map((g) => ({
45
- path: String(g.path ?? ''),
46
- title: String(g.title ?? ''),
47
- description: g.description ? String(g.description) : undefined,
48
- }))
49
- .filter((g) => g.path);
50
- }
51
- }
52
24
  const requires = raw.requires;
53
25
  const rawVisibility = String(raw.visibility ?? '');
54
26
  const visibility = rawVisibility === 'private' ? 'private'
55
27
  : rawVisibility === 'public' ? 'public'
56
28
  : undefined;
29
+ const rawType = String(raw.type ?? '');
30
+ const type = rawType === 'command' ? 'command'
31
+ : rawType === 'passive' ? 'passive'
32
+ : rawType === 'hybrid' ? 'hybrid'
33
+ : undefined;
57
34
  return {
58
35
  name: String(raw.name ?? ''),
59
36
  slug: String(raw.slug ?? ''),
@@ -62,9 +39,9 @@ function parseRelayYaml(content) {
62
39
  changelog: raw.changelog ? String(raw.changelog) : undefined,
63
40
  long_description: raw.long_description ? String(raw.long_description) : undefined,
64
41
  tags,
65
- portfolio,
66
42
  requires,
67
43
  visibility,
44
+ type,
68
45
  };
69
46
  }
70
47
  function detectCommands(teamDir) {
@@ -111,17 +88,80 @@ function detectSkills(teamDir) {
111
88
  if (!fs_1.default.existsSync(skillMd))
112
89
  continue;
113
90
  let description = entry.name;
91
+ const uses = [];
114
92
  try {
115
93
  const content = fs_1.default.readFileSync(skillMd, 'utf-8');
116
94
  const m = content.match(/^---\n[\s\S]*?description:\s*[|>]?\s*\n?\s*(.+)\n[\s\S]*?---/m)
117
95
  ?? content.match(/^---\n[\s\S]*?description:\s*(.+)\n[\s\S]*?---/m);
118
96
  if (m)
119
97
  description = m[1].trim();
98
+ // Extract allowed-tools from frontmatter
99
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
100
+ if (frontmatterMatch) {
101
+ const fm = js_yaml_1.default.load(frontmatterMatch[1]);
102
+ if (fm && Array.isArray(fm['allowed-tools'])) {
103
+ for (const tool of fm['allowed-tools']) {
104
+ const t = String(tool).trim();
105
+ if (t)
106
+ uses.push(t);
107
+ }
108
+ }
109
+ }
120
110
  }
121
111
  catch {
122
112
  // ignore
123
113
  }
124
- entries.push({ name: entry.name, description });
114
+ entries.push({ name: entry.name, description, uses });
115
+ }
116
+ return entries;
117
+ }
118
+ const MCP_KEYWORDS = ['mcp', 'supabase', 'github', 'slack', 'notion', 'linear', 'jira', 'figma', 'stripe', 'openai', 'anthropic', 'postgres', 'mysql', 'redis', 'mongodb', 'firebase', 'aws', 'gcp', 'azure', 'vercel', 'netlify', 'docker', 'kubernetes'];
119
+ function detectAgentDetails(teamDir, requires) {
120
+ const agentsDir = path_1.default.join(teamDir, 'agents');
121
+ if (!fs_1.default.existsSync(agentsDir))
122
+ return [];
123
+ const mcpNames = new Set((requires?.mcp ?? []).map((m) => m.name.toLowerCase()));
124
+ const envNames = new Set((requires?.env ?? []).map((e) => e.name.toLowerCase()));
125
+ const entries = [];
126
+ const files = fs_1.default.readdirSync(agentsDir).filter((f) => f.endsWith('.md'));
127
+ for (const file of files) {
128
+ const name = path_1.default.basename(file, '.md');
129
+ let description = name;
130
+ const uses = [];
131
+ try {
132
+ const content = fs_1.default.readFileSync(path_1.default.join(agentsDir, file), 'utf-8');
133
+ // Parse frontmatter
134
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
135
+ if (frontmatterMatch) {
136
+ const fm = js_yaml_1.default.load(frontmatterMatch[1]);
137
+ if (fm) {
138
+ if (typeof fm.description === 'string')
139
+ description = fm.description.trim();
140
+ }
141
+ }
142
+ // Scan body for MCP-related keywords
143
+ const bodyLower = content.toLowerCase();
144
+ for (const keyword of MCP_KEYWORDS) {
145
+ if (bodyLower.includes(keyword) && !uses.includes(keyword)) {
146
+ uses.push(keyword);
147
+ }
148
+ }
149
+ // Cross-reference with requires.mcp and requires.env
150
+ for (const mcp of mcpNames) {
151
+ if (bodyLower.includes(mcp) && !uses.includes(mcp)) {
152
+ uses.push(mcp);
153
+ }
154
+ }
155
+ for (const env of envNames) {
156
+ if (bodyLower.includes(env) && !uses.includes(env)) {
157
+ uses.push(env);
158
+ }
159
+ }
160
+ }
161
+ catch {
162
+ // ignore read errors
163
+ }
164
+ entries.push({ name, description, uses });
125
165
  }
126
166
  return entries;
127
167
  }
@@ -180,53 +220,6 @@ function listDir(teamDir, dirName) {
180
220
  return [];
181
221
  return fs_1.default.readdirSync(dirPath).filter((f) => !f.startsWith('.'));
182
222
  }
183
- /**
184
- * 슬롯 기반 포트폴리오를 PortfolioEntry[] 로 평탄화한다.
185
- * relay.yaml에 슬롯이 정의되어 있으면 사용, 없으면 portfolio/ 자동 스캔.
186
- */
187
- function collectPortfolio(relayDir, slots) {
188
- const entries = [];
189
- // Demo
190
- if (slots.demo) {
191
- if (slots.demo.type === 'video_url' && slots.demo.url) {
192
- entries.push({ path: '', title: 'Demo', slot_type: 'demo', demo_url: slots.demo.url });
193
- }
194
- else if (slots.demo.path) {
195
- const absPath = path_1.default.resolve(relayDir, slots.demo.path);
196
- if (fs_1.default.existsSync(absPath)) {
197
- entries.push({ path: slots.demo.path, title: 'Demo', slot_type: 'demo' });
198
- }
199
- }
200
- }
201
- // Gallery
202
- if (slots.gallery && slots.gallery.length > 0) {
203
- for (const g of slots.gallery.slice(0, 5)) {
204
- const absPath = path_1.default.resolve(relayDir, g.path);
205
- if (fs_1.default.existsSync(absPath)) {
206
- entries.push({ path: g.path, title: g.title, description: g.description, slot_type: 'gallery' });
207
- }
208
- }
209
- }
210
- // If no slots defined, auto-scan portfolio/ directory
211
- if (entries.length === 0) {
212
- const portfolioDir = path_1.default.join(relayDir, 'portfolio');
213
- if (fs_1.default.existsSync(portfolioDir)) {
214
- const files = fs_1.default.readdirSync(portfolioDir)
215
- .filter((f) => IMAGE_EXTS.some((ext) => f.toLowerCase().endsWith(ext)))
216
- .sort();
217
- // All images as gallery
218
- for (let i = 0; i < files.length && i < 6; i++) {
219
- const f = files[i];
220
- entries.push({
221
- path: path_1.default.join('portfolio', f),
222
- title: path_1.default.basename(f, path_1.default.extname(f)).replace(/[-_]/g, ' '),
223
- slot_type: 'gallery',
224
- });
225
- }
226
- }
227
- }
228
- return entries;
229
- }
230
223
  /**
231
224
  * long_description을 결정한다.
232
225
  * 1. relay.yaml에 있으면 사용
@@ -251,7 +244,7 @@ async function createTarball(teamDir) {
251
244
  const dirsToInclude = VALID_DIRS.filter((d) => fs_1.default.existsSync(path_1.default.join(teamDir, d)));
252
245
  // Include root-level files if they exist
253
246
  const entries = [...dirsToInclude];
254
- const rootFiles = ['relay.yaml', 'GUIDE.html', 'SKILL.md'];
247
+ const rootFiles = ['relay.yaml', 'SKILL.md'];
255
248
  for (const file of rootFiles) {
256
249
  if (fs_1.default.existsSync(path_1.default.join(teamDir, file))) {
257
250
  entries.push(file);
@@ -264,63 +257,12 @@ async function createTarball(teamDir) {
264
257
  }, entries);
265
258
  return tmpFile;
266
259
  }
267
- async function publishToApi(token, tarPath, metadata, teamDir, portfolioEntries) {
260
+ async function publishToApi(token, tarPath, metadata) {
268
261
  const fileBuffer = fs_1.default.readFileSync(tarPath);
269
262
  const blob = new Blob([fileBuffer], { type: 'application/gzip' });
270
263
  const form = new FormData();
271
264
  form.append('package', blob, `${metadata.slug}-${metadata.version}.tar.gz`);
272
265
  form.append('metadata', JSON.stringify(metadata));
273
- // Attach portfolio images (with size validation and slot_type)
274
- if (portfolioEntries.length > 0) {
275
- const portfolioMeta = [];
276
- let totalImageSize = 0;
277
- let fileIndex = 0;
278
- for (let i = 0; i < portfolioEntries.length; i++) {
279
- const entry = portfolioEntries[i];
280
- // video_url demo has no file to upload
281
- if (entry.slot_type === 'demo' && entry.demo_url && !entry.path) {
282
- portfolioMeta.push({
283
- title: entry.title,
284
- description: entry.description,
285
- sort_order: i,
286
- slot_type: entry.slot_type,
287
- demo_url: entry.demo_url,
288
- });
289
- continue;
290
- }
291
- const absPath = path_1.default.resolve(teamDir, entry.path);
292
- const imgBuffer = fs_1.default.readFileSync(absPath);
293
- if (imgBuffer.length > MAX_IMAGE_SIZE) {
294
- const sizeMB = (imgBuffer.length / 1024 / 1024).toFixed(1);
295
- throw new Error(`포트폴리오 이미지 '${path_1.default.basename(entry.path)}'이(가) 너무 큽니다 (${sizeMB}MB). 최대 ${MAX_IMAGE_SIZE / 1024 / 1024}MB까지 허용됩니다.`);
296
- }
297
- totalImageSize += imgBuffer.length;
298
- if (totalImageSize > MAX_TOTAL_UPLOAD_SIZE) {
299
- const totalMB = (totalImageSize / 1024 / 1024).toFixed(1);
300
- throw new Error(`포트폴리오 이미지 총 크기가 너무 큽니다 (${totalMB}MB). 최대 ${MAX_TOTAL_UPLOAD_SIZE / 1024 / 1024}MB까지 허용됩니다.`);
301
- }
302
- const ext = path_1.default.extname(entry.path).slice(1) || 'png';
303
- const mimeType = ext === 'gif' ? 'image/gif' : ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'webp' ? 'image/webp' : 'image/png';
304
- const imgBlob = new Blob([imgBuffer], { type: mimeType });
305
- form.append(`portfolio[${fileIndex}]`, imgBlob, path_1.default.basename(entry.path));
306
- fileIndex++;
307
- portfolioMeta.push({
308
- title: entry.title,
309
- description: entry.description,
310
- sort_order: i,
311
- slot_type: entry.slot_type,
312
- demo_url: entry.demo_url,
313
- });
314
- }
315
- form.append('portfolio_meta', JSON.stringify(portfolioMeta));
316
- }
317
- // Attach GUIDE.html if it exists
318
- const guidePath = path_1.default.join(teamDir, 'GUIDE.html');
319
- if (fs_1.default.existsSync(guidePath)) {
320
- const guideBuffer = fs_1.default.readFileSync(guidePath);
321
- const guideBlob = new Blob([guideBuffer], { type: 'text/html' });
322
- form.append('guide', guideBlob, 'GUIDE.html');
323
- }
324
266
  const res = await fetch(`${config_js_1.API_URL}/api/publish`, {
325
267
  method: 'POST',
326
268
  headers: { Authorization: `Bearer ${token}` },
@@ -562,9 +504,9 @@ function registerPublish(program) {
562
504
  rules: countDir(relayDir, 'rules'),
563
505
  skills: countDir(relayDir, 'skills'),
564
506
  };
565
- // Collect portfolio and long_description
566
- const portfolioEntries = collectPortfolio(relayDir, config.portfolio);
567
507
  const longDescription = resolveLongDescription(relayDir, config.long_description);
508
+ const detectedSkills = detectSkills(relayDir);
509
+ const detectedAgents = detectAgentDetails(relayDir, config.requires);
568
510
  const metadata = {
569
511
  slug: config.slug,
570
512
  name: config.name,
@@ -580,17 +522,21 @@ function registerPublish(program) {
580
522
  cli_version: cliPkg.version,
581
523
  agent_names: listDir(relayDir, 'agents'),
582
524
  skill_names: listDir(relayDir, 'skills'),
525
+ type: config.type ?? 'hybrid',
526
+ agent_details: detectedAgents,
527
+ skill_details: detectedSkills,
583
528
  };
584
529
  if (!json) {
585
530
  console.error(`패키지 생성 중... (${config.name} v${config.version})`);
586
- if (portfolioEntries.length > 0) {
587
- console.error(`포트폴리오 이미지: ${portfolioEntries.length}개`);
588
- }
531
+ }
532
+ // GUIDE.html deprecation warning
533
+ if (fs_1.default.existsSync(path_1.default.join(relayDir, 'GUIDE.html'))) {
534
+ console.error('\x1b[33m⚠ GUIDE.html은 더 이상 지원되지 않습니다. 상세페이지가 가이드 역할을 합니다.\x1b[0m');
535
+ console.error(' long_description을 활용하거나 relayax.com에서 팀 정보를 편집하세요.\n');
589
536
  }
590
537
  // Generate bin/relay-preamble.sh (self-contained tracking + update check)
591
538
  (0, preamble_js_1.generatePreambleBin)(relayDir, config.slug, config_js_1.API_URL);
592
539
  // Generate entry command (commands/{author}-{name}.md)
593
- const detectedSkills = detectSkills(relayDir);
594
540
  const entryContent = generateEntryCommand(config, detectedCommands, detectedSkills, config.slug);
595
541
  const commandsDir = path_1.default.join(relayDir, 'commands');
596
542
  if (!fs_1.default.existsSync(commandsDir)) {
@@ -606,7 +552,7 @@ function registerPublish(program) {
606
552
  if (!json) {
607
553
  console.error(`업로드 중...`);
608
554
  }
609
- const result = await publishToApi(token, tarPath, metadata, relayDir, portfolioEntries);
555
+ const result = await publishToApi(token, tarPath, metadata);
610
556
  // Update entry command preamble with scoped slug from server (non-fatal)
611
557
  try {
612
558
  if (result.slug && result.slug !== config.slug) {
@@ -628,9 +574,6 @@ function registerPublish(program) {
628
574
  console.log(`\n\x1b[32m✓ ${config.name} 배포 완료\x1b[0m v${result.version}`);
629
575
  console.log(` 슬러그: \x1b[36m${result.slug}\x1b[0m`);
630
576
  console.log(` URL: \x1b[36m${result.url}\x1b[0m`);
631
- if (result.portfolio_count && result.portfolio_count > 0) {
632
- console.log(` 포트폴리오: ${result.portfolio_count}개 이미지 업로드됨`);
633
- }
634
577
  // Show business card preview
635
578
  const profile = result.profile;
636
579
  if (profile) {
@@ -659,11 +602,22 @@ function registerPublish(program) {
659
602
  'relay login',
660
603
  `relay install ${result.slug}`,
661
604
  ];
662
- console.log(`\n \x1b[90m공유용 온보딩 가이드:\x1b[0m\n`);
605
+ // Type-based usage hint
606
+ const teamType = config.type ?? 'hybrid';
607
+ if (teamType === 'passive') {
608
+ guideLines.push('', '# 설치하면 자동 적용됩니다');
609
+ }
610
+ else if (detectedCommands.length > 0) {
611
+ const usageCmd = teamType === 'hybrid'
612
+ ? `# 자동 적용 + /${detectedCommands[0].name} 으로 추가 기능`
613
+ : `# 사용법: /${detectedCommands[0].name}`;
614
+ guideLines.push('', usageCmd);
615
+ }
616
+ console.log(`\n \x1b[90m주변인에게 공유하세요:\x1b[0m\n`);
663
617
  console.log('```');
664
618
  guideLines.forEach((line) => console.log(line));
665
619
  console.log('```');
666
- console.log(`\n \x1b[90m 블록을 복사하여 팀원에게 공유하세요\x1b[0m`);
620
+ console.log(`\n \x1b[90m상세페이지: \x1b[36mrelayax.com/teams/${result.slug}\x1b[0m`);
667
621
  }
668
622
  }
669
623
  }
@@ -61,9 +61,9 @@ function formatCommandFile(content) {
61
61
  const LOGIN_JIT_GUIDE = `
62
62
  ### 인증 오류 처리
63
63
  - 커맨드 실행 결과에 \`LOGIN_REQUIRED\` 에러가 포함되면:
64
- 1. 사용자에게 "이 팀은 로그인이 필요합니다"라고 안내합니다.
65
- 2. \`relay login\` 명령어를 실행합니다.
66
- 3. 로그인 완료 후 원래 커맨드를 재실행합니다.`;
64
+ 1. \`relay login\` 실행 (timeout 300초)
65
+ - 브라우저가 자동으로 열리고, 사용자가 로그인을 완료하면 토큰이 자동 저장됩니다.
66
+ 2. 완료 후 \`relay status --json\`으로 로그인 성공을 확인하고, 원래 커맨드를 재실행합니다.`;
67
67
  // ─── 명함 표시 포맷 ───
68
68
  const BUSINESS_CARD_FORMAT = `
69
69
  ### 빌더 명함 표시
@@ -301,10 +301,27 @@ ${BUSINESS_CARD_FORMAT}
301
301
  - "팔로우" → \`relay follow @{username}\` 실행. 로그인이 안 되어 있으면 \`relay login\` 먼저 실행 후 재시도.
302
302
  - "건너뛰기" → 다음 단계로 진행
303
303
 
304
- #### 3-3. 사용 제안
304
+ #### 3-3. 공유 가이드 (필수 — 설치 완료 시 반드시 표시)
305
+ 설치 완료 후 아래 공유용 설치 가이드를 표시합니다. 복사 가능한 코드 블록으로 보여줍니다.
306
+
307
+ \`\`\`
308
+ 주변인에게 공유하세요:
309
+
310
+ npm install -g relayax-cli
311
+ relay login
312
+ relay install {slug}
313
+
314
+ 상세페이지: relayax.com/teams/{slug}
315
+ \`\`\`
316
+
317
+ - type 기반 사용법도 포함:
318
+ - command/hybrid: \`# 사용법: /{첫번째 커맨드}\`
319
+ - passive: \`# 설치하면 자동 적용됩니다\`
320
+
321
+ #### 3-4. 사용 제안
305
322
  - "바로 사용해볼까요?" 제안
306
323
 
307
- #### 3-4. 업데이트 확인
324
+ #### 3-5. 업데이트 확인
308
325
  - \`relay check-update\` 명령어를 실행합니다.
309
326
  - CLI 업데이트가 있으면 안내합니다: "relay v{new} available. Run: npm update -g relayax-cli"
310
327
  - 다른 팀 업데이트가 있으면 안내합니다.
@@ -447,29 +464,59 @@ ${LOGIN_JIT_GUIDE}
447
464
  },
448
465
  {
449
466
  id: 'relay-publish',
450
- description: '현재 팀 패키지를 포트폴리오와 함께 relay 마켓플레이스에 배포합니다',
451
- body: `현재 디렉토리의 에이전트 팀(.relay/)을 분석하고, 보안 점검 및 requirements를 구성한 뒤, 사용가이드와 포트폴리오를 생성하고 relay 마켓플레이스에 배포합니다.
467
+ description: '현재 팀 패키지를 relay 마켓플레이스에 배포합니다',
468
+ body: `현재 디렉토리의 에이전트 팀(.relay/)을 분석하고, 보안 점검 및 requirements를 구성한 뒤, 사용가이드를 생성하고 relay 마켓플레이스에 배포합니다.
452
469
 
453
- ## 사전 확인
470
+ ## 사전 준비 (자동)
454
471
 
455
- 커맨드는 빌더 프로젝트(.relay/ 디렉토리)에서만 실행할 수 있습니다.
456
- .relay/ 디렉토리가 없으면: "이 프로젝트에는 .relay/ 디렉토리가 없습니다. \`relay create <name>\`으로 팀을 먼저 만들어주세요." 안내 후 종료합니다.
472
+ ### 0-1. 프로젝트 확인 & 초기화
457
473
 
458
- ## 사전 준비 (자동)
474
+ .relay/relay.yaml이 있는지 확인합니다.
475
+
476
+ **있으면** → 바로 0-2로 진행합니다.
477
+
478
+ **없으면** → 팀 프로젝트를 인라인으로 초기화합니다:
479
+
480
+ 1. "이 프로젝트를 relay 팀으로 배포하겠습니다. 먼저 팀 정보를 입력해주세요." 안내
481
+
482
+ 2. **AskUserQuestion 호출:**
483
+ - question: "팀 이름을 입력해주세요"
484
+ - 기본값으로 현재 디렉토리명을 제안합니다.
485
+
486
+ 3. **AskUserQuestion 호출:**
487
+ - question: "팀을 한 줄로 설명해주세요 (마켓플레이스에 표시됩니다)"
488
+ - 기본값으로 적절한 값을 만들어줍니다.
489
+
490
+ 4. 자동 처리:
491
+ - \`mkdir -p .relay/skills .relay/commands .relay/agents .relay/rules\` 실행
492
+ - \`.relay/relay.yaml\` 생성:
493
+ \`\`\`yaml
494
+ name: <입력된 이름>
495
+ slug: <이름에서 자동 생성 — 소문자, 특수문자→하이픈>
496
+ description: <입력된 설명>
497
+ version: 1.0.0
498
+ tags: []
499
+ \`\`\`
500
+ - \`relay init --update\` 실행하여 글로벌 커맨드 설치 보장
501
+
502
+ 5. "✓ 팀 프로젝트가 초기화되었습니다. 배포를 계속 진행합니다." 안내
503
+
504
+ ### 0-2. 인증 확인
459
505
 
460
- ### 0-1. 인증 확인
461
506
  - \`relay status --json\` 명령어를 실행하여 로그인 상태를 확인합니다.
462
- - 미인증이면 즉시 \`relay login\`을 실행합니다.
463
- ${LOGIN_JIT_GUIDE}
507
+ - 인증되어 있으면 다음 단계로 진행합니다.
508
+ - 미인증이면 바로 로그인을 진행합니다:
509
+ 1. \`relay login\` 실행 (timeout 300초)
510
+ - 브라우저가 자동으로 열리고, 사용자가 로그인을 완료하면 토큰이 자동 저장됩니다.
511
+ 2. 완료 후 \`relay status --json\`으로 로그인 성공을 확인합니다.
464
512
 
465
- ### 0-2. 팀 구조 분석
513
+ ### 0-3. 팀 구조 분석
466
514
  - .relay/ 디렉토리의 skills/, agents/, rules/, commands/를 탐색합니다.
467
515
  - 각 파일의 이름과 description을 추출합니다.
468
- - .relay/relay.yaml이 있으면 읽고, 없으면 사용자에게 팀 정보(name, slug, description, tags)를 물어보고 생성합니다.
469
516
 
470
517
  ## 인터랙션 플로우
471
518
 
472
- 이 커맨드는 6단계 인터랙션으로 진행됩니다. 각 단계에서 반드시 AskUserQuestion 도구를 사용하세요.
519
+ 이 커맨드는 5단계 인터랙션으로 진행됩니다. 각 단계에서 반드시 AskUserQuestion 도구를 사용하세요.
473
520
 
474
521
  ### Step 0. 버전 범프
475
522
 
@@ -590,85 +637,14 @@ requires:
590
637
  - @alice/doc-writer
591
638
  \`\`\`
592
639
 
593
- ### Step 3. 포트폴리오 선택
594
-
595
- output/, results/, examples/, portfolio/ 디렉토리를 스캔하여 결과물(PNG, JPG, HTML, PDF)을 찾습니다.
596
- HTML 파일은 Playwright 스크린샷으로 변환합니다.
597
-
598
- 발견된 파일을 번호 리스트로 보여줍니다:
599
-
600
- \`\`\`
601
- 포트폴리오 후보 (4개)
602
-
603
- 1. output/report-example.png — 리포트 예시
604
- 2. output/dashboard.html → 스크린샷 변환
605
- 3. examples/result-1.png — 결과물 1
606
- 4. examples/result-2.png — 결과물 2
607
- \`\`\`
608
-
609
- **AskUserQuestion 호출:**
610
- - question: "어떤 결과물을 포트폴리오에 포함할까요? (최대 5개)"
611
- - options: \`["전체 포함", "1", "2", "3", "4", "건너뛰기"]\`
612
-
613
- **응답 처리:**
614
- - "전체 포함" → 모든 파일을 .relay/portfolio/에 저장
615
- - 번호 선택 → 해당 파일만 포함 (여러 번 선택 가능 — 한 번 선택 후 추가 선택 여부를 다시 물어봄)
616
- - "건너뛰기" → 포트폴리오 없이 진행
617
-
618
- 선택된 이미지를 .relay/portfolio/에 저장하고 relay.yaml의 portfolio.gallery에 등록합니다.
619
-
620
- \`\`\`yaml
621
- portfolio:
622
- demo: # 선택
623
- type: gif
624
- path: portfolio/demo.gif
625
- gallery: # 선택, 최대 5장
626
- - path: portfolio/guide-preview.png
627
- title: "사용가이드 미리보기"
628
- - path: portfolio/report-example.png
629
- title: "리포트 예시"
630
- \`\`\`
631
-
632
- 파일이 하나도 발견되지 않으면 이 단계를 건너뜁니다.
633
-
634
- ### Step 4. GUIDE.html 컨펌
635
-
636
- 설치자가 읽을 수 있는 사용가이드를 HTML로 생성합니다.
637
-
638
- #### 4-1. 팀 소스 분석 (자동)
639
- - skills/, agents/, commands/ 디렉토리의 **모든 파일 내용**을 읽습니다.
640
- - 각 스킬의 SKILL.md, 에이전트 설정, 커맨드 문서를 분석하여 팀의 파이프라인 흐름을 추론합니다.
641
-
642
- #### 4-2. GUIDE.html 생성 (자동)
643
- - 팀의 핵심 기능, 시작 방법, 파이프라인 흐름, Q&A를 포함하는 단일 HTML 가이드를 생성합니다.
644
- - 디자인: 깔끔한 단일 페이지, 시스템 폰트, 최대 1200px 너비, 라이트 테마.
645
- - 파이프라인이 없는 단순한 팀은 시작 방법 + 기능 설명 + Q&A만 포함합니다.
646
-
647
- #### 4-3. 미리보기 + 컨펌
648
- 생성된 GUIDE.html을 브라우저에서 열어 빌더에게 미리보기를 보여줍니다.
649
-
650
- **AskUserQuestion 호출:**
651
- - question: "사용가이드를 확인해주세요. 진행할까요?"
652
- - options: \`["진행", "수정 요청"]\`
653
-
654
- **응답 처리:**
655
- - "진행" → GUIDE.html을 \`.relay/GUIDE.html\`에 저장
656
- - "수정 요청" → 사용자와 텍스트 대화로 수정사항 파악 → GUIDE.html 재생성 → 다시 미리보기 → AskUserQuestion 반복
657
-
658
- #### 4-4. GUIDE.html 스크린샷 → gallery 등록 (자동)
659
- - GUIDE.html을 Playwright로 열어 첫 화면(뷰포트 1200x630)을 스크린샷 캡처합니다.
660
- - 결과 PNG를 \`./portfolio/guide-preview.png\`에 저장합니다.
661
- - relay.yaml의 portfolio gallery 첫 번째 항목으로 자동 등록합니다.
662
- - 이 이미지는 마켓플레이스 카드 및 OG 이미지의 fallback으로 사용됩니다 (demo > gallery 순).
663
-
664
- ### Step 5. 최종 확인 & 배포
640
+ ### Step 3. 최종 확인 & 배포
665
641
 
666
- #### 5-1. 메타데이터 생성 (자동)
642
+ #### 3-1. 메타데이터 생성 (자동)
667
643
  - description: skills 내용 기반으로 자동 생성합니다.
668
644
  - long_description: 팀 소개 마크다운을 자동 생성합니다 (README.md가 있으면 활용).
669
645
  - tags: 팀 특성에 맞는 태그를 추천합니다.
670
646
 
671
- #### 5-2. 배포 요약 + 최종 확인
647
+ #### 3-2. 배포 요약 + 최종 확인
672
648
  배포할 내용을 요약 표시합니다:
673
649
 
674
650
  \`\`\`
@@ -677,7 +653,6 @@ portfolio:
677
653
  팀: my-team v1.0.0
678
654
  공개: 마켓플레이스 (공개)
679
655
  Skills: 3개, Commands: 5개
680
- 포트폴리오: 2장
681
656
  requires: env 2개, cli 1개
682
657
  \`\`\`
683
658
 
@@ -689,7 +664,7 @@ requires: env 2개, cli 1개
689
664
  - "배포" → \`relay publish --json\` 실행 (슬래시 커맨드에서 이미 버전/visibility를 relay.yaml에 저장했으므로 --json으로 인터랙티브 프롬프트 생략)
690
665
  - "취소" → 중단
691
666
 
692
- #### 5-3. 배포 완료 & 온보딩 가이드
667
+ #### 3-3. 배포 완료 & 온보딩 가이드
693
668
  - 배포 결과와 마켓플레이스 URL을 보여줍니다.
694
669
  - \`relay publish\` 출력 끝에 코드블록 형태의 온보딩 가이드가 포함됩니다.
695
670
  - 이 코드블록을 사용자에게 그대로 보여줍니다.
@@ -714,12 +689,6 @@ ${BUSINESS_CARD_FORMAT}
714
689
  → 보안 스캔 ✓ 시크릿 없음 → requires 분석 결과 표시
715
690
  → AskUserQuestion: "requires 설정이 맞나요?" → ["확인", "수정"]
716
691
  → "확인"
717
- → 포트폴리오 후보 스캔 → 리스트 표시
718
- → AskUserQuestion: "어떤 결과물을 포함할까요?" → ["전체 포함", "1", "2", "건너뛰기"]
719
- → "1" 선택
720
- → GUIDE.html 생성 → 브라우저 미리보기
721
- → AskUserQuestion: "사용가이드 진행할까요?" → ["진행", "수정 요청"]
722
- → "진행"
723
692
  → 배포 요약 표시
724
693
  → AskUserQuestion: "이대로 배포할까요?" → ["배포", "취소"]
725
694
  → "배포" → relay publish 실행
package/dist/types.d.ts CHANGED
@@ -34,6 +34,17 @@ export interface TeamRegistryInfo {
34
34
  name: string;
35
35
  description: string;
36
36
  }[];
37
+ type?: 'command' | 'passive' | 'hybrid';
38
+ agent_details?: {
39
+ name: string;
40
+ description: string;
41
+ uses: string[];
42
+ }[];
43
+ skill_details?: {
44
+ name: string;
45
+ description: string;
46
+ uses: string[];
47
+ }[];
37
48
  component_agents: number;
38
49
  component_rules: number;
39
50
  component_skills: number;
@@ -48,10 +59,6 @@ export interface TeamRegistryInfo {
48
59
  display_name: string | null;
49
60
  contact_links: ContactItem[] | Record<string, string>;
50
61
  } | null;
51
- latest_post?: {
52
- title: string;
53
- slug: string;
54
- } | null;
55
62
  }
56
63
  export interface SearchResult {
57
64
  /** scoped slug 포맷: "@owner/name" */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relayax-cli",
3
- "version": "0.2.35",
3
+ "version": "0.2.36",
4
4
  "description": "RelayAX Agent Team Marketplace CLI - Install and manage agent teams",
5
5
  "main": "dist/index.js",
6
6
  "bin": {