@team-semicolon/semo-cli 4.2.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,520 @@
1
+ "use strict";
2
+ /**
3
+ * semo test — 테스트 관리
4
+ *
5
+ * semo test list — 등록된 스위트 + 최근 실행 상태
6
+ * semo test run [suite] — 스위트 실행 + DB 기록
7
+ * semo test run --all — 전체 스위트 실행
8
+ * semo test run --notify — 실패 시 Slack 알림
9
+ * semo test history [suite] — 실행 이력
10
+ */
11
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
12
+ if (k2 === undefined) k2 = k;
13
+ var desc = Object.getOwnPropertyDescriptor(m, k);
14
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
15
+ desc = { enumerable: true, get: function() { return m[k]; } };
16
+ }
17
+ Object.defineProperty(o, k2, desc);
18
+ }) : (function(o, m, k, k2) {
19
+ if (k2 === undefined) k2 = k;
20
+ o[k2] = m[k];
21
+ }));
22
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
23
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
24
+ }) : function(o, v) {
25
+ o["default"] = v;
26
+ });
27
+ var __importStar = (this && this.__importStar) || (function () {
28
+ var ownKeys = function(o) {
29
+ ownKeys = Object.getOwnPropertyNames || function (o) {
30
+ var ar = [];
31
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
32
+ return ar;
33
+ };
34
+ return ownKeys(o);
35
+ };
36
+ return function (mod) {
37
+ if (mod && mod.__esModule) return mod;
38
+ var result = {};
39
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
40
+ __setModuleDefault(result, mod);
41
+ return result;
42
+ };
43
+ })();
44
+ var __importDefault = (this && this.__importDefault) || function (mod) {
45
+ return (mod && mod.__esModule) ? mod : { "default": mod };
46
+ };
47
+ Object.defineProperty(exports, "__esModule", { value: true });
48
+ exports.registerTestCommands = registerTestCommands;
49
+ const chalk_1 = __importDefault(require("chalk"));
50
+ const ora_1 = __importDefault(require("ora"));
51
+ const child_process_1 = require("child_process");
52
+ const crypto_1 = require("crypto");
53
+ const path = __importStar(require("path"));
54
+ const os = __importStar(require("os"));
55
+ const database_1 = require("../database");
56
+ const slack_notify_1 = require("../slack-notify");
57
+ const workspace_audit_1 = require("../test-runners/workspace-audit");
58
+ // ============================================================
59
+ // JSONL Parser
60
+ // ============================================================
61
+ function parseTestOutputLine(line) {
62
+ const trimmed = line.trim();
63
+ if (!trimmed || !trimmed.startsWith("{"))
64
+ return null;
65
+ try {
66
+ const obj = JSON.parse(trimmed);
67
+ if (obj.type === "case" || obj.type === "summary")
68
+ return obj;
69
+ return null;
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ // ============================================================
76
+ // Runner Path Resolution
77
+ // ============================================================
78
+ function resolveRunnerPath(runnerPath) {
79
+ // Expand ~ to HOME
80
+ if (runnerPath.startsWith("~/")) {
81
+ return path.join(os.homedir(), runnerPath.slice(2));
82
+ }
83
+ // Relative path → resolve from project root (cwd)
84
+ if (!path.isAbsolute(runnerPath)) {
85
+ return path.resolve(process.cwd(), runnerPath);
86
+ }
87
+ return runnerPath;
88
+ }
89
+ // ============================================================
90
+ // Test Suite Execution
91
+ // ============================================================
92
+ async function executeTestSuite(suite, triggeredBy) {
93
+ const pool = (0, database_1.getPool)();
94
+ const runId = (0, crypto_1.randomUUID)();
95
+ const startedAt = new Date();
96
+ // Create run record
97
+ await pool.query(`INSERT INTO semo.test_runs (run_id, suite_id, triggered_by, started_at, status)
98
+ VALUES ($1, $2, $3, $4, 'running')`, [runId, suite.suite_id, triggeredBy, startedAt.toISOString()]);
99
+ // ── Declarative runner: DB에서 규칙 로드 → 동적 TC 생성 ──
100
+ if (suite.runner_type === "declarative") {
101
+ return executeDeclarativeSuite(pool, suite, runId);
102
+ }
103
+ // ── Script runner: 외부 프로세스 spawn ──
104
+ const resolvedPath = resolveRunnerPath(suite.runner_path || "");
105
+ const cases = [];
106
+ let pass = 0;
107
+ let fail = 0;
108
+ let warn = 0;
109
+ const failedLabels = [];
110
+ return new Promise((resolve) => {
111
+ let cmd;
112
+ let args;
113
+ let env;
114
+ if (suite.runner_type === "shell") {
115
+ cmd = "bash";
116
+ args = [resolvedPath];
117
+ env = { ...process.env, OUTPUT_FORMAT: "json" };
118
+ }
119
+ else {
120
+ cmd = "npx";
121
+ args = ["tsx", resolvedPath, "--json"];
122
+ env = { ...process.env };
123
+ }
124
+ const child = (0, child_process_1.spawn)(cmd, args, {
125
+ cwd: process.cwd(),
126
+ env,
127
+ stdio: ["ignore", "pipe", "pipe"],
128
+ });
129
+ let buffer = "";
130
+ child.stdout.on("data", (chunk) => {
131
+ buffer += chunk.toString();
132
+ const lines = buffer.split("\n");
133
+ buffer = lines.pop() || ""; // Keep incomplete last line
134
+ for (const line of lines) {
135
+ const parsed = parseTestOutputLine(line);
136
+ if (!parsed)
137
+ continue;
138
+ if (parsed.type === "case") {
139
+ cases.push(parsed);
140
+ if (parsed.status === "pass")
141
+ pass++;
142
+ else if (parsed.status === "fail") {
143
+ fail++;
144
+ if (parsed.label)
145
+ failedLabels.push(parsed.label);
146
+ }
147
+ else if (parsed.status === "warn")
148
+ warn++;
149
+ }
150
+ else if (parsed.type === "summary") {
151
+ // Use summary counts as authoritative if provided
152
+ if (typeof parsed.pass === "number")
153
+ pass = parsed.pass;
154
+ if (typeof parsed.fail === "number")
155
+ fail = parsed.fail;
156
+ if (typeof parsed.warn === "number")
157
+ warn = parsed.warn;
158
+ }
159
+ }
160
+ });
161
+ child.stderr.on("data", () => {
162
+ /* discard stderr */
163
+ });
164
+ const timeout = setTimeout(() => {
165
+ child.kill("SIGTERM");
166
+ }, 600000); // 10 minute timeout
167
+ child.on("close", async (code) => {
168
+ clearTimeout(timeout);
169
+ // Process remaining buffer
170
+ if (buffer.trim()) {
171
+ const parsed = parseTestOutputLine(buffer);
172
+ if (parsed?.type === "case") {
173
+ cases.push(parsed);
174
+ if (parsed.status === "pass")
175
+ pass++;
176
+ else if (parsed.status === "fail") {
177
+ fail++;
178
+ if (parsed.label)
179
+ failedLabels.push(parsed.label);
180
+ }
181
+ else if (parsed.status === "warn")
182
+ warn++;
183
+ }
184
+ else if (parsed?.type === "summary") {
185
+ if (typeof parsed.pass === "number")
186
+ pass = parsed.pass;
187
+ if (typeof parsed.fail === "number")
188
+ fail = parsed.fail;
189
+ if (typeof parsed.warn === "number")
190
+ warn = parsed.warn;
191
+ }
192
+ }
193
+ const status = code !== 0 || fail > 0 ? "failed" : cases.length === 0 ? "error" : "passed";
194
+ const summary = `${pass} passed, ${fail} failed, ${warn} warn`;
195
+ try {
196
+ // Insert individual results
197
+ for (const c of cases) {
198
+ if (c.type !== "case")
199
+ continue;
200
+ await pool.query(`INSERT INTO semo.test_results (run_id, case_id, suite_id, label, status, detail, duration_ms)
201
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`, [
202
+ runId,
203
+ c.id || c.label || "unknown",
204
+ suite.suite_id,
205
+ c.label || c.id || "unknown",
206
+ c.status || "skip",
207
+ c.detail || null,
208
+ c.duration_ms || null,
209
+ ]);
210
+ }
211
+ // Update run record
212
+ await pool.query(`UPDATE semo.test_runs
213
+ SET finished_at = NOW(), total_pass = $1, total_fail = $2, total_warn = $3,
214
+ status = $4, summary = $5
215
+ WHERE run_id = $6`, [pass, fail, warn, status, summary, runId]);
216
+ }
217
+ catch (err) {
218
+ console.error(chalk_1.default.red(` DB 기록 오류: ${err.message}`));
219
+ }
220
+ resolve({ runId, suiteId: suite.suite_id, status, pass, fail, warn, failedLabels });
221
+ });
222
+ });
223
+ }
224
+ // ============================================================
225
+ // Declarative Runner
226
+ // ============================================================
227
+ async function executeDeclarativeSuite(pool, suite, runId) {
228
+ let outputs = [];
229
+ // Dispatch by rule_source
230
+ if (suite.rule_source === "bot_workspace_standard") {
231
+ outputs = await (0, workspace_audit_1.runDeclarativeWorkspaceAudit)(pool);
232
+ }
233
+ else {
234
+ return {
235
+ runId,
236
+ suiteId: suite.suite_id,
237
+ status: "error",
238
+ pass: 0,
239
+ fail: 0,
240
+ warn: 0,
241
+ failedLabels: [`unknown rule_source: ${suite.rule_source}`],
242
+ };
243
+ }
244
+ let pass = 0;
245
+ let fail = 0;
246
+ let warn = 0;
247
+ const failedLabels = [];
248
+ for (const o of outputs) {
249
+ if (o.type === "summary") {
250
+ pass = o.pass ?? pass;
251
+ fail = o.fail ?? fail;
252
+ warn = o.warn ?? warn;
253
+ continue;
254
+ }
255
+ if (o.type !== "case")
256
+ continue;
257
+ // Record to DB
258
+ await pool.query(`INSERT INTO semo.test_results (run_id, case_id, suite_id, label, status, detail)
259
+ VALUES ($1, $2, $3, $4, $5, $6)`, [
260
+ runId,
261
+ o.id || o.label || "unknown",
262
+ suite.suite_id,
263
+ o.label || o.id || "unknown",
264
+ o.status || "skip",
265
+ o.detail || null,
266
+ ]);
267
+ if (o.status === "fail")
268
+ failedLabels.push(o.label || "");
269
+ }
270
+ const status = fail > 0 ? "failed" : "passed";
271
+ const summary = `${pass} passed, ${fail} failed, ${warn} warn`;
272
+ await pool.query(`UPDATE semo.test_runs
273
+ SET finished_at = NOW(), total_pass = $1, total_fail = $2, total_warn = $3,
274
+ status = $4, summary = $5
275
+ WHERE run_id = $6`, [pass, fail, warn, status, summary, runId]);
276
+ return { runId, suiteId: suite.suite_id, status, pass, fail, warn, failedLabels };
277
+ }
278
+ // ============================================================
279
+ // Commands
280
+ // ============================================================
281
+ function registerTestCommands(program) {
282
+ const testCmd = program
283
+ .command("test")
284
+ .description("테스트 관리 — 실행, 이력 조회");
285
+ // ── semo test list ──
286
+ testCmd
287
+ .command("list")
288
+ .description("등록된 테스트 스위트 목록 + 최근 실행 상태")
289
+ .action(async () => {
290
+ const pool = (0, database_1.getPool)();
291
+ try {
292
+ const { rows } = await pool.query(`
293
+ SELECT s.suite_id, s.name, s.layer, s.runner_type, s.enabled,
294
+ r.status AS last_status,
295
+ r.started_at::text AS last_run_at,
296
+ r.total_pass AS last_pass,
297
+ r.total_fail AS last_fail,
298
+ r.total_warn AS last_warn
299
+ FROM semo.test_suites s
300
+ LEFT JOIN LATERAL (
301
+ SELECT * FROM semo.test_runs
302
+ WHERE suite_id = s.suite_id
303
+ ORDER BY started_at DESC LIMIT 1
304
+ ) r ON true
305
+ ORDER BY s.suite_id
306
+ `);
307
+ if (rows.length === 0) {
308
+ console.log(chalk_1.default.yellow("등록된 테스트 스위트가 없습니다."));
309
+ return;
310
+ }
311
+ console.log(chalk_1.default.bold("\n 테스트 스위트 목록\n"));
312
+ // Header
313
+ const hdr = padR("Suite ID", 22) +
314
+ padR("Layer", 14) +
315
+ padR("Runner", 8) +
316
+ padR("Last Run", 22) +
317
+ padR("Status", 10) +
318
+ "Result";
319
+ console.log(chalk_1.default.gray(` ${hdr}`));
320
+ console.log(chalk_1.default.gray(` ${"─".repeat(90)}`));
321
+ for (const row of rows) {
322
+ const statusColor = row.last_status === "passed"
323
+ ? chalk_1.default.green
324
+ : row.last_status === "failed"
325
+ ? chalk_1.default.red
326
+ : chalk_1.default.gray;
327
+ const lastRun = row.last_run_at
328
+ ? timeAgo(new Date(row.last_run_at))
329
+ : "-";
330
+ const result = row.last_pass !== null
331
+ ? `${row.last_pass}/${(row.last_pass || 0) + (row.last_fail || 0) + (row.last_warn || 0)}`
332
+ : "-";
333
+ console.log(` ${padR(row.suite_id, 22)}${padR(row.layer, 14)}${padR(row.runner_type, 8)}${padR(lastRun, 22)}${statusColor(padR(row.last_status || "no runs", 10))}${result}`);
334
+ }
335
+ console.log();
336
+ }
337
+ finally {
338
+ await (0, database_1.closeConnection)();
339
+ }
340
+ });
341
+ // ── semo test run [suite] ──
342
+ testCmd
343
+ .command("run [suite]")
344
+ .description("테스트 스위트 실행 + DB 기록")
345
+ .option("--all", "비활성 포함 전체 실행")
346
+ .option("--notify", "실패 시 Slack 알림")
347
+ .option("--triggered-by <who>", "트리거 주체", "manual")
348
+ .action(async (suiteArg, options) => {
349
+ const pool = (0, database_1.getPool)();
350
+ try {
351
+ let suites;
352
+ if (suiteArg) {
353
+ // Single suite
354
+ const { rows } = await pool.query("SELECT * FROM semo.test_suites WHERE suite_id = $1", [suiteArg]);
355
+ if (rows.length === 0) {
356
+ console.error(chalk_1.default.red(`스위트 '${suiteArg}'를 찾을 수 없습니다.`));
357
+ const allSuites = await pool.query("SELECT suite_id FROM semo.test_suites");
358
+ console.log(chalk_1.default.gray(`등록된 스위트: ${allSuites.rows.map((r) => r.suite_id).join(", ")}`));
359
+ return;
360
+ }
361
+ suites = rows;
362
+ }
363
+ else {
364
+ // All suites
365
+ const where = options.all ? "" : "WHERE enabled = true";
366
+ const { rows } = await pool.query(`SELECT * FROM semo.test_suites ${where} ORDER BY suite_id`);
367
+ suites = rows;
368
+ }
369
+ if (suites.length === 0) {
370
+ console.log(chalk_1.default.yellow("실행할 스위트가 없습니다."));
371
+ return;
372
+ }
373
+ console.log(chalk_1.default.bold(`\n ${suites.length}개 스위트 실행\n`));
374
+ const results = [];
375
+ for (const suite of suites) {
376
+ const spinner = (0, ora_1.default)(`${suite.suite_id} (${suite.name})`).start();
377
+ try {
378
+ const result = await executeTestSuite(suite, options.triggeredBy || "manual");
379
+ results.push(result);
380
+ if (result.status === "passed") {
381
+ spinner.succeed(`${suite.suite_id}: ${chalk_1.default.green("PASSED")} (${result.pass}/${result.pass + result.fail + result.warn})`);
382
+ }
383
+ else if (result.status === "failed") {
384
+ spinner.fail(`${suite.suite_id}: ${chalk_1.default.red("FAILED")} (pass: ${result.pass}, fail: ${result.fail}, warn: ${result.warn})`);
385
+ }
386
+ else {
387
+ spinner.warn(`${suite.suite_id}: ${chalk_1.default.yellow("ERROR")} (출력 파싱 실패)`);
388
+ }
389
+ }
390
+ catch (err) {
391
+ spinner.fail(`${suite.suite_id}: ${chalk_1.default.red("ERROR")} — ${err.message}`);
392
+ results.push({
393
+ runId: "error",
394
+ suiteId: suite.suite_id,
395
+ status: "error",
396
+ pass: 0,
397
+ fail: 0,
398
+ warn: 0,
399
+ failedLabels: [],
400
+ });
401
+ }
402
+ }
403
+ // Summary
404
+ const totalPass = results.reduce((s, r) => s + r.pass, 0);
405
+ const totalFail = results.reduce((s, r) => s + r.fail, 0);
406
+ const totalWarn = results.reduce((s, r) => s + r.warn, 0);
407
+ const allPassed = results.every((r) => r.status === "passed");
408
+ console.log(`\n ${allPassed ? chalk_1.default.green("ALL PASSED") : chalk_1.default.red("FAILURES DETECTED")} — ${totalPass} pass, ${totalFail} fail, ${totalWarn} warn\n`);
409
+ // Slack notification
410
+ if (options.notify && !allPassed) {
411
+ const failedResults = results.filter((r) => r.status === "failed");
412
+ for (const fr of failedResults) {
413
+ const msg = (0, slack_notify_1.formatTestFailureMessage)(fr.suiteId, fr.runId, fr.pass, fr.fail, fr.warn, fr.failedLabels);
414
+ const sent = await (0, slack_notify_1.sendSlackNotification)(msg);
415
+ if (sent) {
416
+ console.log(chalk_1.default.gray(` Slack 알림 전송: ${fr.suiteId}`));
417
+ }
418
+ }
419
+ }
420
+ }
421
+ finally {
422
+ await (0, database_1.closeConnection)();
423
+ }
424
+ });
425
+ // ── semo test history [suite] ──
426
+ testCmd
427
+ .command("history [suite]")
428
+ .description("테스트 실행 이력 조회")
429
+ .option("--limit <n>", "최근 N건", "10")
430
+ .action(async (suiteArg, options) => {
431
+ const pool = (0, database_1.getPool)();
432
+ const limit = parseInt(options.limit) || 10;
433
+ try {
434
+ let query;
435
+ let params;
436
+ if (suiteArg) {
437
+ query = `
438
+ SELECT r.run_id, r.suite_id, s.name, r.triggered_by,
439
+ r.started_at::text, r.finished_at::text,
440
+ r.total_pass, r.total_fail, r.total_warn, r.status, r.summary
441
+ FROM semo.test_runs r
442
+ JOIN semo.test_suites s ON s.suite_id = r.suite_id
443
+ WHERE r.suite_id = $1
444
+ ORDER BY r.started_at DESC
445
+ LIMIT $2
446
+ `;
447
+ params = [suiteArg, limit];
448
+ }
449
+ else {
450
+ query = `
451
+ SELECT r.run_id, r.suite_id, s.name, r.triggered_by,
452
+ r.started_at::text, r.finished_at::text,
453
+ r.total_pass, r.total_fail, r.total_warn, r.status, r.summary
454
+ FROM semo.test_runs r
455
+ JOIN semo.test_suites s ON s.suite_id = r.suite_id
456
+ ORDER BY r.started_at DESC
457
+ LIMIT $1
458
+ `;
459
+ params = [limit];
460
+ }
461
+ const { rows } = await pool.query(query, params);
462
+ if (rows.length === 0) {
463
+ console.log(chalk_1.default.yellow("실행 이력이 없습니다."));
464
+ return;
465
+ }
466
+ console.log(chalk_1.default.bold(`\n 실행 이력${suiteArg ? ` — ${suiteArg}` : ""}\n`));
467
+ const hdr = padR("Run ID", 10) +
468
+ padR("Suite", 22) +
469
+ padR("Triggered", 10) +
470
+ padR("Started", 22) +
471
+ padR("Status", 10) +
472
+ "Result";
473
+ console.log(chalk_1.default.gray(` ${hdr}`));
474
+ console.log(chalk_1.default.gray(` ${"─".repeat(90)}`));
475
+ for (const row of rows) {
476
+ const statusColor = row.status === "passed"
477
+ ? chalk_1.default.green
478
+ : row.status === "failed"
479
+ ? chalk_1.default.red
480
+ : chalk_1.default.yellow;
481
+ const started = row.started_at
482
+ ? new Date(row.started_at).toLocaleString("ko-KR", {
483
+ timeZone: "Asia/Seoul",
484
+ month: "2-digit",
485
+ day: "2-digit",
486
+ hour: "2-digit",
487
+ minute: "2-digit",
488
+ })
489
+ : "-";
490
+ console.log(` ${padR(row.run_id.substring(0, 8), 10)}${padR(row.suite_id, 22)}${padR(row.triggered_by, 10)}${padR(started, 22)}${statusColor(padR(row.status, 10))}${row.summary || "-"}`);
491
+ }
492
+ console.log();
493
+ }
494
+ finally {
495
+ await (0, database_1.closeConnection)();
496
+ }
497
+ });
498
+ }
499
+ // ============================================================
500
+ // Helpers
501
+ // ============================================================
502
+ function padR(s, len) {
503
+ if (s.length >= len)
504
+ return s.substring(0, len);
505
+ return s + " ".repeat(len - s.length);
506
+ }
507
+ function timeAgo(date) {
508
+ const now = Date.now();
509
+ const diff = now - date.getTime();
510
+ const mins = Math.floor(diff / 60000);
511
+ if (mins < 1)
512
+ return "just now";
513
+ if (mins < 60)
514
+ return `${mins}m ago`;
515
+ const hours = Math.floor(mins / 60);
516
+ if (hours < 24)
517
+ return `${hours}h ago`;
518
+ const days = Math.floor(hours / 24);
519
+ return `${days}d ago`;
520
+ }
@@ -43,6 +43,7 @@ export interface Agent {
43
43
  package: string;
44
44
  is_active: boolean;
45
45
  install_order: number;
46
+ metadata?: Record<string, unknown>;
46
47
  }
47
48
  export interface Package {
48
49
  id: string;
package/dist/database.js CHANGED
@@ -172,7 +172,7 @@ async function getActiveSkills() {
172
172
  ARRAY[]::text[]
173
173
  ) AS bot_ids,
174
174
  category, package, is_active, is_required, install_order, version
175
- FROM skill_definitions
175
+ FROM semo.skill_definitions
176
176
  WHERE is_active = true AND office_id IS NULL
177
177
  ORDER BY install_order
178
178
  `);
@@ -209,7 +209,7 @@ async function getActiveSkillsForBot(botId) {
209
209
  ) AS bot_ids,
210
210
  sd.category, sd.package, sd.is_active, sd.is_required,
211
211
  sd.install_order, sd.version
212
- FROM skill_definitions sd
212
+ FROM semo.skill_definitions sd
213
213
  WHERE sd.is_active = true
214
214
  AND sd.office_id IS NULL
215
215
  AND (NOT sd.metadata ? 'bot_ids' OR sd.metadata->'bot_ids' ? $1)
@@ -262,7 +262,8 @@ async function getAgents() {
262
262
  const result = await getPool().query(`
263
263
  SELECT id, name, name AS display_name,
264
264
  persona_prompt AS content,
265
- package, is_active, install_order
265
+ package, is_active, install_order,
266
+ metadata
266
267
  FROM agent_definitions
267
268
  WHERE is_active = true AND office_id IS NULL
268
269
  ORDER BY install_order
@@ -174,6 +174,19 @@ async function syncGlobalCache(claudeDir) {
174
174
  .join("\n");
175
175
  content += `\n\n## 위임 매트릭스\n${delegationLines}\n`;
176
176
  }
177
+ // metadata에 model/description이 있으면 YAML frontmatter 주입
178
+ if (agent.metadata && (agent.metadata.model || agent.metadata.description)) {
179
+ const hasFrontmatter = content.trimStart().startsWith('---');
180
+ if (!hasFrontmatter) {
181
+ const fm = ['---'];
182
+ if (agent.metadata.description)
183
+ fm.push(`description: "${agent.metadata.description}"`);
184
+ if (agent.metadata.model)
185
+ fm.push(`model: "${agent.metadata.model}"`);
186
+ fm.push('---', '');
187
+ content = fm.join('\n') + content;
188
+ }
189
+ }
177
190
  fs.writeFileSync(path.join(agentFolder, `${agent.name}.md`), content);
178
191
  }
179
192
  return {