relayax-cli 0.1.95 → 0.1.96

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.
@@ -93,87 +93,6 @@ function isTeamProject(projectPath) {
93
93
  return fs_1.default.readdirSync(dirPath).filter((f) => !f.startsWith('.')).length > 0;
94
94
  });
95
95
  }
96
- /** 레거시 User 커맨드 ID (이전에 로컬에 설치되던 것) */
97
- const LEGACY_LOCAL_COMMAND_IDS = ['relay-explore', 'relay-install', 'relay-publish'];
98
- /**
99
- * 레거시 구조를 감지하고 마이그레이션한다.
100
- * - relay.yaml (루트) → .relay/relay.yaml
101
- * - portfolio/ (루트) → .relay/portfolio/
102
- * - 로컬 레거시 슬래시 커맨드 제거 (글로벌로 이동되므로)
103
- */
104
- /** 루트에 있으면 .relay/ 안으로 옮길 디렉토리 */
105
- const LEGACY_CONTENT_DIRS = ['skills', 'agents', 'rules', 'commands', 'portfolio'];
106
- function detectLegacy(projectPath) {
107
- const details = [];
108
- if (fs_1.default.existsSync(path_1.default.join(projectPath, 'relay.yaml'))) {
109
- details.push('relay.yaml → .relay/relay.yaml');
110
- }
111
- for (const dir of LEGACY_CONTENT_DIRS) {
112
- const legacyDir = path_1.default.join(projectPath, dir);
113
- if (fs_1.default.existsSync(legacyDir) && fs_1.default.statSync(legacyDir).isDirectory()) {
114
- const hasFiles = fs_1.default.readdirSync(legacyDir).filter((f) => !f.startsWith('.')).length > 0;
115
- if (hasFiles) {
116
- details.push(`${dir}/ → .relay/${dir}/`);
117
- }
118
- }
119
- }
120
- // 로컬에 레거시 슬래시 커맨드가 있는지 확인
121
- const detected = (0, ai_tools_js_1.detectAgentCLIs)(projectPath);
122
- for (const tool of detected) {
123
- const cmdDir = path_1.default.join(projectPath, tool.skillsDir, 'commands', 'relay');
124
- if (!fs_1.default.existsSync(cmdDir))
125
- continue;
126
- for (const cmdId of LEGACY_LOCAL_COMMAND_IDS) {
127
- if (fs_1.default.existsSync(path_1.default.join(cmdDir, `${cmdId}.md`))) {
128
- details.push(`${tool.skillsDir}/commands/relay/ 레거시 커맨드 정리`);
129
- break;
130
- }
131
- }
132
- }
133
- return { hasLegacy: details.length > 0, details };
134
- }
135
- function runMigration(projectPath) {
136
- const result = { relayYaml: false, portfolio: false, localCommandsCleaned: 0 };
137
- const relayDir = path_1.default.join(projectPath, '.relay');
138
- fs_1.default.mkdirSync(relayDir, { recursive: true });
139
- // 1. relay.yaml 이동
140
- const legacyYaml = path_1.default.join(projectPath, 'relay.yaml');
141
- const newYaml = path_1.default.join(relayDir, 'relay.yaml');
142
- if (fs_1.default.existsSync(legacyYaml) && !fs_1.default.existsSync(newYaml)) {
143
- fs_1.default.renameSync(legacyYaml, newYaml);
144
- result.relayYaml = true;
145
- }
146
- // 2. 콘텐츠 디렉토리 이동 (skills, agents, rules, commands, portfolio)
147
- for (const dir of LEGACY_CONTENT_DIRS) {
148
- const legacyDir = path_1.default.join(projectPath, dir);
149
- const newDir = path_1.default.join(relayDir, dir);
150
- if (fs_1.default.existsSync(legacyDir) && fs_1.default.statSync(legacyDir).isDirectory() && !fs_1.default.existsSync(newDir)) {
151
- fs_1.default.renameSync(legacyDir, newDir);
152
- if (dir === 'portfolio')
153
- result.portfolio = true;
154
- }
155
- }
156
- // 3. 로컬 레거시 슬래시 커맨드 제거
157
- const detected = (0, ai_tools_js_1.detectAgentCLIs)(projectPath);
158
- for (const tool of detected) {
159
- const cmdDir = path_1.default.join(projectPath, tool.skillsDir, 'commands', 'relay');
160
- if (!fs_1.default.existsSync(cmdDir))
161
- continue;
162
- for (const cmdId of LEGACY_LOCAL_COMMAND_IDS) {
163
- const cmdPath = path_1.default.join(cmdDir, `${cmdId}.md`);
164
- if (fs_1.default.existsSync(cmdPath)) {
165
- fs_1.default.unlinkSync(cmdPath);
166
- result.localCommandsCleaned++;
167
- }
168
- }
169
- // 디렉토리가 비었으면 삭제
170
- const remaining = fs_1.default.readdirSync(cmdDir);
171
- if (remaining.length === 0) {
172
- fs_1.default.rmdirSync(cmdDir);
173
- }
174
- }
175
- return result;
176
- }
177
96
  function registerInit(program) {
178
97
  program
179
98
  .command('init')
@@ -183,42 +102,6 @@ function registerInit(program) {
183
102
  .action(async (opts) => {
184
103
  const json = program.opts().json ?? false;
185
104
  const projectPath = process.cwd();
186
- // ── 0. 레거시 마이그레이션 ──
187
- const legacy = detectLegacy(projectPath);
188
- let migrated = false;
189
- if (legacy.hasLegacy) {
190
- if (json) {
191
- // JSON 모드: 자동 마이그레이션
192
- const migrationResult = runMigration(projectPath);
193
- migrated = migrationResult.relayYaml || migrationResult.portfolio || migrationResult.localCommandsCleaned > 0;
194
- }
195
- else if (process.stdin.isTTY) {
196
- console.log('\n \x1b[33m⚠ 레거시 구조 감지\x1b[0m\n');
197
- for (const d of legacy.details) {
198
- console.log(` ${d}`);
199
- }
200
- console.log();
201
- const { confirm } = await import('@inquirer/prompts');
202
- const doMigrate = await confirm({ message: '마이그레이션할까요?', default: true });
203
- if (doMigrate) {
204
- const migrationResult = runMigration(projectPath);
205
- migrated = true;
206
- console.log(`\n \x1b[32m✓ 마이그레이션 완료\x1b[0m`);
207
- if (migrationResult.relayYaml)
208
- console.log(' relay.yaml → .relay/relay.yaml');
209
- if (migrationResult.portfolio)
210
- console.log(' portfolio/ → .relay/portfolio/');
211
- if (migrationResult.localCommandsCleaned > 0)
212
- console.log(` 레거시 로컬 커맨드 ${migrationResult.localCommandsCleaned}개 제거`);
213
- console.log();
214
- }
215
- }
216
- else {
217
- // 비TTY, 비JSON: 자동 마이그레이션
218
- const migrationResult = runMigration(projectPath);
219
- migrated = migrationResult.relayYaml || migrationResult.portfolio || migrationResult.localCommandsCleaned > 0;
220
- }
221
- }
222
105
  const detected = (0, ai_tools_js_1.detectAgentCLIs)(projectPath);
223
106
  const detectedIds = new Set(detected.map((t) => t.value));
224
107
  const isBuilder = isTeamProject(projectPath);
@@ -303,7 +186,6 @@ function registerInit(program) {
303
186
  console.log(JSON.stringify({
304
187
  status: 'ok',
305
188
  mode: isBuilder ? 'builder' : 'user',
306
- migrated,
307
189
  global: {
308
190
  status: globalStatus,
309
191
  path: (0, command_adapter_js_1.getGlobalCommandDir)(),
@@ -21,13 +21,34 @@ function parseRelayYaml(content) {
21
21
  const tags = Array.isArray(raw.tags)
22
22
  ? raw.tags.map((t) => String(t))
23
23
  : [];
24
- const portfolio = Array.isArray(raw.portfolio)
25
- ? raw.portfolio.map((p) => ({
26
- path: String(p.path ?? ''),
27
- title: String(p.title ?? ''),
28
- description: p.description ? String(p.description) : undefined,
29
- })).filter((p) => p.path)
30
- : [];
24
+ // Parse portfolio slots structure
25
+ const rawPortfolio = raw.portfolio;
26
+ const portfolio = {};
27
+ if (rawPortfolio && typeof rawPortfolio === 'object' && !Array.isArray(rawPortfolio)) {
28
+ // New slot-based format: { cover: {...}, demo: {...}, gallery: [...] }
29
+ if (rawPortfolio.cover && typeof rawPortfolio.cover === 'object') {
30
+ const c = rawPortfolio.cover;
31
+ if (c.path)
32
+ portfolio.cover = { path: String(c.path) };
33
+ }
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
+ }
31
52
  const requires = raw.requires;
32
53
  const rawVisibility = String(raw.visibility ?? '');
33
54
  const visibility = rawVisibility === 'login-only' ? 'login-only'
@@ -86,28 +107,58 @@ function countDir(teamDir, dirName) {
86
107
  return fs_1.default.readdirSync(dirPath).filter((f) => !f.startsWith('.')).length;
87
108
  }
88
109
  /**
89
- * 포트폴리오 이미지를 수집한다.
90
- * 1. relay.yaml에 portfolio 섹션이 있으면 사용
91
- * 2. 없으면 ./portfolio/ 디렉토리 자동 스캔
110
+ * 슬롯 기반 포트폴리오를 PortfolioEntry[] 로 평탄화한다.
111
+ * relay.yaml에 슬롯이 정의되어 있으면 사용, 없으면 portfolio/ 자동 스캔.
92
112
  */
93
- function collectPortfolio(teamDir, yamlPortfolio) {
94
- if (yamlPortfolio.length > 0) {
95
- return yamlPortfolio.filter((p) => {
96
- const absPath = path_1.default.resolve(teamDir, p.path);
97
- return fs_1.default.existsSync(absPath);
98
- });
113
+ function collectPortfolio(relayDir, slots) {
114
+ const entries = [];
115
+ // Cover
116
+ if (slots.cover?.path) {
117
+ const absPath = path_1.default.resolve(relayDir, slots.cover.path);
118
+ if (fs_1.default.existsSync(absPath)) {
119
+ entries.push({ path: slots.cover.path, title: 'Cover', slot_type: 'cover' });
120
+ }
99
121
  }
100
- // Auto-scan .relay/portfolio/
101
- const portfolioDir = path_1.default.join(teamDir, '.relay', 'portfolio');
102
- if (!fs_1.default.existsSync(portfolioDir))
103
- return [];
104
- const files = fs_1.default.readdirSync(portfolioDir)
105
- .filter((f) => IMAGE_EXTS.some((ext) => f.toLowerCase().endsWith(ext)))
106
- .sort();
107
- return files.map((f) => ({
108
- path: path_1.default.join('.relay', 'portfolio', f),
109
- title: path_1.default.basename(f, path_1.default.extname(f)).replace(/[-_]/g, ' '),
110
- }));
122
+ // Demo
123
+ if (slots.demo) {
124
+ if (slots.demo.type === 'video_url' && slots.demo.url) {
125
+ entries.push({ path: '', title: 'Demo', slot_type: 'demo', demo_url: slots.demo.url });
126
+ }
127
+ else if (slots.demo.path) {
128
+ const absPath = path_1.default.resolve(relayDir, slots.demo.path);
129
+ if (fs_1.default.existsSync(absPath)) {
130
+ entries.push({ path: slots.demo.path, title: 'Demo', slot_type: 'demo' });
131
+ }
132
+ }
133
+ }
134
+ // Gallery
135
+ if (slots.gallery && slots.gallery.length > 0) {
136
+ for (const g of slots.gallery.slice(0, 5)) {
137
+ const absPath = path_1.default.resolve(relayDir, g.path);
138
+ if (fs_1.default.existsSync(absPath)) {
139
+ entries.push({ path: g.path, title: g.title, description: g.description, slot_type: 'gallery' });
140
+ }
141
+ }
142
+ }
143
+ // If no slots defined, auto-scan portfolio/ directory
144
+ if (entries.length === 0) {
145
+ const portfolioDir = path_1.default.join(relayDir, 'portfolio');
146
+ if (fs_1.default.existsSync(portfolioDir)) {
147
+ const files = fs_1.default.readdirSync(portfolioDir)
148
+ .filter((f) => IMAGE_EXTS.some((ext) => f.toLowerCase().endsWith(ext)))
149
+ .sort();
150
+ // First image as cover, rest as gallery
151
+ for (let i = 0; i < files.length && i < 6; i++) {
152
+ const f = files[i];
153
+ entries.push({
154
+ path: path_1.default.join('portfolio', f),
155
+ title: path_1.default.basename(f, path_1.default.extname(f)).replace(/[-_]/g, ' '),
156
+ slot_type: i === 0 ? 'cover' : 'gallery',
157
+ });
158
+ }
159
+ }
160
+ }
161
+ return entries;
111
162
  }
112
163
  /**
113
164
  * long_description을 결정한다.
@@ -144,12 +195,24 @@ async function publishToApi(token, tarPath, metadata, teamDir, portfolioEntries)
144
195
  const form = new FormData();
145
196
  form.append('package', blob, `${metadata.slug}-${metadata.version}.tar.gz`);
146
197
  form.append('metadata', JSON.stringify(metadata));
147
- // Attach portfolio images (with size validation)
198
+ // Attach portfolio images (with size validation and slot_type)
148
199
  if (portfolioEntries.length > 0) {
149
200
  const portfolioMeta = [];
150
201
  let totalImageSize = 0;
202
+ let fileIndex = 0;
151
203
  for (let i = 0; i < portfolioEntries.length; i++) {
152
204
  const entry = portfolioEntries[i];
205
+ // video_url demo has no file to upload
206
+ if (entry.slot_type === 'demo' && entry.demo_url && !entry.path) {
207
+ portfolioMeta.push({
208
+ title: entry.title,
209
+ description: entry.description,
210
+ sort_order: i,
211
+ slot_type: entry.slot_type,
212
+ demo_url: entry.demo_url,
213
+ });
214
+ continue;
215
+ }
153
216
  const absPath = path_1.default.resolve(teamDir, entry.path);
154
217
  const imgBuffer = fs_1.default.readFileSync(absPath);
155
218
  if (imgBuffer.length > MAX_IMAGE_SIZE) {
@@ -162,13 +225,16 @@ async function publishToApi(token, tarPath, metadata, teamDir, portfolioEntries)
162
225
  throw new Error(`포트폴리오 이미지 총 크기가 너무 큽니다 (${totalMB}MB). 최대 ${MAX_TOTAL_UPLOAD_SIZE / 1024 / 1024}MB까지 허용됩니다.`);
163
226
  }
164
227
  const ext = path_1.default.extname(entry.path).slice(1) || 'png';
165
- const mimeType = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'webp' ? 'image/webp' : 'image/png';
228
+ const mimeType = ext === 'gif' ? 'image/gif' : ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'webp' ? 'image/webp' : 'image/png';
166
229
  const imgBlob = new Blob([imgBuffer], { type: mimeType });
167
- form.append(`portfolio[${i}]`, imgBlob, path_1.default.basename(entry.path));
230
+ form.append(`portfolio[${fileIndex}]`, imgBlob, path_1.default.basename(entry.path));
231
+ fileIndex++;
168
232
  portfolioMeta.push({
169
233
  title: entry.title,
170
234
  description: entry.description,
171
235
  sort_order: i,
236
+ slot_type: entry.slot_type,
237
+ demo_url: entry.demo_url,
172
238
  });
173
239
  }
174
240
  form.append('portfolio_meta', JSON.stringify(portfolioMeta));
@@ -283,18 +283,36 @@ requires:
283
283
  - contents-team
284
284
  \`\`\`
285
285
 
286
- ### 4. 포트폴리오 생성
287
-
288
- #### Layer 1: 구성 시각화 (자동)
289
- - 분석된 팀 구조를 HTML로 생성합니다.
290
- - 생성된 HTML을 Playwright로 스크린샷 캡처합니다.
291
- - 결과 PNG를 ./.relay/portfolio/team-overview.png에 저장합니다.
292
-
293
- #### Layer 2: 결과물 쇼케이스 (선택)
286
+ ### 4. 포트폴리오 생성 (슬롯 기반)
287
+
288
+ 포트폴리오는 3가지 슬롯으로 구성됩니다. .relay/portfolio/ 디렉토리에 저장합니다.
289
+
290
+ #### 슬롯 1: cover (필수 — 마켓 리스팅 카드)
291
+ - 규격: 1200x630px, WebP, 최대 500KB
292
+ - 팀 구조를 요약하는 카드 HTML을 생성합니다:
293
+ - 이름, 버전
294
+ - Skills 목록 (이름 + 설명)
295
+ - Commands 목록
296
+ - 주요 기능 요약
297
+ - 생성된 HTML을 Playwright로 1200x630 뷰포트에서 스크린샷 캡처합니다.
298
+ - .relay/portfolio/cover.png에 저장합니다.
299
+ - 사용자에게 "이 cover를 사용할까요?" 확인. 직접 제공도 가능.
300
+
301
+ #### 슬롯 2: demo (선택 — 동작 시연)
302
+ - 팀 유형에 따라 제안 여부를 판단합니다:
303
+ - 브라우저 자동화 키워드 감지 (playwright, puppeteer, crawl, scrape, browser) → GIF 데모 제안
304
+ - 그 외 → 건너뜀 (사용자가 원하면 수동 추가)
305
+ - GIF: 최대 5MB, .relay/portfolio/demo.gif에 저장
306
+ - 또는 외부 영상 URL (YouTube, Loom 등)을 relay.yaml에 기록
307
+ - 사용자에게 "데모를 녹화할까요?" / "영상 URL이 있나요?" 확인
308
+
309
+ #### 슬롯 3: gallery (선택 — 결과물 쇼케이스, 최대 5장)
310
+ - 규격: 800x600px 이하, WebP, 각 500KB 이하
294
311
  - output/, results/, examples/ 디렉토리를 스캔합니다.
295
- - 발견된 결과물을 사용자에게 보여주고 포트폴리오 포함 여부를 선택합니다.
296
- - 선택된 이미지를 ./.relay/portfolio/에 저장합니다.
297
- - 이미지는 2MB 이하여야 합니다.
312
+ - 이미지 사용자에게 "어느 영역을 보여줄까요?" 확인 후 핵심 영역 crop
313
+ - HTML 파일 Playwright 스크린샷으로 변환
314
+ - 사용자가 포트폴리오에 포함할 항목을 선택합니다.
315
+ - .relay/portfolio/에 저장합니다.
298
316
 
299
317
  ### 5. 메타데이터 생성
300
318
  - description: skills 내용 기반으로 자동 생성합니다.
@@ -303,7 +321,19 @@ requires:
303
321
  - 사용자에게 확인: "이대로 배포할까요?"
304
322
 
305
323
  ### 6. .relay/relay.yaml 업데이트
306
- - 생성/수정된 메타데이터, requires, 포트폴리오 경로를 .relay/relay.yaml에 반영합니다.
324
+ - 메타데이터, requires, 포트폴리오 슬롯을 .relay/relay.yaml에 반영합니다.
325
+
326
+ \`\`\`yaml
327
+ portfolio:
328
+ cover:
329
+ path: portfolio/cover.png
330
+ demo: # 선택
331
+ type: gif
332
+ path: portfolio/demo.gif
333
+ gallery: # 선택, 최대 5장
334
+ - path: portfolio/example-1.png
335
+ title: "카드뉴스 예시"
336
+ \`\`\`
307
337
 
308
338
  ### 7. 배포
309
339
  - \`relay publish\` 명령어를 실행합니다.
@@ -314,12 +344,11 @@ requires:
314
344
  사용자: /relay-publish
315
345
  → 팀 구조 분석: skills 3개, commands 5개
316
346
  → 보안 스캔: ✓ 시크릿 없음
317
- → 환경변수 감지: OPENAI_API_KEY, DATABASE_URL
318
- → "OPENAI_API_KEY — 필수인가요?" → Yes
319
- → "DATABASE_URL — 필수인가요?" → No, 선택
320
- → npm 의존성 감지: sharp (필수)
347
+ → 환경변수 감지: OPENAI_API_KEY (필수), DATABASE_URL (선택)
321
348
  → requires 업데이트 완료
322
- 포트폴리오 생성사용자 확인
349
+ cover 생성: 팀 구조 카드 HTML 1200x630 스크린샷
350
+ → demo: "브라우저 자동화 감지. GIF 데모 녹화할까요?" → Yes → 녹화 완료
351
+ → gallery: output/ 스캔 → "카드뉴스 예시.png 포함?" → Yes → crop + 리사이즈
323
352
  → relay publish 실행
324
353
  → "배포 완료! URL: https://relayax.com/teams/my-team"`,
325
354
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relayax-cli",
3
- "version": "0.1.95",
3
+ "version": "0.1.96",
4
4
  "description": "RelayAX Agent Team Marketplace CLI - Install and manage agent teams",
5
5
  "main": "dist/index.js",
6
6
  "bin": {