@team-semicolon/semo-cli 4.6.0 → 4.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/bots.js +143 -8
- package/dist/commands/context.js +17 -2
- package/dist/commands/memory.js +3 -0
- package/dist/database.d.ts +12 -0
- package/dist/database.js +37 -2
- package/dist/index.js +106 -159
- package/dist/semo-workspace.d.ts +42 -0
- package/dist/semo-workspace.js +339 -0
- package/package.json +1 -1
package/dist/commands/bots.js
CHANGED
|
@@ -590,15 +590,30 @@ function registerBotsCommands(program) {
|
|
|
590
590
|
.option("--format <type>", "출력 형식 (table|json|slack)", "table")
|
|
591
591
|
.option("--fix", "누락 파일/디렉토리 자동 생성")
|
|
592
592
|
.option("--no-db", "DB 저장 건너뛰기")
|
|
593
|
+
.option("--local", "~/.claude/semo/bots/ 로컬 미러 audit")
|
|
593
594
|
.action(async (options) => {
|
|
594
595
|
const home = process.env.HOME || "/Users/reus";
|
|
595
|
-
const
|
|
596
|
-
|
|
596
|
+
const isLocal = options.local === true;
|
|
597
|
+
const sourceLabel = isLocal ? "~/.claude/semo/bots/" : "~/.openclaw-*/workspace/";
|
|
598
|
+
const spinner = (0, ora_1.default)(`bot-workspaces audit 중... (${sourceLabel})`).start();
|
|
597
599
|
const botEntries = [];
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
600
|
+
if (isLocal) {
|
|
601
|
+
// 로컬 미러: ~/.claude/semo/bots/{botId}/
|
|
602
|
+
const semoBotsDir = path.join(home, ".claude", "semo", "bots");
|
|
603
|
+
if (fs.existsSync(semoBotsDir)) {
|
|
604
|
+
const dirs = fs.readdirSync(semoBotsDir).filter(f => fs.statSync(path.join(semoBotsDir, f)).isDirectory());
|
|
605
|
+
for (const botId of dirs) {
|
|
606
|
+
botEntries.push({ botId, botDir: path.join(semoBotsDir, botId) });
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
// v2.0: SoT는 ~/.openclaw-{bot}/workspace/
|
|
612
|
+
for (const botId of KNOWN_BOTS) {
|
|
613
|
+
const botDir = path.join(home, `.openclaw-${botId}`, "workspace");
|
|
614
|
+
if (fs.existsSync(botDir)) {
|
|
615
|
+
botEntries.push({ botId, botDir });
|
|
616
|
+
}
|
|
602
617
|
}
|
|
603
618
|
}
|
|
604
619
|
if (botEntries.length === 0) {
|
|
@@ -620,7 +635,9 @@ function registerBotsCommands(program) {
|
|
|
620
635
|
if (options.fix) {
|
|
621
636
|
let totalFixed = 0;
|
|
622
637
|
for (const r of results) {
|
|
623
|
-
const botDir =
|
|
638
|
+
const botDir = isLocal
|
|
639
|
+
? path.join(home, ".claude", "semo", "bots", r.botId)
|
|
640
|
+
: path.join(home, `.openclaw-${r.botId}`, "workspace");
|
|
624
641
|
const fixed = (0, audit_1.fixBot)(botDir, r.botId, r.checks);
|
|
625
642
|
if (fixed > 0) {
|
|
626
643
|
console.log(chalk_1.default.green(` ✔ ${r.botId}: ${fixed}개 파일/디렉토리 생성`));
|
|
@@ -631,7 +648,9 @@ function registerBotsCommands(program) {
|
|
|
631
648
|
console.log(chalk_1.default.green(`\n총 ${totalFixed}개 수정`));
|
|
632
649
|
// Re-audit after fix
|
|
633
650
|
for (let i = 0; i < results.length; i++) {
|
|
634
|
-
const botDir =
|
|
651
|
+
const botDir = isLocal
|
|
652
|
+
? path.join(home, ".claude", "semo", "bots", results[i].botId)
|
|
653
|
+
: path.join(home, `.openclaw-${results[i].botId}`, "workspace");
|
|
635
654
|
results[i] = (0, audit_1.auditBot)(botDir, results[i].botId);
|
|
636
655
|
}
|
|
637
656
|
}
|
|
@@ -1039,6 +1058,122 @@ function registerBotsCommands(program) {
|
|
|
1039
1058
|
await (0, database_1.closeConnection)();
|
|
1040
1059
|
}
|
|
1041
1060
|
});
|
|
1061
|
+
// ── semo bots skill-deploy ──────────────────────────────────
|
|
1062
|
+
botsCmd
|
|
1063
|
+
.command("skill-deploy")
|
|
1064
|
+
.description("DB skill_definitions → 봇 워크스페이스 SKILL.md 역배포")
|
|
1065
|
+
.option("--bot <botId>", "특정 봇에만 배포")
|
|
1066
|
+
.option("--dry-run", "파일 쓰기 없이 계획만 출력")
|
|
1067
|
+
.option("--force", "기존 SKILL.md와 내용이 달라도 덮어쓰기")
|
|
1068
|
+
.action(async (options) => {
|
|
1069
|
+
const spinner = (0, ora_1.default)("스킬 배포 준비 중...").start();
|
|
1070
|
+
const connected = await (0, database_1.isDbConnected)();
|
|
1071
|
+
if (!connected) {
|
|
1072
|
+
spinner.fail("DB 연결 실패");
|
|
1073
|
+
await (0, database_1.closeConnection)();
|
|
1074
|
+
process.exit(1);
|
|
1075
|
+
}
|
|
1076
|
+
try {
|
|
1077
|
+
const skills = await (0, database_1.getActiveSkills)();
|
|
1078
|
+
spinner.succeed(`활성 스킬 ${skills.length}개 조회 완료`);
|
|
1079
|
+
// Filter skills that have bot_ids assigned and content
|
|
1080
|
+
const deployable = skills.filter((s) => s.bot_ids && s.bot_ids.length > 0 && s.content && s.content.trim());
|
|
1081
|
+
if (deployable.length === 0) {
|
|
1082
|
+
console.log(chalk_1.default.yellow("배포 가능한 스킬이 없습니다."));
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
const entries = [];
|
|
1086
|
+
for (const skill of deployable) {
|
|
1087
|
+
const targetBots = options.bot
|
|
1088
|
+
? skill.bot_ids.filter((b) => b === options.bot)
|
|
1089
|
+
: skill.bot_ids;
|
|
1090
|
+
for (const botId of targetBots) {
|
|
1091
|
+
const wsDir = path.join(os.homedir(), `.openclaw-${botId}`, "workspace");
|
|
1092
|
+
const skillDir = path.join(wsDir, "skills", skill.name);
|
|
1093
|
+
const filePath = path.join(skillDir, "SKILL.md");
|
|
1094
|
+
let action;
|
|
1095
|
+
if (!fs.existsSync(filePath)) {
|
|
1096
|
+
action = "CREATE";
|
|
1097
|
+
}
|
|
1098
|
+
else {
|
|
1099
|
+
const existing = fs.readFileSync(filePath, "utf-8");
|
|
1100
|
+
if (existing === skill.content) {
|
|
1101
|
+
action = "SKIP_SAME";
|
|
1102
|
+
}
|
|
1103
|
+
else if (options.force) {
|
|
1104
|
+
action = "OVERWRITE";
|
|
1105
|
+
}
|
|
1106
|
+
else {
|
|
1107
|
+
action = "SKIP_EXISTS";
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
entries.push({ skillName: skill.name, botId, action, filePath, content: skill.content });
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
// Summary table
|
|
1114
|
+
const actionColor = {
|
|
1115
|
+
CREATE: chalk_1.default.green,
|
|
1116
|
+
OVERWRITE: chalk_1.default.yellow,
|
|
1117
|
+
SKIP_SAME: chalk_1.default.gray,
|
|
1118
|
+
SKIP_EXISTS: chalk_1.default.cyan,
|
|
1119
|
+
};
|
|
1120
|
+
console.log("\n" + chalk_1.default.bold("배포 계획:"));
|
|
1121
|
+
console.log("─".repeat(70));
|
|
1122
|
+
for (const e of entries) {
|
|
1123
|
+
const tag = actionColor[e.action](e.action.padEnd(12));
|
|
1124
|
+
console.log(` ${tag} ${e.botId}/${e.skillName}`);
|
|
1125
|
+
}
|
|
1126
|
+
console.log("─".repeat(70));
|
|
1127
|
+
const creates = entries.filter((e) => e.action === "CREATE").length;
|
|
1128
|
+
const overwrites = entries.filter((e) => e.action === "OVERWRITE").length;
|
|
1129
|
+
const skipSame = entries.filter((e) => e.action === "SKIP_SAME").length;
|
|
1130
|
+
const skipExists = entries.filter((e) => e.action === "SKIP_EXISTS").length;
|
|
1131
|
+
console.log(` CREATE: ${creates} OVERWRITE: ${overwrites} 동일: ${skipSame} 스킵(--force 필요): ${skipExists}`);
|
|
1132
|
+
if (options.dryRun) {
|
|
1133
|
+
console.log(chalk_1.default.yellow("\n--dry-run: 파일 쓰기를 건너뜁니다."));
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
const toWrite = entries.filter((e) => e.action === "CREATE" || e.action === "OVERWRITE");
|
|
1137
|
+
if (toWrite.length === 0) {
|
|
1138
|
+
console.log(chalk_1.default.green("\n변경할 파일이 없습니다."));
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
// Write files
|
|
1142
|
+
const writeSpinner = (0, ora_1.default)(`SKILL.md ${toWrite.length}개 배포 중...`).start();
|
|
1143
|
+
for (const e of toWrite) {
|
|
1144
|
+
const dir = path.dirname(e.filePath);
|
|
1145
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1146
|
+
fs.writeFileSync(e.filePath, e.content, "utf-8");
|
|
1147
|
+
}
|
|
1148
|
+
writeSpinner.succeed(`SKILL.md ${toWrite.length}개 배포 완료`);
|
|
1149
|
+
// Sync workspace files for affected bots
|
|
1150
|
+
const affectedBots = [...new Set(toWrite.map((e) => e.botId))];
|
|
1151
|
+
const pool = (0, database_1.getPool)();
|
|
1152
|
+
const client = await pool.connect();
|
|
1153
|
+
try {
|
|
1154
|
+
for (const botId of affectedBots) {
|
|
1155
|
+
const wsDir = path.join(os.homedir(), `.openclaw-${botId}`, "workspace");
|
|
1156
|
+
if (fs.existsSync(wsDir)) {
|
|
1157
|
+
const syncSpinner = (0, ora_1.default)(`${botId} 워크스페이스 DB 싱크 중...`).start();
|
|
1158
|
+
const count = await syncWorkspaceFiles(client, botId, wsDir);
|
|
1159
|
+
syncSpinner.succeed(`${botId} 워크스페이스 싱크 완료 (${count}개 파일 갱신)`);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
finally {
|
|
1164
|
+
client.release();
|
|
1165
|
+
}
|
|
1166
|
+
console.log(chalk_1.default.green(`\n✔ 스킬 역배포 완료`));
|
|
1167
|
+
}
|
|
1168
|
+
catch (err) {
|
|
1169
|
+
spinner.isSpinning && spinner.fail("스킬 배포 실패");
|
|
1170
|
+
console.error(chalk_1.default.red(`❌ ${err}`));
|
|
1171
|
+
process.exit(1);
|
|
1172
|
+
}
|
|
1173
|
+
finally {
|
|
1174
|
+
await (0, database_1.closeConnection)();
|
|
1175
|
+
}
|
|
1176
|
+
});
|
|
1042
1177
|
// ── semo bots set-status ─────────────────────────────────────
|
|
1043
1178
|
botsCmd
|
|
1044
1179
|
.command("set-status <bot_id> <status>")
|
package/dist/commands/context.js
CHANGED
|
@@ -55,6 +55,7 @@ const database_1 = require("../database");
|
|
|
55
55
|
const kb_1 = require("../kb");
|
|
56
56
|
// [v4.4.0] syncSkillsToDB 제거 — semo-system/ 폐기됨, 스킬 SoT는 DB 직접 관리
|
|
57
57
|
const global_cache_1 = require("../global-cache");
|
|
58
|
+
const semo_workspace_1 = require("../semo-workspace");
|
|
58
59
|
// ============================================================
|
|
59
60
|
// Memory file mapping
|
|
60
61
|
// ============================================================
|
|
@@ -224,7 +225,21 @@ function registerContextCommands(program) {
|
|
|
224
225
|
console.log(chalk_1.default.yellow(` ⚠ 글로벌 캐시 동기화 실패 (기존 파일 유지): ${cacheErr}`));
|
|
225
226
|
}
|
|
226
227
|
}
|
|
227
|
-
// 3.
|
|
228
|
+
// 3. 봇 미러 리프레시 (DB → ~/.claude/semo/bots/)
|
|
229
|
+
const semoDir = path.join(os.homedir(), ".claude", "semo");
|
|
230
|
+
if (fs.existsSync(semoDir)) {
|
|
231
|
+
try {
|
|
232
|
+
spinner.text = "봇 미러 동기화 (~/.claude/semo/bots/)...";
|
|
233
|
+
const mirrorResult = await (0, semo_workspace_1.populateBotMirrors)();
|
|
234
|
+
if (mirrorResult.files > 0) {
|
|
235
|
+
console.log(chalk_1.default.green(` ✓ 봇 미러: ${mirrorResult.bots}개 봇, ${mirrorResult.files}개 파일`));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// 봇 미러 동기화 실패는 비치명적
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// 4. 크론잡 동기화 (local → DB)
|
|
228
243
|
try {
|
|
229
244
|
spinner.text = "크론잡 동기화...";
|
|
230
245
|
const cronResult = await syncCronJobs(pool);
|
|
@@ -235,7 +250,7 @@ function registerContextCommands(program) {
|
|
|
235
250
|
catch {
|
|
236
251
|
// 크론잡 동기화 실패는 비치명적
|
|
237
252
|
}
|
|
238
|
-
spinner.succeed("context sync 완료 —
|
|
253
|
+
spinner.succeed("context sync 완료 — 스킬/캐시/봇미러/크론잡 동기화");
|
|
239
254
|
console.log(chalk_1.default.gray(` 저장 위치: ${memDir}`));
|
|
240
255
|
}
|
|
241
256
|
catch (err) {
|
package/dist/commands/memory.js
CHANGED
|
@@ -211,6 +211,9 @@ function registerMemoryCommands(program) {
|
|
|
211
211
|
.option("--dry-run", "프리뷰만 (실제 동기화 안 함)")
|
|
212
212
|
.option("--force", "워터마크 무시, 전체 재동기화")
|
|
213
213
|
.action(async (options) => {
|
|
214
|
+
console.log(chalk_1.default.yellow.bold("\n⚠️ [DEPRECATED] semo memory sync는 semiclaw memory-escalation 크론잡으로 대체되었습니다."));
|
|
215
|
+
console.log(chalk_1.default.yellow(" 매일 06:00 자동 실행되며, LLM 기반 분류로 적절한 KB 도메인/키에 에스컬레이션합니다."));
|
|
216
|
+
console.log(chalk_1.default.yellow(" 수동 실행이 필요하면 semiclaw에게 'memory-escalation 스킬 실행' 을 지시하세요.\n"));
|
|
214
217
|
const dryRun = !!options.dryRun;
|
|
215
218
|
const force = !!options.force;
|
|
216
219
|
const minAgeDays = parseInt(options.days) || 2;
|
package/dist/database.d.ts
CHANGED
|
@@ -123,3 +123,15 @@ export declare function closeConnection(): Promise<void>;
|
|
|
123
123
|
* DB 연결 상태 확인
|
|
124
124
|
*/
|
|
125
125
|
export declare function isDbConnected(): Promise<boolean>;
|
|
126
|
+
export interface BotWorkspaceFile {
|
|
127
|
+
file_path: string;
|
|
128
|
+
content: string;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* 활성 봇 ID 목록 조회 (retired 제외)
|
|
132
|
+
*/
|
|
133
|
+
export declare function getActiveBotIds(): Promise<string[]>;
|
|
134
|
+
/**
|
|
135
|
+
* 특정 봇의 워크스페이스 파일 목록 조회 (DB 미러)
|
|
136
|
+
*/
|
|
137
|
+
export declare function getBotWorkspaceFiles(botId: string): Promise<BotWorkspaceFile[]>;
|
package/dist/database.js
CHANGED
|
@@ -58,16 +58,21 @@ exports.getDelegations = getDelegations;
|
|
|
58
58
|
exports.getProtocol = getProtocol;
|
|
59
59
|
exports.closeConnection = closeConnection;
|
|
60
60
|
exports.isDbConnected = isDbConnected;
|
|
61
|
+
exports.getActiveBotIds = getActiveBotIds;
|
|
62
|
+
exports.getBotWorkspaceFiles = getBotWorkspaceFiles;
|
|
61
63
|
const pg_1 = require("pg");
|
|
62
64
|
const fs = __importStar(require("fs"));
|
|
63
65
|
const os = __importStar(require("os"));
|
|
64
66
|
const path = __importStar(require("path"));
|
|
65
67
|
const env_parser_1 = require("./env-parser");
|
|
66
|
-
// ~/.semo
|
|
68
|
+
// ~/.claude/semo/.env 자동 로드 — LaunchAgent / Claude Code 앱 / cron 등
|
|
67
69
|
// 인터랙티브 쉘이 아닌 환경에서 환경변수를 공급한다.
|
|
68
70
|
// 이미 설정된 환경변수는 덮어쓰지 않는다 (env var > file).
|
|
71
|
+
// v4.5.0: ~/.semo.env → ~/.claude/semo/.env 이전. 구 경로 폴백 유지.
|
|
69
72
|
function loadSemoEnv() {
|
|
70
|
-
const
|
|
73
|
+
const newEnvFile = path.join(os.homedir(), ".claude", "semo", ".env");
|
|
74
|
+
const legacyEnvFile = path.join(os.homedir(), ".semo.env");
|
|
75
|
+
const envFile = fs.existsSync(newEnvFile) ? newEnvFile : legacyEnvFile;
|
|
71
76
|
if (!fs.existsSync(envFile))
|
|
72
77
|
return;
|
|
73
78
|
try {
|
|
@@ -382,3 +387,33 @@ async function closeConnection() {
|
|
|
382
387
|
async function isDbConnected() {
|
|
383
388
|
return checkDbConnection();
|
|
384
389
|
}
|
|
390
|
+
/**
|
|
391
|
+
* 활성 봇 ID 목록 조회 (retired 제외)
|
|
392
|
+
*/
|
|
393
|
+
async function getActiveBotIds() {
|
|
394
|
+
const isConnected = await checkDbConnection();
|
|
395
|
+
if (!isConnected)
|
|
396
|
+
return [];
|
|
397
|
+
try {
|
|
398
|
+
const result = await getPool().query(`SELECT bot_id FROM semo.bot_status WHERE status != 'retired' ORDER BY bot_id`);
|
|
399
|
+
return result.rows.map((r) => r.bot_id);
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
return [];
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* 특정 봇의 워크스페이스 파일 목록 조회 (DB 미러)
|
|
407
|
+
*/
|
|
408
|
+
async function getBotWorkspaceFiles(botId) {
|
|
409
|
+
const isConnected = await checkDbConnection();
|
|
410
|
+
if (!isConnected)
|
|
411
|
+
return [];
|
|
412
|
+
try {
|
|
413
|
+
const result = await getPool().query(`SELECT file_path, content FROM semo.bot_workspace_files WHERE bot_id = $1 ORDER BY file_path`, [botId]);
|
|
414
|
+
return result.rows;
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
return [];
|
|
418
|
+
}
|
|
419
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -66,6 +66,7 @@ const db_1 = require("./commands/db");
|
|
|
66
66
|
const memory_1 = require("./commands/memory");
|
|
67
67
|
const test_1 = require("./commands/test");
|
|
68
68
|
const global_cache_1 = require("./global-cache");
|
|
69
|
+
const semo_workspace_1 = require("./semo-workspace");
|
|
69
70
|
const PACKAGE_NAME = "@team-semicolon/semo-cli";
|
|
70
71
|
// package.json에서 버전 동적 로드
|
|
71
72
|
function getCliVersion() {
|
|
@@ -744,20 +745,24 @@ async function showToolsStatus() {
|
|
|
744
745
|
// === 글로벌 설정 체크 ===
|
|
745
746
|
function isGlobalSetupDone() {
|
|
746
747
|
const home = os.homedir();
|
|
747
|
-
|
|
748
|
-
fs.existsSync(path.join(home, ".
|
|
748
|
+
const hasEnv = fs.existsSync(path.join(home, ".claude", "semo", ".env")) ||
|
|
749
|
+
fs.existsSync(path.join(home, ".semo.env")); // 하위 호환
|
|
750
|
+
const hasSetup = fs.existsSync(path.join(home, ".claude", "semo", "SOUL.md")) ||
|
|
751
|
+
fs.existsSync(path.join(home, ".claude", "skills")); // 하위 호환
|
|
752
|
+
return hasEnv && hasSetup;
|
|
749
753
|
}
|
|
750
|
-
// === onboarding 명령어 (글로벌
|
|
754
|
+
// === onboarding 명령어 (글로벌 설정 — init 통합) ===
|
|
751
755
|
program
|
|
752
756
|
.command("onboarding")
|
|
753
|
-
.description("글로벌 SEMO 설정
|
|
757
|
+
.description("글로벌 SEMO 설정 — ~/.claude/semo/, skills/agents/commands")
|
|
754
758
|
.option("--credentials-gist <gistId>", "Private GitHub Gist에서 DB 접속정보 가져오기")
|
|
755
759
|
.option("-f, --force", "기존 설정 덮어쓰기")
|
|
756
760
|
.option("--skip-mcp", "MCP 설정 생략")
|
|
761
|
+
.option("--skip-bots", "봇 워크스페이스 미러 건너뛰기")
|
|
757
762
|
.action(async (options) => {
|
|
758
|
-
console.log(chalk_1.default.cyan.bold("\n🏠 SEMO
|
|
759
|
-
console.log(chalk_1.default.gray(" 대상: ~/.claude
|
|
760
|
-
// 1. ~/.semo
|
|
763
|
+
console.log(chalk_1.default.cyan.bold("\n🏠 SEMO 온보딩\n"));
|
|
764
|
+
console.log(chalk_1.default.gray(" 대상: ~/.claude/semo/ (머신당 1회)\n"));
|
|
765
|
+
// 1. ~/.claude/semo/.env DB 접속 설정
|
|
761
766
|
await setupSemoEnv(options.credentialsGist, options.force);
|
|
762
767
|
// 2. DB health check
|
|
763
768
|
const spinner = (0, ora_1.default)("DB 연결 확인 중...").start();
|
|
@@ -766,103 +771,82 @@ program
|
|
|
766
771
|
spinner.succeed("DB 연결 확인됨");
|
|
767
772
|
}
|
|
768
773
|
else {
|
|
769
|
-
spinner.warn("DB 연결 실패 —
|
|
770
|
-
console.log(chalk_1.default.gray(" ~/.semo
|
|
774
|
+
spinner.warn("DB 연결 실패 — 스킬/봇 미러 설치를 건너뜁니다");
|
|
775
|
+
console.log(chalk_1.default.gray(" ~/.claude/semo/.env를 확인하고 다시 시도하세요: semo onboarding\n"));
|
|
771
776
|
await (0, database_1.closeConnection)();
|
|
772
777
|
return;
|
|
773
778
|
}
|
|
774
|
-
// 3.
|
|
779
|
+
// 3. ~/.claude/semo/ 디렉토리 구조 생성
|
|
780
|
+
console.log(chalk_1.default.cyan("\n📂 SEMO 워크스페이스 구성 (~/.claude/semo/)"));
|
|
781
|
+
(0, semo_workspace_1.ensureSemoDir)();
|
|
782
|
+
console.log(chalk_1.default.green(" ✓ ~/.claude/semo/ 디렉토리 생성됨"));
|
|
783
|
+
// 4. 봇 워크스페이스 미러 (DB → semo/bots/)
|
|
784
|
+
if (!options.skipBots) {
|
|
785
|
+
const mirrorSpinner = (0, ora_1.default)("봇 워크스페이스 미러링 (DB → semo/bots/)...").start();
|
|
786
|
+
try {
|
|
787
|
+
const result = await (0, semo_workspace_1.populateBotMirrors)();
|
|
788
|
+
mirrorSpinner.succeed(`봇 미러 완료: ${result.bots}개 봇, ${result.files}개 파일`);
|
|
789
|
+
}
|
|
790
|
+
catch (err) {
|
|
791
|
+
mirrorSpinner.warn(`봇 미러 실패 (계속 진행): ${err}`);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
else {
|
|
795
|
+
console.log(chalk_1.default.gray(" → 봇 미러 건너뜀 (--skip-bots)"));
|
|
796
|
+
}
|
|
797
|
+
// 5. SOUL.md / MEMORY.md / USER.md 생성
|
|
798
|
+
try {
|
|
799
|
+
await (0, semo_workspace_1.generateSoulMd)();
|
|
800
|
+
console.log(chalk_1.default.green(" ✓ semo/SOUL.md 생성됨 (오케스트레이터 페르소나)"));
|
|
801
|
+
}
|
|
802
|
+
catch (err) {
|
|
803
|
+
console.log(chalk_1.default.yellow(` ⚠ SOUL.md 생성 실패: ${err}`));
|
|
804
|
+
}
|
|
805
|
+
(0, semo_workspace_1.generateMemoryMd)();
|
|
806
|
+
console.log(chalk_1.default.green(" ✓ semo/MEMORY.md 생성됨 (KB 인덱스)"));
|
|
807
|
+
(0, semo_workspace_1.generateUserMd)();
|
|
808
|
+
console.log(chalk_1.default.green(" ✓ semo/USER.md 확인됨 (사용자 프로필)"));
|
|
809
|
+
// 6. Standard 설치 (DB → ~/.claude/skills, commands, agents)
|
|
775
810
|
await setupStandardGlobal();
|
|
776
|
-
//
|
|
811
|
+
// 7. Hooks 설치
|
|
777
812
|
await setupHooks(false);
|
|
778
|
-
//
|
|
813
|
+
// 8. MCP 설정
|
|
779
814
|
if (!options.skipMcp) {
|
|
780
815
|
await setupMCP(os.homedir(), [], options.force || false);
|
|
781
816
|
}
|
|
782
|
-
//
|
|
783
|
-
|
|
817
|
+
// 9. Thin Router CLAUDE.md 생성
|
|
818
|
+
console.log(chalk_1.default.cyan("\n📄 CLAUDE.md 라우터 생성"));
|
|
819
|
+
const kbFirstBlock = await buildKbFirstBlock();
|
|
820
|
+
(0, semo_workspace_1.generateThinRouter)(kbFirstBlock);
|
|
821
|
+
console.log(chalk_1.default.green(" ✓ ~/.claude/CLAUDE.md (thin router) 생성됨"));
|
|
784
822
|
await (0, database_1.closeConnection)();
|
|
785
823
|
// 결과 요약
|
|
786
|
-
console.log(chalk_1.default.green.bold("\n✅ SEMO
|
|
824
|
+
console.log(chalk_1.default.green.bold("\n✅ SEMO 온보딩 완료!\n"));
|
|
787
825
|
console.log(chalk_1.default.cyan("설치된 구성:"));
|
|
788
|
-
console.log(chalk_1.default.gray(" ~/.semo
|
|
789
|
-
console.log(chalk_1.default.gray(" ~/.claude/
|
|
790
|
-
console.log(chalk_1.default.gray(" ~/.claude/
|
|
791
|
-
console.log(chalk_1.default.gray(" ~/.claude/
|
|
826
|
+
console.log(chalk_1.default.gray(" ~/.claude/semo/.env DB 접속정보 (권한 600)"));
|
|
827
|
+
console.log(chalk_1.default.gray(" ~/.claude/semo/SOUL.md 오케스트레이터 페르소나"));
|
|
828
|
+
console.log(chalk_1.default.gray(" ~/.claude/semo/MEMORY.md KB 접근 가이드"));
|
|
829
|
+
console.log(chalk_1.default.gray(" ~/.claude/semo/USER.md 사용자 프로필"));
|
|
830
|
+
console.log(chalk_1.default.gray(" ~/.claude/semo/bots/ 봇 워크스페이스 미러"));
|
|
831
|
+
console.log(chalk_1.default.gray(" ~/.claude/skills/ 팀 스킬 (DB 기반)"));
|
|
832
|
+
console.log(chalk_1.default.gray(" ~/.claude/commands/ 팀 커맨드 (DB 기반)"));
|
|
833
|
+
console.log(chalk_1.default.gray(" ~/.claude/agents/ 팀 에이전트 (DB 기반)"));
|
|
834
|
+
console.log(chalk_1.default.gray(" ~/.claude/CLAUDE.md Thin router + KB-First"));
|
|
792
835
|
console.log(chalk_1.default.gray(" ~/.claude/settings.local.json SessionStart/Stop 훅"));
|
|
793
|
-
console.log(chalk_1.default.gray(" ~/.claude/settings.json Claude Code 설정 (유저레벨)"));
|
|
794
836
|
console.log(chalk_1.default.cyan("\n다음 단계:"));
|
|
795
|
-
console.log(chalk_1.default.gray("
|
|
837
|
+
console.log(chalk_1.default.gray(" Claude Code에서 프로젝트를 열면 SessionStart 훅이 자동으로 sync합니다."));
|
|
796
838
|
console.log();
|
|
797
839
|
});
|
|
798
|
-
// === init 명령어 (
|
|
840
|
+
// === init 명령어 (deprecated — onboarding으로 통합됨) ===
|
|
799
841
|
program
|
|
800
842
|
.command("init")
|
|
801
|
-
.description("
|
|
802
|
-
.
|
|
803
|
-
.
|
|
804
|
-
.
|
|
805
|
-
console.log(chalk_1.default.
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
if (!isGlobalSetupDone()) {
|
|
809
|
-
console.log(chalk_1.default.yellow("⚠ 글로벌 설정이 완료되지 않았습니다."));
|
|
810
|
-
console.log(chalk_1.default.gray(" 먼저 'semo onboarding'을 실행하세요.\n"));
|
|
811
|
-
console.log(chalk_1.default.gray(" 이 머신에서 처음 SEMO를 사용하시나요?"));
|
|
812
|
-
console.log(chalk_1.default.cyan(" → semo onboarding --credentials-gist <GIST_ID>\n"));
|
|
813
|
-
process.exit(1);
|
|
814
|
-
}
|
|
815
|
-
// 1. Git 레포지토리 확인
|
|
816
|
-
const spinner = (0, ora_1.default)("Git 레포지토리 확인 중...").start();
|
|
817
|
-
try {
|
|
818
|
-
(0, child_process_1.execSync)("git rev-parse --git-dir", { cwd, stdio: "pipe" });
|
|
819
|
-
spinner.succeed("Git 레포지토리 확인됨");
|
|
820
|
-
}
|
|
821
|
-
catch {
|
|
822
|
-
spinner.fail("Git 레포지토리가 아닙니다. 'git init'을 먼저 실행하세요.");
|
|
823
|
-
process.exit(1);
|
|
824
|
-
}
|
|
825
|
-
// 2. .claude 디렉토리 생성
|
|
826
|
-
const claudeDir = path.join(cwd, ".claude");
|
|
827
|
-
if (!fs.existsSync(claudeDir)) {
|
|
828
|
-
fs.mkdirSync(claudeDir, { recursive: true });
|
|
829
|
-
console.log(chalk_1.default.green("\n✓ .claude/ 디렉토리 생성됨"));
|
|
830
|
-
}
|
|
831
|
-
// 3. 로컬 스킬 경고 (기존 프로젝트 호환)
|
|
832
|
-
const localSkillsDir = path.join(claudeDir, "skills");
|
|
833
|
-
if (fs.existsSync(localSkillsDir)) {
|
|
834
|
-
try {
|
|
835
|
-
const localSkills = fs.readdirSync(localSkillsDir).filter(f => fs.statSync(path.join(localSkillsDir, f)).isDirectory());
|
|
836
|
-
if (localSkills.length > 0) {
|
|
837
|
-
console.log(chalk_1.default.yellow(`\nℹ 프로젝트 로컬 스킬이 감지되었습니다 (${localSkills.length}개).`));
|
|
838
|
-
console.log(chalk_1.default.gray(" 글로벌 스킬(~/.claude/skills/)이 우선 적용됩니다."));
|
|
839
|
-
console.log(chalk_1.default.gray(" 로컬 스킬을 제거하려면: rm -rf .claude/skills/ .claude/commands/ .claude/agents/\n"));
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
catch {
|
|
843
|
-
// ignore
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
// 4. Context Mesh 초기화
|
|
847
|
-
await setupContextMesh(cwd);
|
|
848
|
-
// 5. CLAUDE.md 생성 (프로젝트 규칙만, 스킬 목록 없음)
|
|
849
|
-
await setupClaudeMd(cwd, [], options.force || false);
|
|
850
|
-
// 6. .gitignore 업데이트
|
|
851
|
-
if (options.gitignore !== false) {
|
|
852
|
-
updateGitignore(cwd);
|
|
853
|
-
}
|
|
854
|
-
// 완료 메시지
|
|
855
|
-
console.log(chalk_1.default.green.bold("\n✅ SEMO 프로젝트 설정 완료!\n"));
|
|
856
|
-
console.log(chalk_1.default.cyan("생성된 파일:"));
|
|
857
|
-
console.log(chalk_1.default.gray(" {cwd}/.claude/CLAUDE.md 프로젝트 규칙"));
|
|
858
|
-
console.log(chalk_1.default.gray(" {cwd}/.claude/memory/context.md 프로젝트 상태"));
|
|
859
|
-
console.log(chalk_1.default.gray(" {cwd}/.claude/memory/decisions.md ADR"));
|
|
860
|
-
console.log(chalk_1.default.gray(" {cwd}/.claude/memory/projects.md 프로젝트 맵"));
|
|
861
|
-
console.log(chalk_1.default.cyan("\n다음 단계:"));
|
|
862
|
-
console.log(chalk_1.default.gray(" 1. Claude Code에서 프로젝트 열기 (SessionStart 훅이 자동 sync)"));
|
|
863
|
-
console.log(chalk_1.default.gray(" 2. 자연어로 요청하기 (예: \"댓글 기능 구현해줘\")"));
|
|
864
|
-
console.log(chalk_1.default.gray(" 3. /SEMO:help로 도움말 확인"));
|
|
865
|
-
console.log();
|
|
843
|
+
.description("[deprecated] semo onboarding으로 통합되었습니다")
|
|
844
|
+
.action(async () => {
|
|
845
|
+
console.log(chalk_1.default.yellow("\n⚠ 'semo init'은 'semo onboarding'으로 통합되었습니다.\n"));
|
|
846
|
+
console.log(chalk_1.default.cyan(" 글로벌 설정이 필요하면:"));
|
|
847
|
+
console.log(chalk_1.default.gray(" semo onboarding\n"));
|
|
848
|
+
console.log(chalk_1.default.cyan(" 이미 온보딩을 완료했다면:"));
|
|
849
|
+
console.log(chalk_1.default.gray(" Claude Code에서 프로젝트를 열면 SessionStart 훅이 자동으로 sync합니다.\n"));
|
|
866
850
|
});
|
|
867
851
|
// === Standard 설치 (DB 기반, 글로벌 ~/.claude/) ===
|
|
868
852
|
async function setupStandardGlobal() {
|
|
@@ -1186,6 +1170,10 @@ const BASE_MCP_SERVERS = [
|
|
|
1186
1170
|
scope: "user",
|
|
1187
1171
|
},
|
|
1188
1172
|
];
|
|
1173
|
+
// === ~/.claude/semo/.env 설정 (자동 감지 → Gist → 프롬프트) ===
|
|
1174
|
+
// v4.5.0: ~/.semo.env → ~/.claude/semo/.env 이전
|
|
1175
|
+
const SEMO_ENV_PATH = path.join(os.homedir(), ".claude", "semo", ".env");
|
|
1176
|
+
const LEGACY_ENV_PATH = path.join(os.homedir(), ".semo.env");
|
|
1189
1177
|
const SEMO_CREDENTIALS = [
|
|
1190
1178
|
{
|
|
1191
1179
|
key: "DATABASE_URL",
|
|
@@ -1210,10 +1198,12 @@ const SEMO_CREDENTIALS = [
|
|
|
1210
1198
|
},
|
|
1211
1199
|
];
|
|
1212
1200
|
function writeSemoEnvFile(creds) {
|
|
1213
|
-
|
|
1201
|
+
// 디렉토리 보장
|
|
1202
|
+
fs.mkdirSync(path.dirname(SEMO_ENV_PATH), { recursive: true });
|
|
1214
1203
|
const lines = [
|
|
1215
1204
|
"# SEMO 환경변수 — 모든 컨텍스트에서 자동 로드됨",
|
|
1216
1205
|
"# (Claude Code 앱, OpenClaw LaunchAgent, cron 등)",
|
|
1206
|
+
"# 경로: ~/.claude/semo/.env (v4.5.0+)",
|
|
1217
1207
|
"",
|
|
1218
1208
|
];
|
|
1219
1209
|
// 레지스트리 키 먼저 (순서 보장)
|
|
@@ -1230,10 +1220,28 @@ function writeSemoEnvFile(creds) {
|
|
|
1230
1220
|
}
|
|
1231
1221
|
}
|
|
1232
1222
|
lines.push("");
|
|
1233
|
-
fs.writeFileSync(
|
|
1223
|
+
fs.writeFileSync(SEMO_ENV_PATH, lines.join("\n"), { mode: 0o600 });
|
|
1224
|
+
// 하위 호환 심링크: ~/.semo.env → ~/.claude/semo/.env
|
|
1225
|
+
try {
|
|
1226
|
+
if (fs.existsSync(LEGACY_ENV_PATH)) {
|
|
1227
|
+
const stat = fs.lstatSync(LEGACY_ENV_PATH);
|
|
1228
|
+
if (!stat.isSymbolicLink()) {
|
|
1229
|
+
// 기존 실파일은 백업 후 심링크로 교체
|
|
1230
|
+
fs.renameSync(LEGACY_ENV_PATH, LEGACY_ENV_PATH + ".bak");
|
|
1231
|
+
}
|
|
1232
|
+
else {
|
|
1233
|
+
fs.unlinkSync(LEGACY_ENV_PATH);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
fs.symlinkSync(SEMO_ENV_PATH, LEGACY_ENV_PATH);
|
|
1237
|
+
}
|
|
1238
|
+
catch {
|
|
1239
|
+
// 심링크 실패 시 무시 — 새 경로가 원본
|
|
1240
|
+
}
|
|
1234
1241
|
}
|
|
1235
1242
|
function readSemoEnvCreds() {
|
|
1236
|
-
|
|
1243
|
+
// 새 경로 우선, 없으면 레거시 폴백
|
|
1244
|
+
const envFile = fs.existsSync(SEMO_ENV_PATH) ? SEMO_ENV_PATH : LEGACY_ENV_PATH;
|
|
1237
1245
|
if (!fs.existsSync(envFile))
|
|
1238
1246
|
return {};
|
|
1239
1247
|
try {
|
|
@@ -1304,17 +1312,16 @@ async function setupSemoEnv(credentialsGist, force) {
|
|
|
1304
1312
|
}
|
|
1305
1313
|
}
|
|
1306
1314
|
// 5. 변경사항이 있거나 파일이 없으면 쓰기
|
|
1307
|
-
const envFile = path.join(os.homedir(), ".semo.env");
|
|
1308
1315
|
const needsWrite = force ||
|
|
1309
1316
|
hasNewKeys ||
|
|
1310
|
-
!fs.existsSync(
|
|
1317
|
+
!fs.existsSync(SEMO_ENV_PATH) ||
|
|
1311
1318
|
Object.keys(gistCreds).some((k) => !existing[k]);
|
|
1312
1319
|
if (needsWrite) {
|
|
1313
1320
|
writeSemoEnvFile(merged);
|
|
1314
|
-
console.log(chalk_1.default.green(" ✅ ~/.semo
|
|
1321
|
+
console.log(chalk_1.default.green(" ✅ ~/.claude/semo/.env 저장됨 (권한: 600)"));
|
|
1315
1322
|
}
|
|
1316
1323
|
else {
|
|
1317
|
-
console.log(chalk_1.default.gray(" ~/.semo
|
|
1324
|
+
console.log(chalk_1.default.gray(" ~/.claude/semo/.env 변경 없음"));
|
|
1318
1325
|
}
|
|
1319
1326
|
}
|
|
1320
1327
|
// === Claude MCP 서버 존재 여부 확인 ===
|
|
@@ -1441,30 +1448,7 @@ KB에 없으면: "KB에 해당 정보가 없습니다. 알려주시면 등록하
|
|
|
1441
1448
|
**금지:** "알겠습니다/기억하겠습니다"만 하고 KB에 쓰지 않는 것.
|
|
1442
1449
|
`;
|
|
1443
1450
|
}
|
|
1444
|
-
|
|
1445
|
-
const globalClaudeMd = path.join(os.homedir(), ".claude", "CLAUDE.md");
|
|
1446
|
-
const kbFirstBlock = await buildKbFirstBlock();
|
|
1447
|
-
if (fs.existsSync(globalClaudeMd)) {
|
|
1448
|
-
const content = fs.readFileSync(globalClaudeMd, "utf-8");
|
|
1449
|
-
if (content.includes(KB_FIRST_SECTION_MARKER)) {
|
|
1450
|
-
// 기존 섹션 교체
|
|
1451
|
-
const regex = new RegExp(`\\n${KB_FIRST_SECTION_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?(?=\\n## |$)`, "m");
|
|
1452
|
-
const updated = content.replace(regex, kbFirstBlock);
|
|
1453
|
-
fs.writeFileSync(globalClaudeMd, updated);
|
|
1454
|
-
console.log(chalk_1.default.gray(" ~/.claude/CLAUDE.md KB-First 규칙 업데이트됨"));
|
|
1455
|
-
}
|
|
1456
|
-
else {
|
|
1457
|
-
// 끝에 추가
|
|
1458
|
-
fs.writeFileSync(globalClaudeMd, content.trimEnd() + "\n" + kbFirstBlock);
|
|
1459
|
-
console.log(chalk_1.default.green(" ✓ ~/.claude/CLAUDE.md에 KB-First 규칙 추가됨"));
|
|
1460
|
-
}
|
|
1461
|
-
}
|
|
1462
|
-
else {
|
|
1463
|
-
// 파일 없으면 생성
|
|
1464
|
-
fs.writeFileSync(globalClaudeMd, kbFirstBlock.trim() + "\n");
|
|
1465
|
-
console.log(chalk_1.default.green(" ✓ ~/.claude/CLAUDE.md 생성됨 (KB-First 규칙)"));
|
|
1466
|
-
}
|
|
1467
|
-
}
|
|
1451
|
+
// injectKbFirstToGlobalClaudeMd 제거 — generateThinRouter()로 대체 (semo-workspace.ts)
|
|
1468
1452
|
// === MCP 설정 ===
|
|
1469
1453
|
async function setupMCP(cwd, _extensions, force) {
|
|
1470
1454
|
console.log(chalk_1.default.cyan("\n🔧 Black Box 설정 (MCP Server)"));
|
|
@@ -1530,44 +1514,7 @@ async function setupMCP(cwd, _extensions, force) {
|
|
|
1530
1514
|
console.log();
|
|
1531
1515
|
}
|
|
1532
1516
|
}
|
|
1533
|
-
//
|
|
1534
|
-
function updateGitignore(cwd) {
|
|
1535
|
-
console.log(chalk_1.default.cyan("\n📝 .gitignore 업데이트"));
|
|
1536
|
-
const gitignorePath = path.join(cwd, ".gitignore");
|
|
1537
|
-
const semoIgnoreBlock = `
|
|
1538
|
-
# === SEMO ===
|
|
1539
|
-
.claude/*
|
|
1540
|
-
!.claude/memory/
|
|
1541
|
-
!.claude/memory/**
|
|
1542
|
-
semo-system/
|
|
1543
|
-
`;
|
|
1544
|
-
if (fs.existsSync(gitignorePath)) {
|
|
1545
|
-
let content = fs.readFileSync(gitignorePath, "utf-8");
|
|
1546
|
-
// 이미 SEMO 블록이 있으면 스킵
|
|
1547
|
-
if (content.includes("# === SEMO ===")) {
|
|
1548
|
-
console.log(chalk_1.default.gray(" → SEMO 블록 이미 존재 (건너뜀)"));
|
|
1549
|
-
return;
|
|
1550
|
-
}
|
|
1551
|
-
// 기존에 .claude/ 또는 .claude 전체 무시 항목 제거 (memory/ 접근을 위해)
|
|
1552
|
-
const lines = content.split("\n");
|
|
1553
|
-
const filtered = lines.filter(line => {
|
|
1554
|
-
const trimmed = line.trim();
|
|
1555
|
-
return trimmed !== ".claude" && trimmed !== ".claude/" && trimmed !== ".claude/**";
|
|
1556
|
-
});
|
|
1557
|
-
if (filtered.length !== lines.length) {
|
|
1558
|
-
content = filtered.join("\n");
|
|
1559
|
-
console.log(chalk_1.default.gray(" → 기존 .claude 무시 항목 제거됨 (memory/ 접근 허용)"));
|
|
1560
|
-
}
|
|
1561
|
-
// 기존 파일에 추가
|
|
1562
|
-
fs.writeFileSync(gitignorePath, content + semoIgnoreBlock);
|
|
1563
|
-
console.log(chalk_1.default.green("✓ .gitignore에 SEMO 규칙 추가됨"));
|
|
1564
|
-
}
|
|
1565
|
-
else {
|
|
1566
|
-
// 새로 생성
|
|
1567
|
-
fs.writeFileSync(gitignorePath, semoIgnoreBlock.trim() + "\n");
|
|
1568
|
-
console.log(chalk_1.default.green("✓ .gitignore 생성됨 (SEMO 규칙 포함)"));
|
|
1569
|
-
}
|
|
1570
|
-
}
|
|
1517
|
+
// updateGitignore 제거 — init 통합으로 프로젝트별 .gitignore 수정 불필요
|
|
1571
1518
|
// === Hooks 설치/업데이트 ===
|
|
1572
1519
|
async function setupHooks(isUpdate = false) {
|
|
1573
1520
|
const action = isUpdate ? "업데이트" : "설치";
|
|
@@ -1583,7 +1530,7 @@ async function setupHooks(isUpdate = false) {
|
|
|
1583
1530
|
hooks: [
|
|
1584
1531
|
{
|
|
1585
1532
|
type: "command",
|
|
1586
|
-
command: ". ~/.semo.env 2>/dev/null; semo context sync 2>/dev/null || true",
|
|
1533
|
+
command: ". ~/.claude/semo/.env 2>/dev/null || . ~/.semo.env 2>/dev/null; semo context sync 2>/dev/null || true",
|
|
1587
1534
|
timeout: 30,
|
|
1588
1535
|
},
|
|
1589
1536
|
],
|
|
@@ -1595,7 +1542,7 @@ async function setupHooks(isUpdate = false) {
|
|
|
1595
1542
|
hooks: [
|
|
1596
1543
|
{
|
|
1597
1544
|
type: "command",
|
|
1598
|
-
command: ". ~/.semo.env 2>/dev/null; semo context push 2>/dev/null || true",
|
|
1545
|
+
command: ". ~/.claude/semo/.env 2>/dev/null || . ~/.semo.env 2>/dev/null; semo context push 2>/dev/null || true",
|
|
1599
1546
|
timeout: 30,
|
|
1600
1547
|
},
|
|
1601
1548
|
],
|
|
@@ -1961,7 +1908,7 @@ program
|
|
|
1961
1908
|
console.log(chalk_1.default.gray(" 대상: ~/.claude/skills, commands, agents (DB 최신)\n"));
|
|
1962
1909
|
const connected = await (0, database_1.isDbConnected)();
|
|
1963
1910
|
if (!connected) {
|
|
1964
|
-
console.log(chalk_1.default.red(" DB 연결 실패 — ~/.semo
|
|
1911
|
+
console.log(chalk_1.default.red(" DB 연결 실패 — ~/.claude/semo/.env를 확인하세요."));
|
|
1965
1912
|
await (0, database_1.closeConnection)();
|
|
1966
1913
|
process.exit(1);
|
|
1967
1914
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* semo-workspace — ~/.claude/semo/ 디렉토리 관리
|
|
3
|
+
*
|
|
4
|
+
* OpenClaw 봇 워크스페이스 스키마를 로컬에 미러링하여
|
|
5
|
+
* 로컬 Claude Code 세션이 봇 환경과 동형(isomorphic) 구조로 동작하게 한다.
|
|
6
|
+
*
|
|
7
|
+
* v4.5.0: onboarding/init 통합 — 글로벌 단일 설정
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* ~/.claude/semo/ 디렉토리 트리 생성
|
|
11
|
+
*/
|
|
12
|
+
export declare function ensureSemoDir(): void;
|
|
13
|
+
/**
|
|
14
|
+
* DB bot_workspace_files → ~/.claude/semo/bots/{botId}/ 미러링
|
|
15
|
+
*
|
|
16
|
+
* 1. getActiveBotIds() → 봇 목록
|
|
17
|
+
* 2. 봇별 getBotWorkspaceFiles() → 파일 쓰기
|
|
18
|
+
* 3. DB에 없는 orphan 파일 정리
|
|
19
|
+
*/
|
|
20
|
+
export declare function populateBotMirrors(): Promise<{
|
|
21
|
+
bots: number;
|
|
22
|
+
files: number;
|
|
23
|
+
}>;
|
|
24
|
+
/**
|
|
25
|
+
* 로컬 오케스트레이터 SOUL.md 생성
|
|
26
|
+
* (봇 SOUL.md와 동일 포맷 — bot_workspace_standard 준수)
|
|
27
|
+
*/
|
|
28
|
+
export declare function generateSoulMd(): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* KB 접근 가이드 인덱스
|
|
31
|
+
*/
|
|
32
|
+
export declare function generateMemoryMd(): void;
|
|
33
|
+
/**
|
|
34
|
+
* 사용자 프로필 플레이스홀더
|
|
35
|
+
*/
|
|
36
|
+
export declare function generateUserMd(): void;
|
|
37
|
+
/**
|
|
38
|
+
* ~/.claude/CLAUDE.md를 얇은 라우터로 생성/교체
|
|
39
|
+
*
|
|
40
|
+
* @param kbFirstBlock - buildKbFirstBlock()의 결과 (index.ts에서 전달)
|
|
41
|
+
*/
|
|
42
|
+
export declare function generateThinRouter(kbFirstBlock: string): void;
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* semo-workspace — ~/.claude/semo/ 디렉토리 관리
|
|
4
|
+
*
|
|
5
|
+
* OpenClaw 봇 워크스페이스 스키마를 로컬에 미러링하여
|
|
6
|
+
* 로컬 Claude Code 세션이 봇 환경과 동형(isomorphic) 구조로 동작하게 한다.
|
|
7
|
+
*
|
|
8
|
+
* v4.5.0: onboarding/init 통합 — 글로벌 단일 설정
|
|
9
|
+
*/
|
|
10
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
13
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
14
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
15
|
+
}
|
|
16
|
+
Object.defineProperty(o, k2, desc);
|
|
17
|
+
}) : (function(o, m, k, k2) {
|
|
18
|
+
if (k2 === undefined) k2 = k;
|
|
19
|
+
o[k2] = m[k];
|
|
20
|
+
}));
|
|
21
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
22
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
23
|
+
}) : function(o, v) {
|
|
24
|
+
o["default"] = v;
|
|
25
|
+
});
|
|
26
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
27
|
+
var ownKeys = function(o) {
|
|
28
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
29
|
+
var ar = [];
|
|
30
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
31
|
+
return ar;
|
|
32
|
+
};
|
|
33
|
+
return ownKeys(o);
|
|
34
|
+
};
|
|
35
|
+
return function (mod) {
|
|
36
|
+
if (mod && mod.__esModule) return mod;
|
|
37
|
+
var result = {};
|
|
38
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
39
|
+
__setModuleDefault(result, mod);
|
|
40
|
+
return result;
|
|
41
|
+
};
|
|
42
|
+
})();
|
|
43
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
44
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
45
|
+
};
|
|
46
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
47
|
+
exports.ensureSemoDir = ensureSemoDir;
|
|
48
|
+
exports.populateBotMirrors = populateBotMirrors;
|
|
49
|
+
exports.generateSoulMd = generateSoulMd;
|
|
50
|
+
exports.generateMemoryMd = generateMemoryMd;
|
|
51
|
+
exports.generateUserMd = generateUserMd;
|
|
52
|
+
exports.generateThinRouter = generateThinRouter;
|
|
53
|
+
const fs = __importStar(require("fs"));
|
|
54
|
+
const path = __importStar(require("path"));
|
|
55
|
+
const os = __importStar(require("os"));
|
|
56
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
57
|
+
const database_1 = require("./database");
|
|
58
|
+
// ============================================================
|
|
59
|
+
// Constants
|
|
60
|
+
// ============================================================
|
|
61
|
+
const SEMO_DIR = path.join(os.homedir(), ".claude", "semo");
|
|
62
|
+
const BOTS_DIR = path.join(SEMO_DIR, "bots");
|
|
63
|
+
function getCliVersion() {
|
|
64
|
+
try {
|
|
65
|
+
const pkgPath = path.join(__dirname, "..", "package.json");
|
|
66
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
67
|
+
return pkg.version || "unknown";
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return "unknown";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// ============================================================
|
|
74
|
+
// Directory Setup
|
|
75
|
+
// ============================================================
|
|
76
|
+
/**
|
|
77
|
+
* ~/.claude/semo/ 디렉토리 트리 생성
|
|
78
|
+
*/
|
|
79
|
+
function ensureSemoDir() {
|
|
80
|
+
const dirs = [SEMO_DIR, BOTS_DIR];
|
|
81
|
+
for (const dir of dirs) {
|
|
82
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// ============================================================
|
|
86
|
+
// Bot Mirrors
|
|
87
|
+
// ============================================================
|
|
88
|
+
/**
|
|
89
|
+
* DB bot_workspace_files → ~/.claude/semo/bots/{botId}/ 미러링
|
|
90
|
+
*
|
|
91
|
+
* 1. getActiveBotIds() → 봇 목록
|
|
92
|
+
* 2. 봇별 getBotWorkspaceFiles() → 파일 쓰기
|
|
93
|
+
* 3. DB에 없는 orphan 파일 정리
|
|
94
|
+
*/
|
|
95
|
+
async function populateBotMirrors() {
|
|
96
|
+
const botIds = await (0, database_1.getActiveBotIds)();
|
|
97
|
+
if (botIds.length === 0)
|
|
98
|
+
return { bots: 0, files: 0 };
|
|
99
|
+
let totalFiles = 0;
|
|
100
|
+
for (const botId of botIds) {
|
|
101
|
+
const botDir = path.join(BOTS_DIR, botId);
|
|
102
|
+
const files = await (0, database_1.getBotWorkspaceFiles)(botId);
|
|
103
|
+
if (files.length === 0)
|
|
104
|
+
continue;
|
|
105
|
+
// 쓰기
|
|
106
|
+
const writtenPaths = new Set();
|
|
107
|
+
for (const file of files) {
|
|
108
|
+
const filePath = path.join(botDir, file.file_path);
|
|
109
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
110
|
+
fs.writeFileSync(filePath, file.content);
|
|
111
|
+
writtenPaths.add(file.file_path);
|
|
112
|
+
totalFiles++;
|
|
113
|
+
}
|
|
114
|
+
// Orphan 정리: DB에 없는 로컬 파일 삭제
|
|
115
|
+
if (fs.existsSync(botDir)) {
|
|
116
|
+
cleanOrphans(botDir, botDir, writtenPaths);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// 봇 디렉토리 중 DB에 없는 것 정리
|
|
120
|
+
if (fs.existsSync(BOTS_DIR)) {
|
|
121
|
+
const localBotDirs = fs.readdirSync(BOTS_DIR).filter(f => fs.statSync(path.join(BOTS_DIR, f)).isDirectory());
|
|
122
|
+
for (const dir of localBotDirs) {
|
|
123
|
+
if (!botIds.includes(dir)) {
|
|
124
|
+
fs.rmSync(path.join(BOTS_DIR, dir), { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { bots: botIds.length, files: totalFiles };
|
|
129
|
+
}
|
|
130
|
+
function cleanOrphans(baseDir, currentDir, validPaths) {
|
|
131
|
+
if (!fs.existsSync(currentDir))
|
|
132
|
+
return;
|
|
133
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
134
|
+
for (const entry of entries) {
|
|
135
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
136
|
+
const relativePath = path.relative(baseDir, fullPath);
|
|
137
|
+
if (entry.isDirectory()) {
|
|
138
|
+
cleanOrphans(baseDir, fullPath, validPaths);
|
|
139
|
+
// 빈 디렉토리 정리
|
|
140
|
+
try {
|
|
141
|
+
const remaining = fs.readdirSync(fullPath);
|
|
142
|
+
if (remaining.length === 0)
|
|
143
|
+
fs.rmdirSync(fullPath);
|
|
144
|
+
}
|
|
145
|
+
catch { /* ignore */ }
|
|
146
|
+
}
|
|
147
|
+
else if (!validPaths.has(relativePath)) {
|
|
148
|
+
fs.unlinkSync(fullPath);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// ============================================================
|
|
153
|
+
// SOUL.md / MEMORY.md / USER.md Generation
|
|
154
|
+
// ============================================================
|
|
155
|
+
/**
|
|
156
|
+
* 로컬 오케스트레이터 SOUL.md 생성
|
|
157
|
+
* (봇 SOUL.md와 동일 포맷 — bot_workspace_standard 준수)
|
|
158
|
+
*/
|
|
159
|
+
async function generateSoulMd() {
|
|
160
|
+
const version = getCliVersion();
|
|
161
|
+
// 봇 로스터 생성
|
|
162
|
+
let botRoster = "";
|
|
163
|
+
try {
|
|
164
|
+
const pool = (0, database_1.getPool)();
|
|
165
|
+
const result = await pool.query(`SELECT bot_id, name, emoji, role, status
|
|
166
|
+
FROM semo.bot_status
|
|
167
|
+
WHERE status != 'retired'
|
|
168
|
+
ORDER BY bot_id`);
|
|
169
|
+
if (result.rows.length > 0) {
|
|
170
|
+
botRoster = "| Bot | Name | Role | Status |\n|-----|------|------|--------|\n";
|
|
171
|
+
for (const row of result.rows) {
|
|
172
|
+
botRoster += `| ${row.emoji || ""} ${row.bot_id} | ${row.name || row.bot_id} | ${row.role || "-"} | ${row.status || "-"} |\n`;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
botRoster = "_DB 연결 실패 — 봇 정보를 가져올 수 없습니다._\n";
|
|
178
|
+
}
|
|
179
|
+
// 위임 매트릭스
|
|
180
|
+
let delegationMatrix = "";
|
|
181
|
+
try {
|
|
182
|
+
const delegations = await (0, database_1.getDelegations)();
|
|
183
|
+
if (delegations.length > 0) {
|
|
184
|
+
delegationMatrix = "\n## Delegation Matrix\n\n";
|
|
185
|
+
delegationMatrix += "| From | To | Type | Domains |\n|------|-----|------|--------|\n";
|
|
186
|
+
for (const d of delegations) {
|
|
187
|
+
delegationMatrix += `| ${d.from_bot_id} | ${d.to_bot_id} | ${d.delegation_type} | ${d.domains.join(", ")} |\n`;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch { /* ignore */ }
|
|
192
|
+
const content = `# SEMO Local Orchestrator — SOUL
|
|
193
|
+
|
|
194
|
+
> v${version} | Generated by \`semo onboarding\`
|
|
195
|
+
|
|
196
|
+
## Identity
|
|
197
|
+
|
|
198
|
+
- **Name**: SEMO Local Session
|
|
199
|
+
- **Role**: Human-AI orchestration interface for Semicolon team
|
|
200
|
+
- **Type**: Local Claude Code session (not an OpenClaw bot)
|
|
201
|
+
|
|
202
|
+
## Mission
|
|
203
|
+
|
|
204
|
+
사용자의 작업 요청을 적절한 봇 에이전트에 위임하거나 직접 처리한다.
|
|
205
|
+
로컬 세션, KB, 봇 팀 간 컨텍스트 동기화를 유지한다.
|
|
206
|
+
|
|
207
|
+
## Bot Roster
|
|
208
|
+
|
|
209
|
+
${botRoster}
|
|
210
|
+
${delegationMatrix}
|
|
211
|
+
## Operating Procedures
|
|
212
|
+
|
|
213
|
+
1. **KB-First**: 도메인 질문은 항상 \`semo kb search/get\`으로 KB 조회 후 답변
|
|
214
|
+
2. **SoT Discipline**: 봇 목록/도메인 구조/워크스페이스 규칙을 하드코딩하지 않음
|
|
215
|
+
3. **3-Party Sync**: 모든 변경은 DB ↔ KB ↔ 로컬 파일 영향을 고려
|
|
216
|
+
|
|
217
|
+
## Constraints
|
|
218
|
+
|
|
219
|
+
- \`~/.claude/semo/bots/\` 내부 파일은 DB 미러이므로 직접 수정 금지 (sync 시 덮어씀)
|
|
220
|
+
- 봇 에이전트 호출 시 \`~/.claude/agents/\`의 정의를 사용
|
|
221
|
+
- 스킬 실행 시 \`~/.claude/skills/\`의 정의를 사용
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
*Last updated: ${new Date().toISOString().split("T")[0]}*
|
|
226
|
+
`;
|
|
227
|
+
fs.writeFileSync(path.join(SEMO_DIR, "SOUL.md"), content);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* KB 접근 가이드 인덱스
|
|
231
|
+
*/
|
|
232
|
+
function generateMemoryMd() {
|
|
233
|
+
const content = `# SEMO Memory Index
|
|
234
|
+
|
|
235
|
+
> KB(Knowledge Base)는 Single Source of Truth입니다.
|
|
236
|
+
> 이 파일은 KB 접근 가이드이며, 실제 데이터는 DB에 있습니다.
|
|
237
|
+
|
|
238
|
+
## KB 조회 명령어
|
|
239
|
+
|
|
240
|
+
| 명령어 | 설명 |
|
|
241
|
+
|--------|------|
|
|
242
|
+
| \`semo kb search "쿼리"\` | 벡터+텍스트 하이브리드 검색 |
|
|
243
|
+
| \`semo kb get <domain> <key> [sub_key]\` | domain+key 정확 조회 |
|
|
244
|
+
| \`semo kb list --domain <domain>\` | 도메인별 엔트리 목록 |
|
|
245
|
+
| \`semo kb upsert <domain> <key> [sub_key] --content "내용"\` | KB 항목 쓰기 |
|
|
246
|
+
| \`semo kb ontology --action <action>\` | 온톨로지 조회 |
|
|
247
|
+
|
|
248
|
+
## 봇 워크스페이스 미러
|
|
249
|
+
|
|
250
|
+
\`~/.claude/semo/bots/\` 디렉토리에 각 봇의 워크스페이스 파일이 미러링됩니다.
|
|
251
|
+
이 파일들은 \`semo context sync\` 시 DB에서 자동 갱신됩니다.
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
*Auto-generated by semo onboarding*
|
|
256
|
+
`;
|
|
257
|
+
fs.writeFileSync(path.join(SEMO_DIR, "MEMORY.md"), content);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* 사용자 프로필 플레이스홀더
|
|
261
|
+
*/
|
|
262
|
+
function generateUserMd() {
|
|
263
|
+
const userMdPath = path.join(SEMO_DIR, "USER.md");
|
|
264
|
+
// 이미 존재하면 덮어쓰지 않음 (사용자가 커스텀할 수 있음)
|
|
265
|
+
if (fs.existsSync(userMdPath))
|
|
266
|
+
return;
|
|
267
|
+
const content = `# User Profile
|
|
268
|
+
|
|
269
|
+
> 이 파일은 사용자 프로필입니다.
|
|
270
|
+
> \`semo kb get semicolon team/{name}\`으로 KB에서 가져오거나 직접 수정하세요.
|
|
271
|
+
|
|
272
|
+
## 기본 정보
|
|
273
|
+
|
|
274
|
+
| 항목 | 값 |
|
|
275
|
+
|------|-----|
|
|
276
|
+
| **이름** | _설정 필요_ |
|
|
277
|
+
| **역할** | _설정 필요_ |
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
*수동 편집 가능 — semo onboarding이 덮어쓰지 않습니다*
|
|
282
|
+
`;
|
|
283
|
+
fs.writeFileSync(userMdPath, content);
|
|
284
|
+
}
|
|
285
|
+
// ============================================================
|
|
286
|
+
// Thin Router (CLAUDE.md)
|
|
287
|
+
// ============================================================
|
|
288
|
+
/**
|
|
289
|
+
* ~/.claude/CLAUDE.md를 얇은 라우터로 생성/교체
|
|
290
|
+
*
|
|
291
|
+
* @param kbFirstBlock - buildKbFirstBlock()의 결과 (index.ts에서 전달)
|
|
292
|
+
*/
|
|
293
|
+
function generateThinRouter(kbFirstBlock) {
|
|
294
|
+
const version = getCliVersion();
|
|
295
|
+
const globalClaudeMd = path.join(os.homedir(), ".claude", "CLAUDE.md");
|
|
296
|
+
const content = `# Global Claude Code Configuration
|
|
297
|
+
|
|
298
|
+
> SEMO (Semicolon Orchestrate) v${version} installed.
|
|
299
|
+
> Orchestrator profile: \`~/.claude/semo/SOUL.md\`
|
|
300
|
+
> Bot roster & workspaces: \`~/.claude/semo/bots/\`
|
|
301
|
+
|
|
302
|
+
## cmux Environment
|
|
303
|
+
|
|
304
|
+
이 터미널은 **cmux** (Ghostty 기반 macOS 네이티브 터미널 멀티플렉서)에서 실행 중이다.
|
|
305
|
+
사용자는 여러 프로젝트를 동시에 멀티태스킹하며, 각 워크스페이스가 별도 프로젝트에 대응한다.
|
|
306
|
+
|
|
307
|
+
### cmux CLI 명령어
|
|
308
|
+
|
|
309
|
+
| 명령어 | 설명 |
|
|
310
|
+
|--------|------|
|
|
311
|
+
| \`cmux identify\` | 현재 워크스페이스/서피스/패인 식별 |
|
|
312
|
+
| \`cmux list-workspaces\` | 열린 워크스페이스 목록 |
|
|
313
|
+
| \`cmux tree [--workspace <ref>]\` | 워크스페이스 내 패인/서피스/탭 구조 |
|
|
314
|
+
| \`cmux read-screen --workspace <ref> [--surface <ref>] [--lines N]\` | 다른 탭/패인 화면 읽기 |
|
|
315
|
+
| \`cmux send --workspace <ref> <text>\` | 다른 탭에 텍스트 전송 |
|
|
316
|
+
|
|
317
|
+
### 활용 가이드라인
|
|
318
|
+
|
|
319
|
+
- 다른 워크스페이스에서 실행 중인 빌드/테스트 상태를 \`cmux read-screen\`으로 확인 가능
|
|
320
|
+
- 에러 로그나 서버 출력을 다른 패인에서 읽을 수 있음
|
|
321
|
+
- \`cmux tree\`로 현재 열려 있는 전체 작업 환경을 파악할 수 있음
|
|
322
|
+
- 사용자가 "다른 탭에서 뭐 돌아가고 있어?" 같은 질문을 하면 cmux 명령어로 확인
|
|
323
|
+
|
|
324
|
+
${kbFirstBlock}
|
|
325
|
+
`;
|
|
326
|
+
// 기존 파일 백업 (마이그레이션)
|
|
327
|
+
if (fs.existsSync(globalClaudeMd)) {
|
|
328
|
+
const existing = fs.readFileSync(globalClaudeMd, "utf-8");
|
|
329
|
+
// 이미 thin router인지 확인
|
|
330
|
+
if (!existing.includes("Orchestrator profile:")) {
|
|
331
|
+
const backupPath = globalClaudeMd + ".bak";
|
|
332
|
+
fs.writeFileSync(backupPath, existing);
|
|
333
|
+
console.log(chalk_1.default.gray(` 기존 CLAUDE.md 백업: ${backupPath}`));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
fs.writeFileSync(globalClaudeMd, content);
|
|
337
|
+
}
|
|
338
|
+
// .env는 이제 ~/.claude/semo/.env에 직접 저장됨 (index.ts writeSemoEnvFile)
|
|
339
|
+
// setupConfigEnvLink 제거 — v4.5.0
|