projectops 3.0.194 → 3.0.195
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/README.md +1 -1
- package/package.json +1 -1
- package/src/commands/interactive.js +10 -7
- package/src/commands/skills.js +82 -0
- package/src/core/ide/adapter.js +36 -0
- package/src/core/ide/adapters/claude.js +94 -0
- package/src/core/ide/adapters/codex.js +49 -0
- package/src/core/ide/adapters/cursor.js +76 -0
- package/src/core/ide/adapters/gemini.js +34 -0
- package/src/core/ide/adapters/pi-common.js +63 -0
- package/src/core/ide/adapters/pi-harness.js +48 -0
- package/src/core/ide/adapters/pi.js +45 -0
- package/src/core/ide/registry.js +27 -0
- package/src/core/ide/runner.js +59 -0
- package/src/core/ide/util.js +18 -0
- package/src/index.js +12 -3
- package/src/ui/skills-prompts.js +37 -0
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -10,29 +10,32 @@ import { runFull } from "./full.js";
|
|
|
10
10
|
import { runVersion } from "./version.js";
|
|
11
11
|
import { runWorkflows } from "./workflows.js";
|
|
12
12
|
import { runIssues } from "./issues.js";
|
|
13
|
+
import { runSkills } from "./skills.js";
|
|
13
14
|
import * as prompts from "../ui/prompts.js";
|
|
14
15
|
|
|
15
16
|
const CANCEL = prompts.CANCEL;
|
|
16
17
|
|
|
17
18
|
// io 기본값 = 실제 prompts. 테스트는 스텁 io 주입.
|
|
18
|
-
|
|
19
|
+
// skills = runSkills 주입 지점(테스트가 실제 IDE CLI를 안 건드리게). 기본은 실제 runSkills.
|
|
20
|
+
export async function runInteractive(baseCtx, { cwd = process.cwd(), source = { type: "git" }, clock, io = prompts, skills = runSkills } = {}) {
|
|
19
21
|
io.intro?.("projectops — 대화형 통합 마법사");
|
|
20
22
|
|
|
21
23
|
// 1) 모드 선택
|
|
22
24
|
const mode = await io.selectMode();
|
|
23
25
|
if (mode === CANCEL || mode == null) { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
|
|
24
26
|
|
|
25
|
-
// skills는 SP2-D 예정
|
|
26
|
-
if (mode === "skills") {
|
|
27
|
-
io.note?.("AI 스킬 설치는 아직 준비 중입니다 (곧 제공). 기존 template_integrator를 사용하세요.", "안내");
|
|
28
|
-
return 1;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
27
|
const tempDir = join(cwd, PATHS.tempDir);
|
|
32
28
|
try {
|
|
33
29
|
acquireTemplate({ tempDir, source });
|
|
34
30
|
const templateVersion = readTemplateVersion(tempDir);
|
|
35
31
|
|
|
32
|
+
// skills 모드 — IDE 스킬 설치 (템플릿 통합 없음). 대화형으로 실행.
|
|
33
|
+
if (mode === "skills") {
|
|
34
|
+
await skills({ templateVersion, tempDir, interactive: true });
|
|
35
|
+
io.outro?.("AI 스킬 설치를 마쳤습니다.");
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
36
39
|
// issues 모드는 정보 수집 없이 바로 실행
|
|
37
40
|
if (mode === "issues") {
|
|
38
41
|
const ctx = createContext({ ...baseCtx, mode, force: true });
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// IDE Skills 설치 오케스트레이터 (.sh offer_ide_tools_install 등가).
|
|
2
|
+
// 레지스트리 기반 — 개별 IDE를 이름으로 알지 않고 ADAPTERS 배열만 순회한다.
|
|
3
|
+
// 새 IDE는 adapters/ 에 파일 추가 + registry 등록만으로 여기 수정 없이 동작한다.
|
|
4
|
+
import { ADAPTERS } from "../core/ide/registry.js";
|
|
5
|
+
import { versionTag } from "../core/ide/util.js";
|
|
6
|
+
import { defaultIo } from "../core/ide/runner.js";
|
|
7
|
+
import * as skillsPrompts from "../ui/skills-prompts.js";
|
|
8
|
+
|
|
9
|
+
// 상태 수집 → [{adapter, status}]. optional 어댑터는 감지 불가(cliMissing)면 목록에서 제외.
|
|
10
|
+
export function collectStatuses(io) {
|
|
11
|
+
const rows = [];
|
|
12
|
+
for (const a of ADAPTERS) {
|
|
13
|
+
const status = safe(() => a.detect(io), { installed: false, version: null, cliMissing: true });
|
|
14
|
+
if (a.optional && status.cliMissing) continue; // harness 등 조건부 항목은 불가 시 숨김
|
|
15
|
+
rows.push({ adapter: a, status });
|
|
16
|
+
}
|
|
17
|
+
return rows;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 상태 표시 문자열 목록 (표시용, 순수).
|
|
21
|
+
export function formatStatuses(rows, templateVersion) {
|
|
22
|
+
return rows.map(({ adapter, status }) => {
|
|
23
|
+
const label = adapter.label.padEnd(12);
|
|
24
|
+
if (status.cliMissing) return `${label}: ${status.note || "skill 미설치 (CLI 없음)"}`;
|
|
25
|
+
if (status.installed) {
|
|
26
|
+
const v = status.version ? ` (v${status.version})` : "";
|
|
27
|
+
return `${label}: skill 설치됨${v}${versionTag(status.version, templateVersion)}`;
|
|
28
|
+
}
|
|
29
|
+
return `${label}: ${status.note || "skill 미설치"}`;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 대화형/비대화형 공통 진입. opts: {io, templateVersion, sourceSkillsDir, tempDir, interactive, ui}
|
|
34
|
+
export async function runSkills(opts = {}) {
|
|
35
|
+
const io = opts.io || defaultIo();
|
|
36
|
+
const ui = opts.ui || skillsPrompts;
|
|
37
|
+
const ctx = { templateVersion: opts.templateVersion || "", sourceSkillsDir: opts.sourceSkillsDir, tempDir: opts.tempDir };
|
|
38
|
+
|
|
39
|
+
const rows = collectStatuses(io);
|
|
40
|
+
io.log("");
|
|
41
|
+
io.log("── IDE Skills 현재 상태 ──");
|
|
42
|
+
for (const line of formatStatuses(rows, ctx.templateVersion)) io.log(line);
|
|
43
|
+
io.log("");
|
|
44
|
+
|
|
45
|
+
// 비대화형: 감지된(=관리 가능) 모든 어댑터에 apply(설치/업데이트) 순차 실행.
|
|
46
|
+
if (!opts.interactive) {
|
|
47
|
+
for (const { adapter } of rows) safe(() => adapter.apply(io, ctx), false);
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 대화형: 동작 선택 → IDE 멀티셀렉트 → 실행.
|
|
52
|
+
const action = await ui.selectAction();
|
|
53
|
+
if (action == null || action === skillsPrompts.CANCEL || action === "skip") {
|
|
54
|
+
io.log("IDE Skills는 변경하지 않고 넘어갑니다.");
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const choices = rows.map(({ adapter, status }) => ({
|
|
59
|
+
id: adapter.id, label: adapter.label,
|
|
60
|
+
disabled: action === "apply" && status.cliMissing, // 설치는 CLI 있어야, 제거는 무조건 후보
|
|
61
|
+
}));
|
|
62
|
+
const preselect = rows
|
|
63
|
+
.filter(({ status }) => action === "apply" ? !status.cliMissing : true)
|
|
64
|
+
.map(({ adapter }) => adapter.id);
|
|
65
|
+
|
|
66
|
+
const targets = await ui.selectTargets(choices, preselect, action);
|
|
67
|
+
if (targets == null || targets === skillsPrompts.CANCEL || !targets.length) {
|
|
68
|
+
io.log("선택한 IDE가 없어 건너뜁니다 (원할 때 다시 실행하세요).");
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const selected = new Set(targets);
|
|
73
|
+
for (const { adapter } of rows) {
|
|
74
|
+
if (!selected.has(adapter.id)) continue;
|
|
75
|
+
io.log("");
|
|
76
|
+
io.log(`[ ${adapter.label} ${action === "apply" ? "설치/업데이트" : "제거"} ]`);
|
|
77
|
+
safe(() => (action === "apply" ? adapter.apply(io, ctx) : adapter.remove(io, ctx)), false);
|
|
78
|
+
}
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function safe(fn, fallback) { try { return fn(); } catch { return fallback; } }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// IDE 어댑터 공통 계약 (확장 포인트의 핵심).
|
|
2
|
+
//
|
|
3
|
+
// 새 IDE(에디터/에이전트 CLI)를 지원하려면 이 shape을 만족하는 객체 하나를
|
|
4
|
+
// src/core/ide/adapters/<name>.js 에 만들고 registry.js 배열에 추가하기만 하면 된다.
|
|
5
|
+
// 오케스트레이터(commands/skills.js)는 어댑터의 필드만 알고, 개별 IDE 로직은 전혀 모른다.
|
|
6
|
+
//
|
|
7
|
+
// @typedef {Object} IdeStatus
|
|
8
|
+
// installed: boolean 설치돼 있는가
|
|
9
|
+
// version: string|null 설치 버전(알 수 있으면)
|
|
10
|
+
// cliMissing: boolean 전용 CLI가 없어 설치 불가한 상태인가 (안내만 가능)
|
|
11
|
+
// note: string 상태 표시줄에 덧붙일 짧은 메모(선택)
|
|
12
|
+
//
|
|
13
|
+
// @typedef {Object} IdeAdapter
|
|
14
|
+
// id: string 내부 고유키 (예: "claude"). 라우팅·preselect에 사용
|
|
15
|
+
// label: string 사람에게 보이는 이름 (예: "Claude Code")
|
|
16
|
+
// order: number 상태·메뉴 표시 순서 (작을수록 먼저)
|
|
17
|
+
// detect(io): IdeStatus 현재 설치 상태
|
|
18
|
+
// apply(io, ctx): boolean 설치 또는 업데이트(멱등). 성공 true
|
|
19
|
+
// remove(io, ctx): boolean 제거(미설치면 no-op, true)
|
|
20
|
+
// manualHint(io): string CLI 없을 때 보여줄 수동 설치 명령(선택)
|
|
21
|
+
//
|
|
22
|
+
// io = runner.defaultIo() 또는 테스트 stub: { which, run, home, log }
|
|
23
|
+
// ctx = { templateVersion, sourceSkillsDir } — 버전 표기·Cursor 복사 소스 등 공용 컨텍스트
|
|
24
|
+
//
|
|
25
|
+
// 규칙:
|
|
26
|
+
// - apply/remove/detect는 예외를 던지지 않는다(내부에서 흡수). 실패는 log + false로 표현.
|
|
27
|
+
// - CLI 미존재는 에러가 아니라 cliMissing 상태 + manualHint로 안내한다.
|
|
28
|
+
// - 어댑터는 io를 통해서만 외부와 상호작용한다(직접 spawnSync/console 금지) — 테스트 가능성.
|
|
29
|
+
|
|
30
|
+
// 어댑터가 최소 계약을 지키는지 개발 중 검증하는 헬퍼(런타임 방어).
|
|
31
|
+
export function assertAdapter(a) {
|
|
32
|
+
for (const k of ["id", "label", "detect", "apply", "remove"]) {
|
|
33
|
+
if (a[k] == null) throw new Error(`IDE 어댑터 '${a?.id || "?"}'에 필수 필드 '${k}'가 없습니다`);
|
|
34
|
+
}
|
|
35
|
+
return a;
|
|
36
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Claude Code 어댑터 (.sh _manage_claude_section / _do_claude_plugin_install /
|
|
2
|
+
// _remove_claude_section / _remove_claude_plugin_data 등가).
|
|
3
|
+
// 마켓플레이스 Cassiiopeia/projectops, 플러그인 cassiiopeia@cassiiopeia-marketplace.
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { existsSync, readdirSync, mkdirSync, cpSync, rmSync } from "node:fs";
|
|
6
|
+
import { compareCacheName } from "../util.js";
|
|
7
|
+
|
|
8
|
+
const MARKETPLACE = "Cassiiopeia/projectops";
|
|
9
|
+
const PLUGIN = "cassiiopeia@cassiiopeia-marketplace";
|
|
10
|
+
|
|
11
|
+
function detect(io) {
|
|
12
|
+
if (!io.which("claude")) return { installed: false, version: null, cliMissing: true, note: "CLI 없음" };
|
|
13
|
+
const r = io.run("claude", ["plugin", "list", "--json"]);
|
|
14
|
+
let scope = "", version = null;
|
|
15
|
+
try {
|
|
16
|
+
const arr = JSON.parse(r.stdout || "[]");
|
|
17
|
+
const list = Array.isArray(arr) ? arr : (arr.plugins || []);
|
|
18
|
+
for (const p of list) {
|
|
19
|
+
const name = String(p.name || p.id || "");
|
|
20
|
+
if (name.includes("cassiiopeia@") || name === "cassiiopeia") { scope = p.scope || ""; version = p.version || null; break; }
|
|
21
|
+
}
|
|
22
|
+
} catch { /* 파싱 실패 → 미설치 취급 */ }
|
|
23
|
+
return { installed: !!scope, version, cliMissing: false, scope };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function apply(io, ctx = {}) {
|
|
27
|
+
const st = detect(io);
|
|
28
|
+
if (st.cliMissing) { io.log(manualHint(io)); return false; }
|
|
29
|
+
if (st.installed) return update(io, st.scope);
|
|
30
|
+
return install(io, "user");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function install(io, scope = "user") {
|
|
34
|
+
io.log("Claude Code 마켓플레이스 등록 중...");
|
|
35
|
+
const add = io.run("claude", ["plugin", "marketplace", "add", MARKETPLACE]);
|
|
36
|
+
io.log(add.code === 0 ? " 마켓플레이스 등록 완료" : " 마켓플레이스 이미 등록되어 있거나 등록 생략");
|
|
37
|
+
io.log(`Claude Code 플러그인 설치 중 (scope: ${scope})...`);
|
|
38
|
+
const ins = io.run("claude", ["plugin", "install", PLUGIN, "--scope", scope]);
|
|
39
|
+
if (ins.code === 0) { io.log(` Claude Code 플러그인 설치 완료 (scope: ${scope})`); return true; }
|
|
40
|
+
io.log(` 플러그인 설치 실패. 수동: claude plugin install ${PLUGIN} --scope ${scope}`);
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function update(io, scope) {
|
|
45
|
+
const cacheRoot = join(io.home(), ".claude/plugins/cache/cassiiopeia-marketplace/cassiiopeia");
|
|
46
|
+
const oldCache = latestCacheDir(cacheRoot);
|
|
47
|
+
io.log("플러그인 업데이트 중...");
|
|
48
|
+
const up = io.run("claude", ["plugin", "update", PLUGIN, "--scope", scope]);
|
|
49
|
+
if (up.code !== 0) { io.log(` 업데이트 실패. 수동: claude plugin update ${PLUGIN} --scope ${scope}`); return false; }
|
|
50
|
+
io.log(` 업데이트 완료 (scope: ${scope})`);
|
|
51
|
+
migrateConfig(io, oldCache, latestCacheDir(cacheRoot));
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function remove(io) {
|
|
56
|
+
const st = detect(io);
|
|
57
|
+
if (st.cliMissing || !st.installed) { io.log(" 설치된 Claude Code 플러그인이 없어 건너뜁니다"); return true; }
|
|
58
|
+
io.log(` 제거할 대상: ${PLUGIN} (scope: ${st.scope})`);
|
|
59
|
+
const un = io.run("claude", ["plugin", "uninstall", PLUGIN, "--scope", st.scope]);
|
|
60
|
+
if (un.code === 0) { io.log(" 플러그인 uninstall 완료"); removePluginData(io); return true; }
|
|
61
|
+
io.log(` 삭제 실패. 수동: claude plugin uninstall ${PLUGIN} --scope ${st.scope}`);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function manualHint() {
|
|
66
|
+
return ` 💡 Claude Code 사용자: claude plugin marketplace add ${MARKETPLACE}\n claude plugin install ${PLUGIN} --scope user`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── 내부 헬퍼 ──
|
|
70
|
+
function latestCacheDir(root) {
|
|
71
|
+
if (!existsSync(root)) return "";
|
|
72
|
+
const dirs = readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort(compareCacheName);
|
|
73
|
+
return dirs.length ? join(root, dirs[dirs.length - 1]) : "";
|
|
74
|
+
}
|
|
75
|
+
function migrateConfig(io, oldCache, newCache) {
|
|
76
|
+
if (!oldCache || !newCache || oldCache === newCache) return;
|
|
77
|
+
const oldCfg = join(oldCache, "config"), newCfg = join(newCache, "config");
|
|
78
|
+
if (!existsSync(oldCfg)) return;
|
|
79
|
+
try {
|
|
80
|
+
mkdirSync(newCfg, { recursive: true });
|
|
81
|
+
let copied = 0;
|
|
82
|
+
for (const f of readdirSync(oldCfg)) if (f.endsWith(".json")) { cpSync(join(oldCfg, f), join(newCfg, f)); copied++; }
|
|
83
|
+
if (copied) io.log(" config.json 마이그레이션 완료 (이전 버전 설정 유지)");
|
|
84
|
+
} catch { /* 무해 */ }
|
|
85
|
+
}
|
|
86
|
+
function removePluginData(io) {
|
|
87
|
+
const dataDir = join(io.home(), ".claude/plugins/data", PLUGIN);
|
|
88
|
+
if (existsSync(dataDir)) { try { rmSync(dataDir, { recursive: true, force: true }); io.log(" 플러그인 데이터(config) 삭제 완료"); } catch { /* 무해 */ } }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const claudeAdapter = {
|
|
92
|
+
id: "claude", label: "Claude Code", order: 10,
|
|
93
|
+
detect, apply, remove, manualHint,
|
|
94
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Codex CLI 어댑터 (.sh _manage_codex_skills / _do_codex_marketplace_register /
|
|
2
|
+
// _remove_codex_section 등가).
|
|
3
|
+
// marketplace 등록/업그레이드가 주 경로. native ~/.agents/skills/cassiiopeia 심링크는 감지·제거에 사용.
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { existsSync, lstatSync, rmSync } from "node:fs";
|
|
6
|
+
|
|
7
|
+
const MARKETPLACE = "Cassiiopeia/projectops";
|
|
8
|
+
const PLUGIN = "cassiiopeia";
|
|
9
|
+
|
|
10
|
+
function nativeTarget(io) { return join(io.home(), ".agents/skills/cassiiopeia"); }
|
|
11
|
+
|
|
12
|
+
function detect(io) {
|
|
13
|
+
const tgt = nativeTarget(io);
|
|
14
|
+
const nativeInstalled = existsSync(tgt);
|
|
15
|
+
if (nativeInstalled) return { installed: true, version: null, cliMissing: false, note: "native skills" };
|
|
16
|
+
if (!io.which("codex")) return { installed: false, version: null, cliMissing: true, note: "CLI 없음" };
|
|
17
|
+
return { installed: false, version: null, cliMissing: false, note: "설치 가능 (CLI 감지됨)" };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function apply(io) {
|
|
21
|
+
if (!io.which("codex")) { io.log(manualHint()); return false; }
|
|
22
|
+
io.log("Codex plugin marketplace 등록 중...");
|
|
23
|
+
const add = io.run("codex", ["plugin", "marketplace", "add", MARKETPLACE]);
|
|
24
|
+
io.log(add.code === 0 ? " Codex marketplace 등록 완료" : " Codex marketplace 이미 등록되어 있거나 등록 생략");
|
|
25
|
+
io.log("Codex plugin marketplace 업데이트 중...");
|
|
26
|
+
if (io.run("codex", ["plugin", "marketplace", "upgrade", PLUGIN]).code === 0) { io.log(" Codex marketplace 등록 완료 (/plugins 확인)"); return true; }
|
|
27
|
+
io.log(` Codex marketplace 관리 오류 — 수동: codex plugin marketplace add ${MARKETPLACE}`);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function remove(io) {
|
|
32
|
+
const tgt = nativeTarget(io);
|
|
33
|
+
let removed = false;
|
|
34
|
+
if (existsSync(tgt) || isSymlink(tgt)) {
|
|
35
|
+
try { rmSync(tgt, { recursive: true, force: true }); io.log(` Codex native skills 제거 완료 (${tgt})`); removed = true; } catch { /* 무해 */ }
|
|
36
|
+
}
|
|
37
|
+
if (!removed) io.log(" 제거할 Codex skills가 없어 건너뜁니다");
|
|
38
|
+
if (io.which("codex")) io.log(` marketplace 등록 해제는 수동: codex plugin marketplace remove ${PLUGIN}`);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function manualHint() { return ` 💡 Codex CLI: codex plugin marketplace add ${MARKETPLACE}`; }
|
|
43
|
+
|
|
44
|
+
function isSymlink(p) { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } }
|
|
45
|
+
|
|
46
|
+
export const codexAdapter = {
|
|
47
|
+
id: "codex", label: "Codex CLI", order: 40,
|
|
48
|
+
detect, apply, remove, manualHint,
|
|
49
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Cursor 어댑터 (.sh _manage_cursor_section / _do_cursor_skills_copy /
|
|
2
|
+
// _write_cursor_skills_meta / _remove_cursor_section 등가).
|
|
3
|
+
// 마켓플레이스 없음 → ~/.cursor/skills/ 에 skills/ 폴더를 복사하고 meta.json으로 버전 추적.
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { existsSync, readFileSync, mkdirSync, cpSync, rmSync, writeFileSync, readdirSync } from "node:fs";
|
|
6
|
+
|
|
7
|
+
function metaPath(io) { return join(io.home(), ".cursor/skills/cursor-skills-meta.json"); }
|
|
8
|
+
|
|
9
|
+
function detect(io) {
|
|
10
|
+
const mp = metaPath(io);
|
|
11
|
+
if (!existsSync(mp)) return { installed: false, version: null, cliMissing: false };
|
|
12
|
+
let version = null;
|
|
13
|
+
try {
|
|
14
|
+
const m = JSON.parse(readFileSync(mp, "utf8"));
|
|
15
|
+
version = m.version || null;
|
|
16
|
+
} catch { /* meta 손상 → 설치는 됐다고 봄 */ }
|
|
17
|
+
return { installed: true, version, cliMissing: false };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function apply(io, ctx = {}) {
|
|
21
|
+
const src = resolveSkillsSrc(ctx);
|
|
22
|
+
if (!src) { io.log(" 설치할 스킬 소스를 찾지 못했습니다 (skills/ 폴더 필요)."); return false; }
|
|
23
|
+
const dest = join(io.home(), ".cursor/skills");
|
|
24
|
+
io.log("Cursor Skills 복사 중...");
|
|
25
|
+
try {
|
|
26
|
+
mkdirSync(dest, { recursive: true });
|
|
27
|
+
for (const e of readdirSync(src, { withFileTypes: true })) {
|
|
28
|
+
cpSync(join(src, e.name), join(dest, e.name), { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
writeMeta(io, dest, ctx.templateVersion);
|
|
31
|
+
io.log(` Cursor Skills 설치 완료 (${dest}/, v${ctx.templateVersion || "unknown"})`);
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
io.log(" Cursor Skills 복사 실패 — skills/ 폴더를 확인하세요.");
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function remove(io) {
|
|
40
|
+
const dir = join(io.home(), ".cursor/skills");
|
|
41
|
+
if (!existsSync(join(dir, "cursor-skills-meta.json"))) { io.log(" 설치된 Cursor Skills가 없어 건너뜁니다"); return true; }
|
|
42
|
+
try { rmSync(dir, { recursive: true, force: true }); io.log(` Cursor Skills 제거 완료 (${dir}/)`); return true; }
|
|
43
|
+
catch { io.log(` Cursor Skills 제거 실패 — 수동 삭제: rm -rf ${dir}`); return false; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function manualHint() {
|
|
47
|
+
return " 💡 Cursor: skills/ 폴더를 ~/.cursor/skills/ 로 복사하면 됩니다.";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── 헬퍼 ──
|
|
51
|
+
// 스킬 소스: 다운로드된 템플릿(TEMP)/skills 우선, 없으면 로컬 skills/.
|
|
52
|
+
function resolveSkillsSrc(ctx) {
|
|
53
|
+
const cands = [ctx.sourceSkillsDir, ctx.tempDir && join(ctx.tempDir, "skills"), "skills"].filter(Boolean);
|
|
54
|
+
for (const c of cands) if (existsSync(c)) return c;
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function writeMeta(io, destDir, templateVersion) {
|
|
59
|
+
const version = templateVersion || "unknown";
|
|
60
|
+
const now = new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
|
61
|
+
const file = join(destDir, "cursor-skills-meta.json");
|
|
62
|
+
let installedAt = now;
|
|
63
|
+
if (existsSync(file)) { try { installedAt = JSON.parse(readFileSync(file, "utf8")).installedAt || now; } catch { /* 무해 */ } }
|
|
64
|
+
const meta = {
|
|
65
|
+
name: "cassiiopeia", version, scope: "user",
|
|
66
|
+
source: "https://github.com/Cassiiopeia/projectops",
|
|
67
|
+
installPath: destDir, installedAt, lastUpdated: now,
|
|
68
|
+
};
|
|
69
|
+
mkdirSync(destDir, { recursive: true });
|
|
70
|
+
writeFileSync(file, JSON.stringify(meta, null, 2) + "\n");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const cursorAdapter = {
|
|
74
|
+
id: "cursor", label: "Cursor", order: 20,
|
|
75
|
+
detect, apply, remove, manualHint,
|
|
76
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Gemini CLI 어댑터 (.sh _manage_gemini_extension / _remove_gemini_section 등가).
|
|
2
|
+
// extension install/update/uninstall. 마켓플레이스 대신 git URL 확장.
|
|
3
|
+
const EXT = "cassiiopeia";
|
|
4
|
+
const URL = "https://github.com/Cassiiopeia/projectops";
|
|
5
|
+
|
|
6
|
+
function detect(io) {
|
|
7
|
+
if (!io.which("gemini")) return { installed: false, version: null, cliMissing: true, note: "CLI 없음" };
|
|
8
|
+
// .sh는 gemini 설치 상태를 정밀 조회하지 않고 "설치 가능"으로만 표기 → installed 미상.
|
|
9
|
+
return { installed: false, version: null, cliMissing: false, note: "설치 가능 (CLI 감지됨)" };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function apply(io) {
|
|
13
|
+
if (!io.which("gemini")) { io.log(manualHint()); return false; }
|
|
14
|
+
io.log("Gemini CLI extension 업데이트 중...");
|
|
15
|
+
if (io.run("gemini", ["extensions", "update", EXT]).code === 0) { io.log(" Gemini CLI extension 업데이트 완료"); return true; }
|
|
16
|
+
io.log("Gemini CLI extension 설치 중...");
|
|
17
|
+
if (io.run("gemini", ["extensions", "install", URL]).code === 0) { io.log(" Gemini CLI extension 설치 완료"); return true; }
|
|
18
|
+
io.log(` Gemini extension 관리 오류 — 수동: gemini extensions install ${URL}`);
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function remove(io) {
|
|
23
|
+
if (!io.which("gemini")) { io.log(" gemini CLI 미감지 — 건너뜁니다"); return true; }
|
|
24
|
+
if (io.run("gemini", ["extensions", "uninstall", EXT]).code === 0) { io.log(" Gemini CLI extension 제거 완료"); return true; }
|
|
25
|
+
io.log(` 제거할 Gemini extension이 없거나 실패 — 수동: gemini extensions uninstall ${EXT}`);
|
|
26
|
+
return true; // 미설치 제거는 실패로 보지 않음
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function manualHint() { return ` 💡 Gemini CLI: gemini extensions install ${URL}`; }
|
|
30
|
+
|
|
31
|
+
export const geminiAdapter = {
|
|
32
|
+
id: "gemini", label: "Gemini CLI", order: 30,
|
|
33
|
+
detect, apply, remove, manualHint,
|
|
34
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// PI 공용 경로·상태 로직 (pi 어댑터와 pi-harness 어댑터가 공유).
|
|
2
|
+
// .sh: PI_PACKAGE_URL / _pi_is_installed / _pi_clone_dir / _pi_harness_loader_path /
|
|
3
|
+
// _pi_settings_path / _pi_harness_enabled 등가.
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
|
|
7
|
+
export const PI_PACKAGE_URL = "https://github.com/Cassiiopeia/projectops";
|
|
8
|
+
|
|
9
|
+
// 'pi list' 출력에 우리 레포가 잡히면 설치됨. (구 레포명·프로젝트명 모두 허용)
|
|
10
|
+
export function piInstalled(io) {
|
|
11
|
+
if (!io.which("pi")) return false;
|
|
12
|
+
const r = io.run("pi", ["list"]); // pi는 일부 출력을 stderr로 보냄 → 둘 다 검사
|
|
13
|
+
const out = (r.stdout || "") + (r.stderr || "");
|
|
14
|
+
return /SUH-DEVOPS-TEMPLATE|projectops|cassiiopeia/i.test(out);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// pi 클론 경로(harness loader가 사는 곳). 레포명 변경 전 구 경로 하위호환.
|
|
18
|
+
export function piCloneDir(io) {
|
|
19
|
+
const base = join(io.home(), ".pi/agent/git/github.com/Cassiiopeia");
|
|
20
|
+
const newDir = join(base, "projectops");
|
|
21
|
+
const oldDir = join(base, "SUH-DEVOPS-TEMPLATE");
|
|
22
|
+
return (!existsSync(newDir) && existsSync(oldDir)) ? oldDir : newDir;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function harnessLoaderPath(io) { return join(piCloneDir(io), "harness/harness-loader.ts"); }
|
|
26
|
+
export function piSettingsPath(io) { return join(io.home(), ".pi/agent/settings.json"); }
|
|
27
|
+
|
|
28
|
+
// settings.json의 extensions 배열에 loader가 등록돼 있는가.
|
|
29
|
+
export function harnessEnabled(io) {
|
|
30
|
+
const settings = piSettingsPath(io), loader = harnessLoaderPath(io);
|
|
31
|
+
if (!existsSync(settings)) return false;
|
|
32
|
+
try {
|
|
33
|
+
const s = JSON.parse(readFileSync(settings, "utf8"));
|
|
34
|
+
return Array.isArray(s.extensions) && s.extensions.includes(loader);
|
|
35
|
+
} catch { return false; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// extensions에 loader 추가(중복 방지). {ok, reason}.
|
|
39
|
+
export function harnessAdd(io) {
|
|
40
|
+
const settings = piSettingsPath(io), loader = harnessLoaderPath(io);
|
|
41
|
+
if (!existsSync(settings)) return { ok: false, reason: "no-settings" };
|
|
42
|
+
if (!existsSync(loader)) return { ok: false, reason: "no-loader" };
|
|
43
|
+
let s = {};
|
|
44
|
+
try { s = JSON.parse(readFileSync(settings, "utf8")); } catch { s = {}; }
|
|
45
|
+
const exts = (Array.isArray(s.extensions) ? s.extensions : []).filter(Boolean);
|
|
46
|
+
if (!exts.includes(loader)) exts.push(loader);
|
|
47
|
+
s.extensions = exts;
|
|
48
|
+
writeFileSync(settings, JSON.stringify(s, null, 2));
|
|
49
|
+
return { ok: true };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// extensions에서 loader 제거. {ok}.
|
|
53
|
+
export function harnessRemove(io) {
|
|
54
|
+
const settings = piSettingsPath(io), loader = harnessLoaderPath(io);
|
|
55
|
+
if (!existsSync(settings)) return { ok: true };
|
|
56
|
+
let s;
|
|
57
|
+
try { s = JSON.parse(readFileSync(settings, "utf8")); } catch { return { ok: true }; }
|
|
58
|
+
if (Array.isArray(s.extensions)) {
|
|
59
|
+
s.extensions = s.extensions.filter((e) => e && e !== loader);
|
|
60
|
+
writeFileSync(settings, JSON.stringify(s, null, 2));
|
|
61
|
+
}
|
|
62
|
+
return { ok: true };
|
|
63
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// PI Persona Harness 어댑터 (.sh _pi_harness_toggle / _pi_harness_remove_only /
|
|
2
|
+
// _pi_harness_add / _pi_harness_remove / _pi_harness_enabled 등가).
|
|
3
|
+
// PI skill과 독립 — skill은 그대로 두고 harness 등록(settings.json extensions)만 켜고/끈다.
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { harnessEnabled, harnessAdd, harnessRemove, harnessLoaderPath } from "./pi-common.js";
|
|
6
|
+
|
|
7
|
+
// 이 어댑터는 "설치 가능" 여부가 pi 존재 + loader 파일 존재에 달림.
|
|
8
|
+
function available(io) { return !!io.which("pi") && existsSync(harnessLoaderPath(io)); }
|
|
9
|
+
|
|
10
|
+
function detect(io) {
|
|
11
|
+
if (!io.which("pi")) return { installed: false, version: null, cliMissing: true, note: "PI 없음" };
|
|
12
|
+
if (!existsSync(harnessLoaderPath(io))) return { installed: false, version: null, cliMissing: true, note: "loader 없음 (PI 설치 필요)" };
|
|
13
|
+
return { installed: harnessEnabled(io), version: null, cliMissing: false, note: harnessEnabled(io) ? "활성화됨" : "비활성화" };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// apply = harness 활성화 (skill은 안 건드림).
|
|
17
|
+
function apply(io) {
|
|
18
|
+
if (!available(io)) { io.log(" harness loader가 없습니다 — 먼저 PI를 설치하세요."); return false; }
|
|
19
|
+
if (harnessEnabled(io)) { io.log(" Persona Harness: 이미 활성화됨 (유지)"); return true; }
|
|
20
|
+
const r = harnessAdd(io);
|
|
21
|
+
if (r.ok) { io.log(" Persona Harness 활성화 완료 — PI 재시작 후 적용됩니다."); return true; }
|
|
22
|
+
io.log(r.reason === "no-settings" ? " PI settings.json이 없습니다 — PI를 한 번 실행한 뒤 다시 시도하세요."
|
|
23
|
+
: " harness loader가 없습니다 — 먼저 PI 패키지를 설치/업데이트하세요.");
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// remove = harness만 해제 (PI skill 보존).
|
|
28
|
+
function remove(io) {
|
|
29
|
+
if (!harnessEnabled(io)) { io.log(" Persona Harness가 활성화돼 있지 않아 건너뜁니다"); return true; }
|
|
30
|
+
io.log(" PI skill은 그대로 두고 harness 등록만 해제합니다.");
|
|
31
|
+
harnessRemove(io);
|
|
32
|
+
io.log(" Persona Harness 해제 완료 — PI 재시작 후 적용됩니다.");
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function manualHint() { return " 💡 PI Persona Harness: PI 설치 후 settings.json extensions 에 harness-loader.ts 등록"; }
|
|
37
|
+
|
|
38
|
+
// harness 설명 (프롬프트에서 재사용).
|
|
39
|
+
export const HARNESS_DESC = [
|
|
40
|
+
"Persona Harness는 PI가 대화를 시작할 때마다 '전문가 페르소나'와 'SDLC 워크플로우'를",
|
|
41
|
+
"시스템 프롬프트에 자동 주입하는 기능입니다 (skill과 독립적으로 켜고/끌 수 있음).",
|
|
42
|
+
].join("\n");
|
|
43
|
+
|
|
44
|
+
export const piHarnessAdapter = {
|
|
45
|
+
id: "pi-harness", label: "PI Persona Harness", order: 60,
|
|
46
|
+
optional: true, // 감지된 경우에만 메뉴 노출 (registry 순회 시 참고)
|
|
47
|
+
detect, apply, remove, manualHint,
|
|
48
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// PI 패키지 어댑터 (.sh _manage_pi_section / _remove_pi_section 등가).
|
|
2
|
+
// pi install/update/remove. skill은 복사되지 않고 pi가 startup마다 스캔 → 설치 검증은 'pi list'.
|
|
3
|
+
import { PI_PACKAGE_URL, piInstalled, harnessEnabled, harnessRemove } from "./pi-common.js";
|
|
4
|
+
|
|
5
|
+
function detect(io) {
|
|
6
|
+
if (!io.which("pi")) return { installed: false, version: null, cliMissing: true, note: "CLI 없음" };
|
|
7
|
+
return { installed: piInstalled(io), version: null, cliMissing: false };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function apply(io) {
|
|
11
|
+
if (!io.which("pi")) { io.log(manualHint()); return false; }
|
|
12
|
+
if (piInstalled(io)) {
|
|
13
|
+
io.log("PI 패키지 업데이트 중...");
|
|
14
|
+
if (io.run("pi", ["update", PI_PACKAGE_URL]).code !== 0) io.run("pi", ["install", PI_PACKAGE_URL]);
|
|
15
|
+
} else {
|
|
16
|
+
io.log("PI 패키지 설치 중...");
|
|
17
|
+
io.run("pi", ["install", PI_PACKAGE_URL]);
|
|
18
|
+
}
|
|
19
|
+
if (piInstalled(io)) {
|
|
20
|
+
io.log(" PI 패키지 설치 / 업데이트 완료");
|
|
21
|
+
io.log(" → 'pi' 재실행 후 'pi list' 로 확인, 채팅창에서 /suh-analyze 등 호출");
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
io.log(` PI 설치/업데이트 실패 — 수동: pi install ${PI_PACKAGE_URL}`);
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function remove(io) {
|
|
29
|
+
if (!io.which("pi")) { io.log(" pi CLI 미감지 — 건너뜁니다"); return true; }
|
|
30
|
+
if (!piInstalled(io)) { io.log(" 설치된 PI 패키지가 없어 건너뜁니다"); return true; }
|
|
31
|
+
io.log(` pi remove ${PI_PACKAGE_URL}`);
|
|
32
|
+
io.run("pi", ["remove", PI_PACKAGE_URL]);
|
|
33
|
+
if (piInstalled(io)) io.log(" 제거 후에도 패키지가 남아있습니다 — 'pi list'로 확인하세요.");
|
|
34
|
+
else io.log(" PI 패키지 제거 완료");
|
|
35
|
+
// package 클론이 사라지면 harness loader 경로가 허공을 가리킴 → 함께 해제
|
|
36
|
+
if (harnessEnabled(io)) { io.log(" Persona Harness 등록도 함께 해제됩니다."); harnessRemove(io); }
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function manualHint() { return ` 💡 PI: pi install ${PI_PACKAGE_URL}`; }
|
|
41
|
+
|
|
42
|
+
export const piAdapter = {
|
|
43
|
+
id: "pi", label: "PI", order: 50,
|
|
44
|
+
detect, apply, remove, manualHint,
|
|
45
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// IDE 어댑터 레지스트리 — 유일한 확장 지점.
|
|
2
|
+
//
|
|
3
|
+
// 새 IDE를 지원하려면:
|
|
4
|
+
// 1) src/core/ide/adapters/<name>.js 에 어댑터 객체(adapter.js 계약) 작성
|
|
5
|
+
// 2) 아래에 import 추가하고 ADAPTERS 배열에 넣기
|
|
6
|
+
// 오케스트레이터·프롬프트·index 라우팅은 이 배열만 순회하므로 다른 파일은 손대지 않는다.
|
|
7
|
+
import { assertAdapter } from "./adapter.js";
|
|
8
|
+
import { claudeAdapter } from "./adapters/claude.js";
|
|
9
|
+
import { cursorAdapter } from "./adapters/cursor.js";
|
|
10
|
+
import { geminiAdapter } from "./adapters/gemini.js";
|
|
11
|
+
import { codexAdapter } from "./adapters/codex.js";
|
|
12
|
+
import { piAdapter } from "./adapters/pi.js";
|
|
13
|
+
import { piHarnessAdapter } from "./adapters/pi-harness.js";
|
|
14
|
+
|
|
15
|
+
// order 오름차순 정렬 + 계약 검증.
|
|
16
|
+
export const ADAPTERS = [
|
|
17
|
+
claudeAdapter,
|
|
18
|
+
cursorAdapter,
|
|
19
|
+
geminiAdapter,
|
|
20
|
+
codexAdapter,
|
|
21
|
+
piAdapter,
|
|
22
|
+
piHarnessAdapter,
|
|
23
|
+
].map(assertAdapter).sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
|
|
24
|
+
|
|
25
|
+
export function adapterById(id) {
|
|
26
|
+
return ADAPTERS.find((a) => a.id === id) || null;
|
|
27
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// 외부 CLI 실행·감지 래퍼 (.sh command -v / CLI 호출 등가).
|
|
2
|
+
// 전부 주입 가능 — 테스트는 stub runner/which/home 를 넘겨 실제 CLI 없이 검증한다.
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { delimiter, join } from "node:path";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
|
|
8
|
+
// which(cmd) → 실행 파일 절대경로 or null. PATH를 직접 스캔 (execa 없이 내장만).
|
|
9
|
+
// Windows는 PATHEXT 확장자(.cmd/.exe/.bat 등)까지 시도.
|
|
10
|
+
export function which(cmd) {
|
|
11
|
+
const paths = (process.env.PATH || "").split(delimiter).filter(Boolean);
|
|
12
|
+
const exts = process.platform === "win32"
|
|
13
|
+
? (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
|
|
14
|
+
: [""];
|
|
15
|
+
for (const dir of paths) {
|
|
16
|
+
for (const ext of exts) {
|
|
17
|
+
const full = join(dir, cmd + ext);
|
|
18
|
+
if (existsSync(full)) return full;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// run(cmd, args, opts) → {code, stdout, stderr}. 동기 실행.
|
|
25
|
+
// opts.cwd, opts.env 지원. 실행 자체 실패(파일 없음 등)면 code=127.
|
|
26
|
+
//
|
|
27
|
+
// Windows: claude/gemini/codex/pi 는 .cmd/.ps1 셸 런처라 spawnSync가 직접 못 띄운다.
|
|
28
|
+
// shell:true + args 배열은 DEP0190(인자 미이스케이프) 경고 → cmd.exe에 안전 인용한 단일
|
|
29
|
+
// 명령 문자열을 넘긴다. args는 우리가 만든 하드코딩 값 + URL/scope뿐이라 신뢰 가능하지만,
|
|
30
|
+
// 그래도 큰따옴표로 감싸 공백·특수문자를 방어한다.
|
|
31
|
+
export function run(cmd, args = [], opts = {}) {
|
|
32
|
+
const common = { cwd: opts.cwd, env: opts.env || process.env, encoding: "utf8", windowsHide: true };
|
|
33
|
+
let r;
|
|
34
|
+
if (process.platform === "win32") {
|
|
35
|
+
const line = [cmd, ...args].map(winQuote).join(" ");
|
|
36
|
+
r = spawnSync(line, { ...common, shell: true });
|
|
37
|
+
} else {
|
|
38
|
+
r = spawnSync(cmd, args, common); // POSIX는 shell 불필요(안전)
|
|
39
|
+
}
|
|
40
|
+
if (r.error) return { code: 127, stdout: "", stderr: String(r.error.message || r.error) };
|
|
41
|
+
return { code: r.status ?? 0, stdout: r.stdout || "", stderr: r.stderr || "" };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// cmd.exe용 인용: 이미 안전한 토큰(영숫자·경로·URL 문자)이면 그대로, 아니면 "..." 로 감싸고 내부 " 이스케이프.
|
|
45
|
+
function winQuote(s) {
|
|
46
|
+
if (/^[A-Za-z0-9_./:@=-]+$/.test(s)) return s;
|
|
47
|
+
return '"' + String(s).replace(/"/g, '\\"') + '"';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// IDE 실행 컨텍스트 기본값. 테스트는 이 shape을 스텁으로 대체.
|
|
51
|
+
// which(cmd)→path|null, run(cmd,args)→{code,stdout,stderr}, home()→string, log(msg)
|
|
52
|
+
export function defaultIo() {
|
|
53
|
+
return {
|
|
54
|
+
which,
|
|
55
|
+
run,
|
|
56
|
+
home: () => homedir(),
|
|
57
|
+
log: (msg) => console.error(msg), // .sh는 안내를 stderr로 출력
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// IDE 어댑터 공용 유틸.
|
|
2
|
+
|
|
3
|
+
// sort -V 근사: 버전형 디렉토리/문자열 비교기. "3.0.9" < "3.0.10" 을 올바로 정렬.
|
|
4
|
+
export function compareCacheName(a, b) {
|
|
5
|
+
const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
|
|
6
|
+
const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
|
|
7
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
8
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
9
|
+
if (d) return d;
|
|
10
|
+
}
|
|
11
|
+
return a.localeCompare(b);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// 상태 → 표시 태그 (" ✓ 최신" / " → 업데이트 가능: vX"). templateVersion 없으면 빈 문자열.
|
|
15
|
+
export function versionTag(installedVersion, templateVersion) {
|
|
16
|
+
if (!templateVersion || !installedVersion) return "";
|
|
17
|
+
return installedVersion === templateVersion ? " ✓ 최신" : ` → 업데이트 가능: v${templateVersion}`;
|
|
18
|
+
}
|
package/src/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { runVersion } from "./commands/version.js";
|
|
|
13
13
|
import { runWorkflows } from "./commands/workflows.js";
|
|
14
14
|
import { runIssues } from "./commands/issues.js";
|
|
15
15
|
import { runInteractive } from "./commands/interactive.js";
|
|
16
|
+
import { runSkills } from "./commands/skills.js";
|
|
16
17
|
|
|
17
18
|
// 결정적 UTC 타임스탬프 (주입 가능 — 테스트/골든용)
|
|
18
19
|
function utcNow(date = new Date()) {
|
|
@@ -35,10 +36,18 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
|
|
|
35
36
|
}
|
|
36
37
|
if (opts.help) { console.log(HELP_TEXT); return 0; }
|
|
37
38
|
|
|
38
|
-
// skills
|
|
39
|
+
// skills 모드 — IDE 스킬 설치/업데이트/제거 (템플릿 통합 없음).
|
|
40
|
+
// Cursor 복사용 skills/ 소스가 필요하므로 템플릿을 획득한 뒤 실행한다.
|
|
39
41
|
if (opts.mode === "skills") {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
const tempDir = join(cwd, PATHS.tempDir);
|
|
43
|
+
const interactive = !opts.force && process.stdout.isTTY;
|
|
44
|
+
try {
|
|
45
|
+
acquireTemplate({ tempDir, source });
|
|
46
|
+
const templateVersion = readTemplateVersion(tempDir);
|
|
47
|
+
return await runSkills({ templateVersion, tempDir, interactive });
|
|
48
|
+
} finally {
|
|
49
|
+
remove(tempDir);
|
|
50
|
+
}
|
|
42
51
|
}
|
|
43
52
|
// 대화형 모드 — 인자 없이 실행 or --mode interactive
|
|
44
53
|
if (opts.mode === "interactive") {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// IDE Skills 대화형 프롬프트 (.sh 라우터 choose_menu 등가) — @clack/prompts 기반.
|
|
2
|
+
import * as clack from "@clack/prompts";
|
|
3
|
+
|
|
4
|
+
export const CANCEL = Symbol("cancel");
|
|
5
|
+
const wrap = (v) => (clack.isCancel(v) ? CANCEL : v);
|
|
6
|
+
|
|
7
|
+
// 동작 선택: 설치/업데이트 · 제거 · 그대로. 취소=skip.
|
|
8
|
+
export async function selectAction() {
|
|
9
|
+
const v = await clack.select({
|
|
10
|
+
message: "AI 스킬을 어떻게 할까요?",
|
|
11
|
+
options: [
|
|
12
|
+
{ value: "apply", label: "설치 / 업데이트 — 최신 상태로 맞추기" },
|
|
13
|
+
{ value: "remove", label: "제거 — 설치된 스킬 삭제하기" },
|
|
14
|
+
{ value: "skip", label: "그대로 두기" },
|
|
15
|
+
],
|
|
16
|
+
});
|
|
17
|
+
const w = wrap(v);
|
|
18
|
+
return w === CANCEL ? "skip" : w;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// IDE 멀티셀렉트. choices=[{id,label,disabled}], preselect=[id...], action.
|
|
22
|
+
export async function selectTargets(choices, preselect = [], action = "apply") {
|
|
23
|
+
const options = choices.map((c) => ({
|
|
24
|
+
value: c.id,
|
|
25
|
+
label: c.label + (c.disabled ? " (미감지)" : ""),
|
|
26
|
+
hint: c.disabled ? "CLI 없음" : undefined,
|
|
27
|
+
}));
|
|
28
|
+
const v = await clack.multiselect({
|
|
29
|
+
message: `${action === "apply" ? "설치 / 업데이트" : "제거"}할 IDE를 고르세요 (Space 토글, Enter 확정)`,
|
|
30
|
+
options,
|
|
31
|
+
initialValues: preselect,
|
|
32
|
+
required: false,
|
|
33
|
+
});
|
|
34
|
+
return wrap(v);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function note(text, title) { clack.note(text, title); }
|