projectops 3.0.193 → 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.
Files changed (41) hide show
  1. package/README.md +1 -1
  2. package/bin/projectops.js +7 -34
  3. package/package.json +7 -2
  4. package/src/cli/args.js +84 -0
  5. package/src/cli/help.js +23 -0
  6. package/src/commands/full.js +64 -0
  7. package/src/commands/interactive.js +135 -0
  8. package/src/commands/issues.js +8 -0
  9. package/src/commands/skills.js +82 -0
  10. package/src/commands/version.js +30 -0
  11. package/src/commands/workflows.js +53 -0
  12. package/src/context.js +25 -0
  13. package/src/core/assets.js +44 -0
  14. package/src/core/breaking.js +26 -0
  15. package/src/core/copy/coderabbit.js +25 -0
  16. package/src/core/copy/gitignore.js +55 -0
  17. package/src/core/copy/readme.js +30 -0
  18. package/src/core/copy/simple.js +55 -0
  19. package/src/core/copy/util.js +22 -0
  20. package/src/core/copy/workflows.js +145 -0
  21. package/src/core/detect-fs.js +64 -0
  22. package/src/core/detect.js +62 -0
  23. package/src/core/exclusions.js +25 -0
  24. package/src/core/fsutil.js +40 -0
  25. package/src/core/ide/adapter.js +36 -0
  26. package/src/core/ide/adapters/claude.js +94 -0
  27. package/src/core/ide/adapters/codex.js +49 -0
  28. package/src/core/ide/adapters/cursor.js +76 -0
  29. package/src/core/ide/adapters/gemini.js +34 -0
  30. package/src/core/ide/adapters/pi-common.js +63 -0
  31. package/src/core/ide/adapters/pi-harness.js +48 -0
  32. package/src/core/ide/adapters/pi.js +45 -0
  33. package/src/core/ide/registry.js +27 -0
  34. package/src/core/ide/runner.js +59 -0
  35. package/src/core/ide/util.js +18 -0
  36. package/src/core/paths.js +14 -0
  37. package/src/core/version-yml.js +146 -0
  38. package/src/core/wizard-env.js +101 -0
  39. package/src/index.js +108 -0
  40. package/src/ui/prompts.js +82 -0
  41. package/src/ui/skills-prompts.js +37 -0
@@ -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
+ }
@@ -0,0 +1,14 @@
1
+ // 경로 상수 (.sh readonly 상수군 등가) — template_integrator.sh 113~137
2
+ export const PATHS = {
3
+ tempDir: ".template_download_temp",
4
+ versionFile: "version.yml",
5
+ workflowsDir: ".github/workflows",
6
+ scriptsDir: ".github/scripts",
7
+ projectTypesDir: "project-types",
8
+ };
9
+
10
+ export const TEMPLATE_RAW_URL = "https://raw.githubusercontent.com/Cassiiopeia/projectops/main";
11
+ export const TEMPLATE_REPO = "https://github.com/Cassiiopeia/projectops.git";
12
+ export const WORKFLOW_PREFIX = "PROJECT";
13
+ export const WORKFLOW_COMMON_PREFIX = "PROJECT-COMMON";
14
+ export const WORKFLOW_TEMPLATE_INIT = "PROJECT-TEMPLATE-INITIALIZER.yaml";