@team-semicolon/semo-cli 4.7.4 → 4.9.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.
@@ -23,13 +23,62 @@ export interface BotAuditResult {
23
23
  score: number;
24
24
  checks: AuditCheck[];
25
25
  }
26
+ interface CheckDef {
27
+ name: string;
28
+ check: (botDir: string, botId: string) => AuditCheck;
29
+ }
30
+ export interface WorkspaceStandardRow {
31
+ path_pattern: string;
32
+ entry_type: string;
33
+ level: string;
34
+ severity: string;
35
+ category: string;
36
+ bot_scope: string;
37
+ bot_ids: string[];
38
+ symlink_target: string | null;
39
+ content_rules: Record<string, unknown> | null;
40
+ description: string | null;
41
+ fix_action: string | null;
42
+ fix_template: string | null;
43
+ }
44
+ export declare function loadCheckDefs(pool: Pool): Promise<{
45
+ defs: CheckDef[];
46
+ rows: WorkspaceStandardRow[];
47
+ }>;
26
48
  /** Sync version using fallback CHECK_DEFS (no DB) */
27
49
  export declare function auditBot(botDir: string, botId: string): BotAuditResult;
28
50
  /** Async version using DB-loaded rules */
29
51
  export declare function auditBotFromDb(botDir: string, botId: string, pool: Pool): Promise<BotAuditResult>;
52
+ /** Legacy hardcoded fix — used when DB is unavailable */
30
53
  export declare function fixBot(botDir: string, botId: string, checks: AuditCheck[]): number;
54
+ /**
55
+ * DB `fix_action`/`fix_template` 기반 auto-fix.
56
+ * 지원 액션: create_file, create_dir, create_symlink, delete (--force 필요)
57
+ */
58
+ export declare function fixBotFromDb(botDir: string, botId: string, checks: AuditCheck[], dbRules: WorkspaceStandardRow[], options?: {
59
+ force?: boolean;
60
+ }): {
61
+ fixed: number;
62
+ skipped: string[];
63
+ };
64
+ /**
65
+ * DB의 required 규칙에 대해 파일이 없으면 생성.
66
+ * content_rules 위반은 보고만 한다.
67
+ */
68
+ export declare function syncBotFromDb(botDir: string, botId: string, dbRules: WorkspaceStandardRow[]): {
69
+ created: number;
70
+ violations: string[];
71
+ };
72
+ export interface SkillAuditCheck extends AuditCheck {
73
+ skillName?: string;
74
+ }
75
+ /**
76
+ * 봇의 skills/ 하위 스킬 디렉토리 구조 검증
77
+ */
78
+ export declare function auditSkillStructure(botDir: string, botId: string): SkillAuditCheck[];
31
79
  export declare function auditBotKb(pool: Pool): Promise<AuditCheck[]>;
32
80
  export declare function auditBotDb(botId: string, pool: Pool): Promise<AuditCheck[]>;
33
81
  export declare function mergeDbChecks(result: BotAuditResult, dbChecks: AuditCheck[]): BotAuditResult;
34
82
  export declare function formatAuditSlack(results: BotAuditResult[]): string;
35
83
  export declare function storeAuditResults(results: BotAuditResult[], client: PoolClient): Promise<void>;
84
+ export {};
@@ -46,9 +46,13 @@ var __importStar = (this && this.__importStar) || (function () {
46
46
  };
47
47
  })();
48
48
  Object.defineProperty(exports, "__esModule", { value: true });
49
+ exports.loadCheckDefs = loadCheckDefs;
49
50
  exports.auditBot = auditBot;
50
51
  exports.auditBotFromDb = auditBotFromDb;
51
52
  exports.fixBot = fixBot;
53
+ exports.fixBotFromDb = fixBotFromDb;
54
+ exports.syncBotFromDb = syncBotFromDb;
55
+ exports.auditSkillStructure = auditSkillStructure;
52
56
  exports.auditBotKb = auditBotKb;
53
57
  exports.auditBotDb = auditBotDb;
54
58
  exports.mergeDbChecks = mergeDbChecks;
@@ -324,6 +328,7 @@ const FIXABLE_DIRS = {
324
328
  const FILE_TEMPLATES = {
325
329
  "SOUL.md": (botId) => `# ${botId} — SOUL\n\n## Identity\n\n> TODO\n\n## R&R\n\n> TODO\n\n## KB Lookup Protocol\n\n> semo kb get/search로 팀 정보 조회\n\n## Operating Procedures\n\n> TODO\n\n## NON-NEGOTIABLE\n\n1. TODO\n`,
326
330
  };
331
+ /** Legacy hardcoded fix — used when DB is unavailable */
327
332
  function fixBot(botDir, botId, checks) {
328
333
  let fixed = 0;
329
334
  for (const check of checks) {
@@ -384,6 +389,295 @@ function fixBot(botDir, botId, checks) {
384
389
  return fixed;
385
390
  }
386
391
  // ============================================================
392
+ // DB-based auto-fix — fix_action/fix_template 활용
393
+ // ============================================================
394
+ /**
395
+ * DB `fix_action`/`fix_template` 기반 auto-fix.
396
+ * 지원 액션: create_file, create_dir, create_symlink, delete (--force 필요)
397
+ */
398
+ function fixBotFromDb(botDir, botId, checks, dbRules, options = {}) {
399
+ let fixed = 0;
400
+ const skipped = [];
401
+ const home = process.env.HOME || "/Users/reus";
402
+ // Build a map of check name → row for quick lookup
403
+ const failedCheckNames = new Set(checks.filter((c) => !c.passed).map((c) => c.name));
404
+ for (const row of dbRules) {
405
+ if (!row.fix_action || !row.fix_template)
406
+ continue;
407
+ const checkName = `${row.category}/${row.path_pattern}`;
408
+ // Check if any of the failed checks relate to this row
409
+ const isRelevant = failedCheckNames.has(checkName) ||
410
+ failedCheckNames.has(`${checkName}-symlink`);
411
+ if (!isRelevant)
412
+ continue;
413
+ // Variable substitution in fix_template
414
+ const resolvedTemplate = row.fix_template
415
+ .replace(/\$BOT_ID/g, botId)
416
+ .replace(/\$HOME/g, home);
417
+ const targetPath = path.join(botDir, row.path_pattern);
418
+ switch (row.fix_action) {
419
+ case "create_file": {
420
+ if (fs.existsSync(targetPath))
421
+ break;
422
+ const dir = path.dirname(targetPath);
423
+ if (!fs.existsSync(dir)) {
424
+ fs.mkdirSync(dir, { recursive: true });
425
+ }
426
+ fs.writeFileSync(targetPath, resolvedTemplate, "utf-8");
427
+ fixed++;
428
+ break;
429
+ }
430
+ case "create_dir": {
431
+ const dirPath = row.path_pattern.endsWith("/")
432
+ ? path.join(botDir, row.path_pattern.slice(0, -1))
433
+ : targetPath;
434
+ if (!fs.existsSync(dirPath)) {
435
+ fs.mkdirSync(dirPath, { recursive: true });
436
+ fixed++;
437
+ }
438
+ break;
439
+ }
440
+ case "create_symlink": {
441
+ const symlinkTarget = resolvedTemplate;
442
+ if (!fs.existsSync(symlinkTarget))
443
+ break;
444
+ if (fs.existsSync(targetPath)) {
445
+ const stats = fs.lstatSync(targetPath);
446
+ if (stats.isSymbolicLink()) {
447
+ const existing = fs.readlinkSync(targetPath);
448
+ if (existing === symlinkTarget)
449
+ break; // already correct
450
+ fs.unlinkSync(targetPath);
451
+ }
452
+ else {
453
+ fs.unlinkSync(targetPath);
454
+ }
455
+ }
456
+ fs.symlinkSync(symlinkTarget, targetPath);
457
+ fixed++;
458
+ break;
459
+ }
460
+ case "delete": {
461
+ if (!fs.existsSync(targetPath))
462
+ break;
463
+ if (!options.force) {
464
+ skipped.push(`${row.path_pattern} (delete requires --force)`);
465
+ break;
466
+ }
467
+ const stats = fs.lstatSync(targetPath);
468
+ if (stats.isDirectory()) {
469
+ fs.rmSync(targetPath, { recursive: true, force: true });
470
+ }
471
+ else {
472
+ fs.unlinkSync(targetPath);
473
+ }
474
+ fixed++;
475
+ break;
476
+ }
477
+ }
478
+ }
479
+ return { fixed, skipped };
480
+ }
481
+ // ============================================================
482
+ // --sync: DB required 항목 proactive 보장
483
+ // ============================================================
484
+ /**
485
+ * DB의 required 규칙에 대해 파일이 없으면 생성.
486
+ * content_rules 위반은 보고만 한다.
487
+ */
488
+ function syncBotFromDb(botDir, botId, dbRules) {
489
+ let created = 0;
490
+ const violations = [];
491
+ const home = process.env.HOME || "/Users/reus";
492
+ for (const row of dbRules) {
493
+ if (row.level !== "required")
494
+ continue;
495
+ if (row.path_pattern.includes("*"))
496
+ continue; // glob patterns handled separately
497
+ const targetPath = path.join(botDir, row.path_pattern);
498
+ if (row.entry_type === "dir") {
499
+ const dirPath = row.path_pattern.endsWith("/")
500
+ ? path.join(botDir, row.path_pattern.slice(0, -1))
501
+ : targetPath;
502
+ if (!fs.existsSync(dirPath)) {
503
+ if (row.fix_action === "create_dir") {
504
+ fs.mkdirSync(dirPath, { recursive: true });
505
+ created++;
506
+ }
507
+ }
508
+ }
509
+ else if (row.entry_type === "symlink") {
510
+ if (!fs.existsSync(targetPath) && row.fix_action === "create_symlink" && row.fix_template) {
511
+ const symlinkTarget = row.fix_template
512
+ .replace(/\$BOT_ID/g, botId)
513
+ .replace(/\$HOME/g, home);
514
+ if (fs.existsSync(symlinkTarget)) {
515
+ const dir = path.dirname(targetPath);
516
+ if (!fs.existsSync(dir))
517
+ fs.mkdirSync(dir, { recursive: true });
518
+ fs.symlinkSync(symlinkTarget, targetPath);
519
+ created++;
520
+ }
521
+ }
522
+ }
523
+ else {
524
+ // file
525
+ if (!fs.existsSync(targetPath)) {
526
+ if (row.fix_action === "create_file" && row.fix_template) {
527
+ const content = row.fix_template
528
+ .replace(/\$BOT_ID/g, botId)
529
+ .replace(/\$HOME/g, home);
530
+ const dir = path.dirname(targetPath);
531
+ if (!fs.existsSync(dir))
532
+ fs.mkdirSync(dir, { recursive: true });
533
+ fs.writeFileSync(targetPath, content, "utf-8");
534
+ created++;
535
+ }
536
+ }
537
+ else if (row.content_rules) {
538
+ // File exists — check content_rules violations (report only)
539
+ const rules = row.content_rules;
540
+ const maxLines = rules.max_lines;
541
+ if (maxLines) {
542
+ try {
543
+ const lines = fs.readFileSync(targetPath, "utf-8").split("\n").length;
544
+ if (lines > maxLines) {
545
+ violations.push(`${row.path_pattern}: ${lines} lines (max ${maxLines})`);
546
+ }
547
+ }
548
+ catch { /* skip unreadable */ }
549
+ }
550
+ }
551
+ }
552
+ }
553
+ return { created, violations };
554
+ }
555
+ /**
556
+ * 봇의 skills/ 하위 스킬 디렉토리 구조 검증
557
+ */
558
+ function auditSkillStructure(botDir, botId) {
559
+ const checks = [];
560
+ const skillsDir = path.join(botDir, "skills");
561
+ if (!fs.existsSync(skillsDir) || !fs.statSync(skillsDir).isDirectory()) {
562
+ return checks;
563
+ }
564
+ let entries;
565
+ try {
566
+ entries = fs.readdirSync(skillsDir, { withFileTypes: true });
567
+ }
568
+ catch {
569
+ return checks;
570
+ }
571
+ for (const entry of entries) {
572
+ if (!entry.isDirectory())
573
+ continue;
574
+ if (entry.name.startsWith("_") || entry.name.startsWith("."))
575
+ continue;
576
+ if (entry.name.endsWith(".skill"))
577
+ continue;
578
+ const skillName = entry.name;
579
+ const skillDir = path.join(skillsDir, skillName);
580
+ const skillMdPath = path.join(skillDir, "SKILL.md");
581
+ // SKILL.md 존재 여부
582
+ if (!fs.existsSync(skillMdPath)) {
583
+ checks.push({
584
+ name: `skill/${skillName}/SKILL.md`,
585
+ passed: false,
586
+ detail: `skills/${skillName}/SKILL.md missing`,
587
+ skillName,
588
+ });
589
+ continue;
590
+ }
591
+ // SKILL.md 내용 검증
592
+ let content;
593
+ try {
594
+ content = fs.readFileSync(skillMdPath, "utf-8");
595
+ }
596
+ catch {
597
+ checks.push({
598
+ name: `skill/${skillName}/SKILL.md`,
599
+ passed: false,
600
+ detail: `skills/${skillName}/SKILL.md unreadable`,
601
+ skillName,
602
+ });
603
+ continue;
604
+ }
605
+ // SKILL.md exists
606
+ checks.push({
607
+ name: `skill/${skillName}/SKILL.md`,
608
+ passed: true,
609
+ detail: `skills/${skillName}/SKILL.md exists`,
610
+ skillName,
611
+ });
612
+ // Line count check (max 500)
613
+ const lines = content.split("\n").length;
614
+ checks.push({
615
+ name: `skill/${skillName}/lines`,
616
+ passed: lines <= 500,
617
+ detail: lines <= 500
618
+ ? `skills/${skillName}/SKILL.md: ${lines} lines (≤ 500)`
619
+ : `skills/${skillName}/SKILL.md: ${lines} lines (> 500, too long)`,
620
+ skillName,
621
+ });
622
+ // Frontmatter check (---\nname: ...\ndescription: ...\n---)
623
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
624
+ if (fmMatch) {
625
+ const fm = fmMatch[1];
626
+ const nameMatch = fm.match(/^name:\s*(.+)$/m);
627
+ const descMatch = fm.match(/^description:/m);
628
+ if (nameMatch) {
629
+ const fmName = nameMatch[1].trim();
630
+ const isKebab = /^[a-z0-9]+(-[a-z0-9]+)*$/.test(fmName);
631
+ checks.push({
632
+ name: `skill/${skillName}/name-kebab`,
633
+ passed: isKebab,
634
+ detail: isKebab
635
+ ? `skills/${skillName} name "${fmName}" is kebab-case`
636
+ : `skills/${skillName} name "${fmName}" is not kebab-case`,
637
+ skillName,
638
+ });
639
+ }
640
+ else {
641
+ checks.push({
642
+ name: `skill/${skillName}/name-kebab`,
643
+ passed: false,
644
+ detail: `skills/${skillName}/SKILL.md frontmatter missing 'name'`,
645
+ skillName,
646
+ });
647
+ }
648
+ checks.push({
649
+ name: `skill/${skillName}/description`,
650
+ passed: !!descMatch,
651
+ detail: descMatch
652
+ ? `skills/${skillName}/SKILL.md has description`
653
+ : `skills/${skillName}/SKILL.md frontmatter missing 'description'`,
654
+ skillName,
655
+ });
656
+ }
657
+ else {
658
+ checks.push({
659
+ name: `skill/${skillName}/frontmatter`,
660
+ passed: false,
661
+ detail: `skills/${skillName}/SKILL.md missing frontmatter (--- block)`,
662
+ skillName,
663
+ });
664
+ }
665
+ // Forbidden files check
666
+ const forbiddenFiles = ["README.md", "CHANGELOG.md", "INSTALL.md", "LICENSE", "LICENSE.md"];
667
+ for (const forbidden of forbiddenFiles) {
668
+ if (fs.existsSync(path.join(skillDir, forbidden))) {
669
+ checks.push({
670
+ name: `skill/${skillName}/forbidden-${forbidden}`,
671
+ passed: false,
672
+ detail: `skills/${skillName}/${forbidden} exists (forbidden)`,
673
+ skillName,
674
+ });
675
+ }
676
+ }
677
+ }
678
+ return checks;
679
+ }
680
+ // ============================================================
387
681
  // KB domain checks (async, requires pool)
388
682
  // ============================================================
389
683
  const KB_REQUIRED_DOMAINS = ["semicolon"];
@@ -561,7 +561,9 @@ function registerBotsCommands(program) {
561
561
  .command("audit")
562
562
  .description("봇 워크스페이스 표준 구조 audit")
563
563
  .option("--format <type>", "출력 형식 (table|json|slack)", "table")
564
- .option("--fix", "누락 파일/디렉토리 자동 생성")
564
+ .option("--fix", "누락 파일/디렉토리 자동 생성 (DB fix_action 활용)")
565
+ .option("--sync", "DB required 항목 proactive 보장 (누락 파일 생성)")
566
+ .option("--force", "delete fix_action 실행 허용 (--fix와 함께 사용)")
565
567
  .option("--no-db", "DB 저장 건너뛰기")
566
568
  .option("--local", "~/.claude/semo/bots/ 로컬 미러 audit")
567
569
  .action(async (options) => {
@@ -596,14 +598,74 @@ function registerBotsCommands(program) {
596
598
  // Run audit — try DB-based rules first, fallback to hardcoded
597
599
  let results;
598
600
  const dbConnected = await (0, database_1.isDbConnected)();
601
+ let dbRules = [];
599
602
  if (dbConnected) {
600
603
  const pool = (0, database_1.getPool)();
604
+ try {
605
+ const { rows } = await (0, audit_1.loadCheckDefs)(pool);
606
+ dbRules = rows;
607
+ }
608
+ catch { /* DB rules load failed, will use fallback */ }
601
609
  results = await Promise.all(botEntries.map(({ botId, botDir }) => (0, audit_1.auditBotFromDb)(botDir, botId, pool)));
602
610
  }
603
611
  else {
604
612
  results = botEntries.map(({ botId, botDir }) => (0, audit_1.auditBot)(botDir, botId));
605
613
  }
614
+ // Merge skill structure checks into results
615
+ for (let i = 0; i < results.length; i++) {
616
+ const skillChecks = (0, audit_1.auditSkillStructure)(botEntries[i].botDir, botEntries[i].botId);
617
+ if (skillChecks.length > 0) {
618
+ results[i] = (0, audit_1.mergeDbChecks)(results[i], skillChecks);
619
+ }
620
+ }
606
621
  spinner.stop();
622
+ // --sync: DB required 항목 proactive 보장
623
+ if (options.sync && dbRules.length > 0) {
624
+ let totalCreated = 0;
625
+ const allViolations = [];
626
+ for (const { botId, botDir } of botEntries) {
627
+ const botSpecificRules = dbRules.filter((row) => {
628
+ if (row.bot_scope === "all")
629
+ return true;
630
+ if (row.bot_scope === "include")
631
+ return row.bot_ids.includes(botId);
632
+ if (row.bot_scope === "exclude")
633
+ return !row.bot_ids.includes(botId);
634
+ return true;
635
+ });
636
+ const { created, violations } = (0, audit_1.syncBotFromDb)(botDir, botId, botSpecificRules);
637
+ if (created > 0) {
638
+ console.log(chalk_1.default.green(` ✔ ${botId}: ${created}개 항목 동기화 생성`));
639
+ totalCreated += created;
640
+ }
641
+ allViolations.push(...violations.map((v) => `${botId}: ${v}`));
642
+ }
643
+ if (totalCreated > 0) {
644
+ console.log(chalk_1.default.green(`\n총 ${totalCreated}개 동기화`));
645
+ }
646
+ if (allViolations.length > 0) {
647
+ console.log(chalk_1.default.yellow(`\n⚠ content_rules 위반 (보고만):`));
648
+ for (const v of allViolations) {
649
+ console.log(chalk_1.default.yellow(` - ${v}`));
650
+ }
651
+ }
652
+ // Re-audit after sync
653
+ if (totalCreated > 0) {
654
+ if (dbConnected) {
655
+ const pool = (0, database_1.getPool)();
656
+ results = await Promise.all(botEntries.map(({ botId, botDir }) => (0, audit_1.auditBotFromDb)(botDir, botId, pool)));
657
+ }
658
+ else {
659
+ results = botEntries.map(({ botId, botDir }) => (0, audit_1.auditBot)(botDir, botId));
660
+ }
661
+ for (let i = 0; i < results.length; i++) {
662
+ const skillChecks = (0, audit_1.auditSkillStructure)(botEntries[i].botDir, botEntries[i].botId);
663
+ if (skillChecks.length > 0) {
664
+ results[i] = (0, audit_1.mergeDbChecks)(results[i], skillChecks);
665
+ }
666
+ }
667
+ }
668
+ }
607
669
  // --fix
608
670
  if (options.fix) {
609
671
  let totalFixed = 0;
@@ -611,20 +673,50 @@ function registerBotsCommands(program) {
611
673
  const botDir = isLocal
612
674
  ? path.join(home, ".claude", "semo", "bots", r.botId)
613
675
  : path.join(home, `.openclaw-${r.botId}`, "workspace");
614
- const fixed = (0, audit_1.fixBot)(botDir, r.botId, r.checks);
676
+ let fixed;
677
+ if (dbRules.length > 0) {
678
+ // DB-based fix
679
+ const botSpecificRules = dbRules.filter((row) => {
680
+ if (row.bot_scope === "all")
681
+ return true;
682
+ if (row.bot_scope === "include")
683
+ return row.bot_ids.includes(r.botId);
684
+ if (row.bot_scope === "exclude")
685
+ return !row.bot_ids.includes(r.botId);
686
+ return true;
687
+ });
688
+ const result = (0, audit_1.fixBotFromDb)(botDir, r.botId, r.checks, botSpecificRules, { force: options.force });
689
+ fixed = result.fixed;
690
+ if (result.skipped.length > 0) {
691
+ for (const s of result.skipped) {
692
+ console.log(chalk_1.default.yellow(` ⚠ ${r.botId}: ${s}`));
693
+ }
694
+ }
695
+ }
696
+ else {
697
+ // Fallback to hardcoded fix
698
+ fixed = (0, audit_1.fixBot)(botDir, r.botId, r.checks);
699
+ }
615
700
  if (fixed > 0) {
616
- console.log(chalk_1.default.green(` ✔ ${r.botId}: ${fixed}개 파일/디렉토리 생성`));
701
+ console.log(chalk_1.default.green(` ✔ ${r.botId}: ${fixed}개 파일/디렉토리 수정`));
617
702
  totalFixed += fixed;
618
703
  }
619
704
  }
620
705
  if (totalFixed > 0) {
621
706
  console.log(chalk_1.default.green(`\n총 ${totalFixed}개 수정`));
622
707
  // Re-audit after fix
708
+ if (dbConnected) {
709
+ const pool = (0, database_1.getPool)();
710
+ results = await Promise.all(botEntries.map(({ botId, botDir }) => (0, audit_1.auditBotFromDb)(botDir, botId, pool)));
711
+ }
712
+ else {
713
+ results = botEntries.map(({ botId, botDir }) => (0, audit_1.auditBot)(botDir, botId));
714
+ }
623
715
  for (let i = 0; i < results.length; i++) {
624
- const botDir = isLocal
625
- ? path.join(home, ".claude", "semo", "bots", results[i].botId)
626
- : path.join(home, `.openclaw-${results[i].botId}`, "workspace");
627
- results[i] = (0, audit_1.auditBot)(botDir, results[i].botId);
716
+ const skillChecks = (0, audit_1.auditSkillStructure)(botEntries[i].botDir, botEntries[i].botId);
717
+ if (skillChecks.length > 0) {
718
+ results[i] = (0, audit_1.mergeDbChecks)(results[i], skillChecks);
719
+ }
628
720
  }
629
721
  }
630
722
  }
@@ -1,12 +1,15 @@
1
1
  /**
2
- * skill-sync — 봇 전용 스킬 파일 스캔 + DB 동기화
2
+ * skill-sync — 봇 워크스페이스 스킬 스캔 + DB 동기화
3
+ *
4
+ * v2.0: ~/.openclaw-{bot}/workspace/skills/ 경로 스캔 (semo-system/ 폐기)
5
+ * 봇 목록은 bot_status DB에서 동적 로드, fallback으로 로컬 디렉토리 스캔.
3
6
  *
4
7
  * semo bots sync (piggyback) 및 semo context sync (세션 훅) 양쪽에서 호출.
5
8
  * 스킬 이름은 flat (예: 'kb-manager'), metadata.bot_ids 배열로 봇 매핑.
6
9
  * 동일 스킬명이 여러 봇에 존재하면 bot_ids를 머지.
7
10
  */
8
- import { PoolClient } from "pg";
9
- interface ScannedSkill {
11
+ import { Pool, PoolClient } from "pg";
12
+ export interface ScannedSkill {
10
13
  name: string;
11
14
  prompt: string;
12
15
  package: string;
@@ -17,12 +20,21 @@ export interface SkillSyncResult {
17
20
  total: number;
18
21
  }
19
22
  /**
20
- * semo-system/bot-workspaces 에서 봇 전용 스킬 파일 스캔
23
+ * ~/.openclaw-{bot}/workspace/skills/ 에서 봇 전용 스킬 파일 스캔
24
+ *
25
+ * @param botIds - 스캔 대상 봇 ID 목록
26
+ */
27
+ export declare function scanSkills(botIds: string[]): ScannedSkill[];
28
+ /**
29
+ * @deprecated semo-system/ 기반 스캔 (하위호환 유지)
21
30
  */
22
31
  export declare function scanSkills(semoSystemDir: string): ScannedSkill[];
32
+ /**
33
+ * DB에서 봇 목록을 로드, fallback으로 로컬 디렉토리 스캔
34
+ */
35
+ export declare function getBotIds(pool: Pool): Promise<string[]>;
23
36
  /**
24
37
  * 스캔된 스킬을 skill_definitions에 upsert
25
38
  * flat name + metadata.bot_ids 배열 사용, 동일 스킬명은 bot_ids 머지
26
39
  */
27
- export declare function syncSkillsToDB(client: PoolClient, semoSystemDir: string): Promise<SkillSyncResult>;
28
- export {};
40
+ export declare function syncSkillsToDB(client: PoolClient, pool: Pool): Promise<SkillSyncResult>;
@@ -1,6 +1,9 @@
1
1
  "use strict";
2
2
  /**
3
- * skill-sync — 봇 전용 스킬 파일 스캔 + DB 동기화
3
+ * skill-sync — 봇 워크스페이스 스킬 스캔 + DB 동기화
4
+ *
5
+ * v2.0: ~/.openclaw-{bot}/workspace/skills/ 경로 스캔 (semo-system/ 폐기)
6
+ * 봇 목록은 bot_status DB에서 동적 로드, fallback으로 로컬 디렉토리 스캔.
4
7
  *
5
8
  * semo bots sync (piggyback) 및 semo context sync (세션 훅) 양쪽에서 호출.
6
9
  * 스킬 이름은 flat (예: 'kb-manager'), metadata.bot_ids 배열로 봇 매핑.
@@ -41,13 +44,55 @@ var __importStar = (this && this.__importStar) || (function () {
41
44
  })();
42
45
  Object.defineProperty(exports, "__esModule", { value: true });
43
46
  exports.scanSkills = scanSkills;
47
+ exports.getBotIds = getBotIds;
44
48
  exports.syncSkillsToDB = syncSkillsToDB;
45
49
  const fs = __importStar(require("fs"));
46
50
  const path = __importStar(require("path"));
47
- /**
48
- * semo-system/bot-workspaces 에서 전용 스킬 파일 스캔
49
- */
50
- function scanSkills(semoSystemDir) {
51
+ function scanSkills(arg) {
52
+ if (typeof arg === "string") {
53
+ return scanSkillsLegacy(arg);
54
+ }
55
+ return scanSkillsV2(arg);
56
+ }
57
+ function scanSkillsV2(botIds) {
58
+ const skills = [];
59
+ const home = process.env.HOME || "/Users/reus";
60
+ for (const botId of botIds) {
61
+ const skillsDir = path.join(home, `.openclaw-${botId}`, "workspace", "skills");
62
+ if (!fs.existsSync(skillsDir))
63
+ continue;
64
+ let skillEntries;
65
+ try {
66
+ skillEntries = fs.readdirSync(skillsDir, { withFileTypes: true });
67
+ }
68
+ catch {
69
+ continue;
70
+ }
71
+ for (const skillEntry of skillEntries) {
72
+ if (!skillEntry.isDirectory())
73
+ continue;
74
+ if (skillEntry.name.endsWith(".skill"))
75
+ continue;
76
+ if (skillEntry.name.startsWith("_") || skillEntry.name.startsWith("."))
77
+ continue;
78
+ const skillMdPath = path.join(skillsDir, skillEntry.name, "SKILL.md");
79
+ if (!fs.existsSync(skillMdPath))
80
+ continue;
81
+ try {
82
+ skills.push({
83
+ name: skillEntry.name,
84
+ prompt: fs.readFileSync(skillMdPath, "utf-8"),
85
+ package: "openclaw",
86
+ botId,
87
+ });
88
+ }
89
+ catch { /* skip unreadable */ }
90
+ }
91
+ }
92
+ return skills;
93
+ }
94
+ /** Legacy: semo-system/bot-workspaces 에서 스캔 (하위호환) */
95
+ function scanSkillsLegacy(semoSystemDir) {
51
96
  const skills = [];
52
97
  const workspacesDir = path.join(semoSystemDir, "bot-workspaces");
53
98
  if (!fs.existsSync(workspacesDir))
@@ -81,12 +126,42 @@ function scanSkills(semoSystemDir) {
81
126
  }
82
127
  return skills;
83
128
  }
129
+ /**
130
+ * DB에서 봇 목록을 로드, fallback으로 로컬 디렉토리 스캔
131
+ */
132
+ async function getBotIds(pool) {
133
+ try {
134
+ const result = await pool.query("SELECT bot_id FROM semo.bot_status ORDER BY bot_id");
135
+ if (result.rows.length > 0) {
136
+ return result.rows.map((r) => r.bot_id);
137
+ }
138
+ }
139
+ catch { /* fallback to local scan */ }
140
+ // Fallback: scan ~/.openclaw-*/workspace/ directories
141
+ const home = process.env.HOME || "/Users/reus";
142
+ const botIds = [];
143
+ try {
144
+ const entries = fs.readdirSync(home);
145
+ for (const entry of entries) {
146
+ const match = entry.match(/^\.openclaw-(.+)$/);
147
+ if (match) {
148
+ const wsDir = path.join(home, entry, "workspace");
149
+ if (fs.existsSync(wsDir)) {
150
+ botIds.push(match[1]);
151
+ }
152
+ }
153
+ }
154
+ }
155
+ catch { /* skip */ }
156
+ return botIds.sort();
157
+ }
84
158
  /**
85
159
  * 스캔된 스킬을 skill_definitions에 upsert
86
160
  * flat name + metadata.bot_ids 배열 사용, 동일 스킬명은 bot_ids 머지
87
161
  */
88
- async function syncSkillsToDB(client, semoSystemDir) {
89
- const skills = scanSkills(semoSystemDir);
162
+ async function syncSkillsToDB(client, pool) {
163
+ const botIds = await getBotIds(pool);
164
+ const skills = scanSkills(botIds);
90
165
  for (const skill of skills) {
91
166
  await client.query(`INSERT INTO semo.skill_definitions (name, prompt, package, metadata, is_active, office_id)
92
167
  VALUES ($1, $2, $3, $4, true, NULL)
package/dist/index.js CHANGED
@@ -1665,7 +1665,7 @@ kbCmd
1665
1665
  });
1666
1666
  kbCmd
1667
1667
  .command("upsert <domain> <key> [sub_key]")
1668
- .description("KB 항목 쓰기 (upsert) — 임베딩 자동 생성 + 스키마 검증")
1668
+ .description("KB 항목 쓰기 (upsert) — 임베딩 자동 생성 + 스키마 검증 (key는 kebab-case만 허용)")
1669
1669
  .requiredOption("--content <text>", "항목 본문")
1670
1670
  .option("--metadata <json>", "추가 메타데이터 (JSON 문자열)")
1671
1671
  .option("--created-by <name>", "작성자 식별자", "semo-cli")
@@ -1702,13 +1702,66 @@ kbCmd
1702
1702
  process.exit(1);
1703
1703
  }
1704
1704
  });
1705
+ kbCmd
1706
+ .command("delete <domain> <key> [sub_key]")
1707
+ .description("KB 항목 삭제 (domain + key + sub_key)")
1708
+ .option("--yes", "확인 프롬프트 건너뛰기 (크론/스크립트용)")
1709
+ .action(async (domain, key, subKey, options) => {
1710
+ try {
1711
+ const pool = (0, database_1.getPool)();
1712
+ // 삭제 전 항목 확인
1713
+ const entry = await (0, kb_1.kbGet)(pool, domain, key, subKey);
1714
+ if (!entry) {
1715
+ console.log(chalk_1.default.yellow(`\n 항목 없음: ${domain}/${key}${subKey ? '/' + subKey : ''}\n`));
1716
+ await (0, database_1.closeConnection)();
1717
+ process.exit(1);
1718
+ }
1719
+ // 확인 프롬프트 (--yes가 없으면)
1720
+ if (!options.yes) {
1721
+ const fullPath = `${domain}/${entry.key}${entry.sub_key ? '/' + entry.sub_key : ''}`;
1722
+ console.log(chalk_1.default.cyan(`\n📄 삭제 대상: ${fullPath}`));
1723
+ console.log(chalk_1.default.gray(` version: ${entry.version} | created_by: ${entry.created_by} | updated: ${entry.updated_at}`));
1724
+ console.log(chalk_1.default.gray(` content: ${entry.content.substring(0, 120)}${entry.content.length > 120 ? '...' : ''}\n`));
1725
+ const { createInterface } = await import("readline");
1726
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1727
+ const answer = await new Promise((resolve) => {
1728
+ rl.question(chalk_1.default.yellow(" 정말 삭제하시겠습니까? (y/N): "), resolve);
1729
+ });
1730
+ rl.close();
1731
+ if (answer.toLowerCase() !== "y") {
1732
+ console.log(chalk_1.default.gray(" 취소됨.\n"));
1733
+ await (0, database_1.closeConnection)();
1734
+ return;
1735
+ }
1736
+ }
1737
+ const result = await (0, kb_1.kbDelete)(pool, domain, key, subKey);
1738
+ if (result.deleted) {
1739
+ const fullPath = `${domain}/${result.entry.key}${result.entry.sub_key ? '/' + result.entry.sub_key : ''}`;
1740
+ console.log(chalk_1.default.green(`✔ KB 삭제 완료: ${fullPath}`));
1741
+ }
1742
+ else {
1743
+ console.log(chalk_1.default.red(`✖ KB 삭제 실패: ${result.error}`));
1744
+ process.exit(1);
1745
+ }
1746
+ await (0, database_1.closeConnection)();
1747
+ }
1748
+ catch (err) {
1749
+ console.error(chalk_1.default.red(`삭제 실패: ${err}`));
1750
+ await (0, database_1.closeConnection)();
1751
+ process.exit(1);
1752
+ }
1753
+ });
1705
1754
  kbCmd
1706
1755
  .command("ontology")
1707
1756
  .description("온톨로지 조회 — 도메인/타입/스키마/라우팅 테이블")
1708
- .option("--action <type>", "동작 (list|show|services|types|instances|schema|routing-table|register)", "list")
1757
+ .option("--action <type>", "동작 (list|show|services|types|instances|schema|routing-table|register|add-key|remove-key)", "list")
1709
1758
  .option("--domain <name>", "action=show|register 시 도메인")
1710
- .option("--type <name>", "action=schema|register 시 타입 키")
1711
- .option("--description <text>", "action=register설명")
1759
+ .option("--type <name>", "action=schema|register|add-key|remove-key 시 타입 키")
1760
+ .option("--key <name>", "action=add-key|remove-key스키마 키")
1761
+ .option("--key-type <type>", "action=add-key 시 키 유형 (singleton|collection)", "singleton")
1762
+ .option("--required", "action=add-key 시 필수 여부")
1763
+ .option("--hint <text>", "action=add-key 시 값 힌트")
1764
+ .option("--description <text>", "action=register|add-key 시 설명")
1712
1765
  .option("--service <name>", "action=register 시 서비스 그룹")
1713
1766
  .option("--tags <tags>", "action=register 시 태그 (쉼표 구분)")
1714
1767
  .option("--no-init", "action=register 시 필수 KB entry 자동 생성 건너뛰기")
@@ -1896,8 +1949,51 @@ kbCmd
1896
1949
  }
1897
1950
  }
1898
1951
  }
1952
+ else if (action === "add-key") {
1953
+ if (!options.type) {
1954
+ console.log(chalk_1.default.red("--type 옵션이 필요합니다. (예: --type service)"));
1955
+ process.exit(1);
1956
+ }
1957
+ if (!options.key) {
1958
+ console.log(chalk_1.default.red("--key 옵션이 필요합니다. (예: --key slack_channel)"));
1959
+ process.exit(1);
1960
+ }
1961
+ const result = await (0, kb_1.ontoAddKey)(pool, {
1962
+ type_key: options.type,
1963
+ scheme_key: options.key,
1964
+ description: options.description,
1965
+ key_type: options.keyType,
1966
+ required: options.required || false,
1967
+ value_hint: options.hint,
1968
+ });
1969
+ if (result.success) {
1970
+ console.log(chalk_1.default.green(`\n✅ 스키마 키 추가 완료: ${options.type}.${options.key} (${options.keyType})\n`));
1971
+ }
1972
+ else {
1973
+ console.log(chalk_1.default.red(`\n❌ 스키마 키 추가 실패: ${result.error}\n`));
1974
+ process.exit(1);
1975
+ }
1976
+ }
1977
+ else if (action === "remove-key") {
1978
+ if (!options.type) {
1979
+ console.log(chalk_1.default.red("--type 옵션이 필요합니다."));
1980
+ process.exit(1);
1981
+ }
1982
+ if (!options.key) {
1983
+ console.log(chalk_1.default.red("--key 옵션이 필요합니다."));
1984
+ process.exit(1);
1985
+ }
1986
+ const result = await (0, kb_1.ontoRemoveKey)(pool, options.type, options.key);
1987
+ if (result.success) {
1988
+ console.log(chalk_1.default.green(`\n✅ 스키마 키 삭제 완료: ${options.type}.${options.key}\n`));
1989
+ }
1990
+ else {
1991
+ console.log(chalk_1.default.red(`\n❌ 스키마 키 삭제 실패: ${result.error}\n`));
1992
+ process.exit(1);
1993
+ }
1994
+ }
1899
1995
  else {
1900
- console.log(chalk_1.default.red(`알 수 없는 action: '${action}'. 사용 가능: list, show, services, types, instances, schema, routing-table, register`));
1996
+ console.log(chalk_1.default.red(`알 수 없는 action: '${action}'. 사용 가능: list, show, services, types, instances, schema, routing-table, register, add-key, remove-key`));
1901
1997
  process.exit(1);
1902
1998
  }
1903
1999
  await (0, database_1.closeConnection)();
package/dist/kb.d.ts CHANGED
@@ -135,6 +135,15 @@ export declare function kbDigest(pool: Pool, since: string, domain?: string): Pr
135
135
  * Get a single KB entry by domain + key + sub_key
136
136
  */
137
137
  export declare function kbGet(pool: Pool, domain: string, rawKey: string, rawSubKey?: string): Promise<KBEntry | null>;
138
+ /**
139
+ * Delete a single KB entry by domain/key/sub_key.
140
+ * Returns the deleted entry content for confirmation, or null if not found.
141
+ */
142
+ export declare function kbDelete(pool: Pool, domain: string, rawKey: string, rawSubKey?: string): Promise<{
143
+ deleted: boolean;
144
+ entry?: KBEntry;
145
+ error?: string;
146
+ }>;
138
147
  /**
139
148
  * Upsert a single KB entry with domain/key validation and embedding generation
140
149
  */
@@ -223,6 +232,27 @@ export interface OntoRegisterResult {
223
232
  * 4. If init_required (default true), create KB entries for required keys in kb_type_schema
224
233
  */
225
234
  export declare function ontoRegister(pool: Pool, opts: OntoRegisterOptions): Promise<OntoRegisterResult>;
235
+ /**
236
+ * Add a key to a type schema
237
+ */
238
+ export declare function ontoAddKey(pool: Pool, opts: {
239
+ type_key: string;
240
+ scheme_key: string;
241
+ description?: string;
242
+ key_type?: "singleton" | "collection";
243
+ required?: boolean;
244
+ value_hint?: string;
245
+ }): Promise<{
246
+ success: boolean;
247
+ error?: string;
248
+ }>;
249
+ /**
250
+ * Remove a key from a type schema
251
+ */
252
+ export declare function ontoRemoveKey(pool: Pool, typeKey: string, schemeKey: string): Promise<{
253
+ success: boolean;
254
+ error?: string;
255
+ }>;
226
256
  /**
227
257
  * Write ontology schemas to local cache
228
258
  */
package/dist/kb.js CHANGED
@@ -55,12 +55,15 @@ exports.ontoListTypes = ontoListTypes;
55
55
  exports.ontoValidate = ontoValidate;
56
56
  exports.kbDigest = kbDigest;
57
57
  exports.kbGet = kbGet;
58
+ exports.kbDelete = kbDelete;
58
59
  exports.kbUpsert = kbUpsert;
59
60
  exports.ontoListSchema = ontoListSchema;
60
61
  exports.ontoRoutingTable = ontoRoutingTable;
61
62
  exports.ontoListServices = ontoListServices;
62
63
  exports.ontoListInstances = ontoListInstances;
63
64
  exports.ontoRegister = ontoRegister;
65
+ exports.ontoAddKey = ontoAddKey;
66
+ exports.ontoRemoveKey = ontoRemoveKey;
64
67
  exports.ontoPullToLocal = ontoPullToLocal;
65
68
  const fs = __importStar(require("fs"));
66
69
  const path = __importStar(require("path"));
@@ -683,6 +686,37 @@ async function kbGet(pool, domain, rawKey, rawSubKey) {
683
686
  client.release();
684
687
  }
685
688
  }
689
+ /**
690
+ * Delete a single KB entry by domain/key/sub_key.
691
+ * Returns the deleted entry content for confirmation, or null if not found.
692
+ */
693
+ async function kbDelete(pool, domain, rawKey, rawSubKey) {
694
+ let key;
695
+ let subKey;
696
+ if (rawSubKey !== undefined) {
697
+ key = rawKey;
698
+ subKey = rawSubKey;
699
+ }
700
+ else {
701
+ const split = splitKey(rawKey);
702
+ key = split.key;
703
+ subKey = split.subKey;
704
+ }
705
+ const client = await pool.connect();
706
+ try {
707
+ const result = await client.query(`DELETE FROM semo.knowledge_base
708
+ WHERE domain = $1 AND key = $2 AND sub_key = $3
709
+ RETURNING domain, key, sub_key, content, metadata, created_by, version,
710
+ created_at::text, updated_at::text`, [domain, key, subKey]);
711
+ if (result.rows.length === 0) {
712
+ return { deleted: false, error: `항목 없음: ${domain}/${combineKey(key, subKey)}` };
713
+ }
714
+ return { deleted: true, entry: result.rows[0] };
715
+ }
716
+ finally {
717
+ client.release();
718
+ }
719
+ }
686
720
  /**
687
721
  * Upsert a single KB entry with domain/key validation and embedding generation
688
722
  */
@@ -714,6 +748,22 @@ async function kbUpsert(pool, entry) {
714
748
  finally {
715
749
  client.release();
716
750
  }
751
+ // Naming convention check: kebab-case only
752
+ const warnings = [];
753
+ if (/_/.test(key)) {
754
+ const suggested = key.replace(/_/g, "-");
755
+ return {
756
+ success: false,
757
+ error: `키 '${key}'에 snake_case가 포함되어 있습니다. kebab-case를 사용하세요: '${suggested}'`,
758
+ };
759
+ }
760
+ if (/_/.test(subKey)) {
761
+ const suggested = subKey.replace(/_/g, "-");
762
+ return {
763
+ success: false,
764
+ error: `sub_key '${subKey}'에 snake_case가 포함되어 있습니다. kebab-case를 사용하세요: '${suggested}'`,
765
+ };
766
+ }
717
767
  // Key validation against type schema
718
768
  {
719
769
  const schemaClient = await pool.connect();
@@ -960,6 +1010,69 @@ async function ontoRegister(pool, opts) {
960
1010
  client.release();
961
1011
  }
962
1012
  }
1013
+ /**
1014
+ * Add a key to a type schema
1015
+ */
1016
+ async function ontoAddKey(pool, opts) {
1017
+ const client = await pool.connect();
1018
+ try {
1019
+ // Naming convention: kebab-case only
1020
+ if (/_/.test(opts.scheme_key)) {
1021
+ const suggested = opts.scheme_key.replace(/_/g, "-");
1022
+ return { success: false, error: `키 '${opts.scheme_key}'에 snake_case가 포함되어 있습니다. kebab-case를 사용하세요: '${suggested}'` };
1023
+ }
1024
+ // Check type exists
1025
+ const typeCheck = await client.query("SELECT DISTINCT type_key FROM semo.kb_type_schema WHERE type_key = $1", [opts.type_key]);
1026
+ if (typeCheck.rows.length === 0) {
1027
+ // Check if this type exists in ontology at all
1028
+ const ontoCheck = await client.query("SELECT DISTINCT entity_type FROM semo.ontology WHERE entity_type = $1", [opts.type_key]);
1029
+ if (ontoCheck.rows.length === 0) {
1030
+ return { success: false, error: `타입 '${opts.type_key}'이(가) 존재하지 않습니다.` };
1031
+ }
1032
+ }
1033
+ // Check duplicate
1034
+ const dupCheck = await client.query("SELECT id FROM semo.kb_type_schema WHERE type_key = $1 AND scheme_key = $2", [opts.type_key, opts.scheme_key]);
1035
+ if (dupCheck.rows.length > 0) {
1036
+ return { success: false, error: `키 '${opts.scheme_key}'은(는) '${opts.type_key}' 타입에 이미 존재합니다.` };
1037
+ }
1038
+ // Get max sort_order
1039
+ const maxOrder = await client.query("SELECT COALESCE(MAX(sort_order), 0) + 10 as next_order FROM semo.kb_type_schema WHERE type_key = $1", [opts.type_key]);
1040
+ const sortOrder = maxOrder.rows[0].next_order;
1041
+ await client.query(`INSERT INTO semo.kb_type_schema (type_key, scheme_key, scheme_description, key_type, required, value_hint, sort_order)
1042
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`, [
1043
+ opts.type_key,
1044
+ opts.scheme_key,
1045
+ opts.description || opts.scheme_key,
1046
+ opts.key_type || "singleton",
1047
+ opts.required || false,
1048
+ opts.value_hint || null,
1049
+ sortOrder,
1050
+ ]);
1051
+ return { success: true };
1052
+ }
1053
+ catch (err) {
1054
+ return { success: false, error: String(err) };
1055
+ }
1056
+ finally {
1057
+ client.release();
1058
+ }
1059
+ }
1060
+ /**
1061
+ * Remove a key from a type schema
1062
+ */
1063
+ async function ontoRemoveKey(pool, typeKey, schemeKey) {
1064
+ const client = await pool.connect();
1065
+ try {
1066
+ const result = await client.query("DELETE FROM semo.kb_type_schema WHERE type_key = $1 AND scheme_key = $2 RETURNING id", [typeKey, schemeKey]);
1067
+ if (result.rows.length === 0) {
1068
+ return { success: false, error: `키 '${schemeKey}'은(는) '${typeKey}' 타입에 존재하지 않습니다.` };
1069
+ }
1070
+ return { success: true };
1071
+ }
1072
+ finally {
1073
+ client.release();
1074
+ }
1075
+ }
963
1076
  /**
964
1077
  * Write ontology schemas to local cache
965
1078
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-semicolon/semo-cli",
3
- "version": "4.7.4",
3
+ "version": "4.9.0",
4
4
  "description": "SEMO CLI - AI Agent Orchestration Framework Installer",
5
5
  "main": "dist/index.js",
6
6
  "bin": {