projectops 4.31.5 → 4.33.1

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 (57) hide show
  1. package/README.md +144 -145
  2. package/package.json +2 -2
  3. package/src/cli/args.js +65 -29
  4. package/src/cli/errors.js +9 -7
  5. package/src/cli/help.js +51 -3
  6. package/src/commands/doctor.js +80 -78
  7. package/src/commands/full.js +17 -3
  8. package/src/commands/interactive.js +78 -50
  9. package/src/commands/issues.js +8 -1
  10. package/src/commands/skills.js +8 -7
  11. package/src/commands/version.js +4 -2
  12. package/src/commands/workflows.js +3 -1
  13. package/src/context.js +4 -0
  14. package/src/core/breaking-check.js +6 -5
  15. package/src/core/copy/simple.js +27 -1
  16. package/src/core/copy/workflows.js +10 -5
  17. package/src/core/exclusions.js +8 -0
  18. package/src/core/ide/adapters/claude.js +18 -17
  19. package/src/core/ide/adapters/codex.js +12 -11
  20. package/src/core/ide/adapters/cursor.js +11 -10
  21. package/src/core/ide/adapters/gemini.js +11 -10
  22. package/src/core/ide/adapters/pi-common.js +2 -1
  23. package/src/core/ide/adapters/pi-harness.js +13 -16
  24. package/src/core/ide/adapters/pi.js +12 -11
  25. package/src/core/label-style.js +78 -0
  26. package/src/core/migration-guide.js +38 -38
  27. package/src/core/migrations/index.js +19 -15
  28. package/src/core/migrations/registry.js +70 -55
  29. package/src/core/options-ask.js +65 -64
  30. package/src/core/orphan-workflows.js +2 -1
  31. package/src/core/paths-resolve.js +31 -30
  32. package/src/core/repo-language.js +57 -0
  33. package/src/core/run-trace.js +10 -0
  34. package/src/core/version-yml.js +68 -36
  35. package/src/i18n/core.en.js +31 -0
  36. package/src/i18n/core.ko.js +31 -0
  37. package/src/i18n/doctor.en.js +89 -0
  38. package/src/i18n/doctor.ko.js +89 -0
  39. package/src/i18n/en.js +142 -0
  40. package/src/i18n/ide.en.js +102 -0
  41. package/src/i18n/ide.ko.js +102 -0
  42. package/src/i18n/index.js +73 -0
  43. package/src/i18n/ko.js +135 -0
  44. package/src/i18n/migrate.en.js +66 -0
  45. package/src/i18n/migrate.ko.js +66 -0
  46. package/src/i18n/options.en.js +104 -0
  47. package/src/i18n/options.ko.js +104 -0
  48. package/src/i18n/summary.en.js +70 -0
  49. package/src/i18n/summary.ko.js +70 -0
  50. package/src/index.js +46 -28
  51. package/src/ui/banner.js +8 -6
  52. package/src/ui/env-plan.js +19 -18
  53. package/src/ui/prompts.js +25 -24
  54. package/src/ui/readline-engine.js +18 -10
  55. package/src/ui/skills-prompts.js +8 -7
  56. package/src/ui/status-cards.js +38 -38
  57. package/src/ui/summary.js +103 -111
@@ -71,7 +71,7 @@ function classify(srcDir, workflowsDir, envOpts, baseline = null) {
71
71
  // hooks: { decisions?: Map<filename, 'skip'|'backup'|'template'> } — 기존 파일(changed) 충돌 결정.
72
72
  // 반환: {copied, skipped, templateAdded, optionalCopied, copiedFiles[]} — copiedFiles는 실제 복사·교체된 파일명 (#473 요약용)
73
73
  export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
74
- const { types = [], paths = new Map(), deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, aiPrSummary = true, repoName = "", resolvers = {}, envValues = new Map(), envUseDefaults = true, branch = "", deployBranch = "" } = context;
74
+ const { types = [], paths = new Map(), deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, aiPrSummary = true, projectsSync = true, repoName = "", resolvers = {}, envValues = new Map(), envUseDefaults = true, branch = "", deployBranch = "" } = context;
75
75
  const decisions = hooks.decisions instanceof Map ? hooks.decisions : new Map();
76
76
  const trace = hooks.trace ?? null; // #494 — 실행 트레이스 (null-safe: 미주입이면 전 이벤트 no-op)
77
77
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
@@ -193,14 +193,18 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
193
193
  // (4.7) common/pr-summary — AI 변경 요약 (#566). 선택했을 때만 복사한다.
194
194
  // 종전에는 common 본체에 있어 무조건 복사된 뒤 런타임에 스스로 빠졌고, 그 판단이
195
195
  // ".coderabbit.yaml 존재"라 파일만 있고 앱이 없는 저장소에서는 아무도 요약하지 않았다.
196
- const prSummaryDir = join(commonDir, "pr-summary");
197
- if (exists(prSummaryDir) && aiPrSummary) {
196
+ // (4.8) common/projects-sync — Projects 보드 동기화 (#716). 이것도 선택했을 때만 복사한다.
197
+ // Secret(_GITHUB_PAT_TOKEN)과 PROJECT_URL 변수를 등록해야 동작하는데, Projects 를 안 쓰는 레포가
198
+ // 많아 설치만 되고 설정되지 않은 채 남았다. 같은 폴더 방식이라 게이트와 고아 감지(orphan-workflows.js)를 함께 둔다.
199
+ for (const [groupName, groupEnabled] of [["pr-summary", aiPrSummary], ["projects-sync", projectsSync]]) {
200
+ const prSummaryDir = join(commonDir, groupName);
201
+ if (exists(prSummaryDir) && groupEnabled) {
198
202
  for (const filename of listYamlFiles(prSummaryDir)) {
199
203
  const src = join(prSummaryDir, filename);
200
204
  const dst = join(workflowsDir, filename);
201
205
  if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOptsFor("common"))) {
202
206
  counters.skipped++;
203
- trace?.event("copy", "skipped-unchanged", filename, { group: "pr-summary" });
207
+ trace?.event("copy", "skipped-unchanged", filename, { group: groupName });
204
208
  continue;
205
209
  }
206
210
  const backedUp = existsSync(dst);
@@ -209,10 +213,11 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
209
213
  counters.optionalCopied++;
210
214
  counters.copied++;
211
215
  counters.copiedFiles.push(filename);
212
- trace?.event("copy", backedUp ? "replaced-bak" : "copied", filename, { group: "pr-summary" });
216
+ trace?.event("copy", backedUp ? "replaced-bak" : "copied", filename, { group: groupName });
213
217
  if (backedUp) counters.replacedBak.push(filename); // #673
214
218
  }
215
219
  }
220
+ }
216
221
 
217
222
  // (5) common/secret-backup — 있으면 무조건 스킵/신규만 복사
218
223
  const secretDir = join(commonDir, "secret-backup");
@@ -5,6 +5,7 @@
5
5
  export const DOCS_TO_REMOVE = [
6
6
  "CONTRIBUTING.md",
7
7
  "SECURITY.md",
8
+ "ARCHITECTURE.md",
8
9
  "CLAUDE.md",
9
10
  "AGENTS.md",
10
11
  "GEMINI.md",
@@ -14,6 +15,10 @@ export const DOCS_TO_REMOVE = [
14
15
  "pr_body.md",
15
16
  ];
16
17
 
18
+ // ⚠️ `.github/i18n`(한국어 템플릿 오버레이, #769)은 여기에 넣지 않는다. 이 목록은 내려받은 템플릿 폴더에서
19
+ // 항목을 지우는데, 오버레이는 language: ko 설치 때 그 폴더에서 읽어 와야 한다. 사용자 레포로는
20
+ // copyIssueTemplates 가 ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE 만 골라 복사하므로 새지 않는다.
21
+ // ("Use this template" 경로는 template_initializer.py 가 지운다.)
17
22
  export const PLUGIN_ITEMS_TO_REMOVE = [
18
23
  ".claude-plugin",
19
24
  ".codex-plugin",
@@ -25,8 +30,11 @@ export const PLUGIN_ITEMS_TO_REMOVE = [
25
30
  "bin",
26
31
  "src",
27
32
  "site",
33
+ ".github/CODEOWNERS",
28
34
  ".github/workflows/PROJECT-TEMPLATE-PLUGIN-VERSION-SYNC.yaml",
29
35
  ".github/workflows/PROJECT-TEMPLATE-NPM-PUBLISH.yaml",
30
36
  ".github/workflows/PROJECT-TEMPLATE-DOCS-DEPLOY.yaml",
37
+ ".github/workflows/PROJECT-TEMPLATE-SYNC-COMMUNITY-LABELS.yaml",
38
+ ".github/config/community-labels.yml",
31
39
  ];
32
40
  // ⚠️ skills/ 는 제외하지 않는다 (Cursor 설치 소스로 보존)
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import { existsSync, readdirSync, mkdirSync, cpSync, rmSync } from "node:fs";
6
6
  import { compareCacheName } from "../util.js";
7
7
  import { migrateConfigRoot } from "../legacy.js";
8
+ import { t } from "../../../i18n/index.js";
8
9
 
9
10
  const MARKETPLACE = "Cassiiopeia/projectops";
10
11
  const PLUGIN = "projectops@projectops-marketplace";
@@ -12,7 +13,7 @@ const LEGACY_PLUGINS = ["cassiiopeia@cassiiopeia-marketplace", "cassiiopeia"];
12
13
  const LEGACY_MARKETPLACES = ["cassiiopeia-marketplace"];
13
14
 
14
15
  function detect(io) {
15
- if (!io.which("claude")) return { installed: false, version: null, cliMissing: true, note: "CLI 없음" };
16
+ if (!io.which("claude")) return { installed: false, version: null, cliMissing: true, note: t("ide.cliMissing") };
16
17
  const r = io.run("claude", ["plugin", "list", "--json"]);
17
18
  let scope = "", version = null;
18
19
  try {
@@ -48,7 +49,7 @@ function migrateLegacy(io) {
48
49
  });
49
50
  if (hit) {
50
51
  const scope = hit.scope || "user";
51
- io.log(` 레거시 플러그인 정리: ${legacy} (scope: ${scope})`);
52
+ io.log(t("ide.claude.legacy", { name: legacy, scope }));
52
53
  io.run("claude", ["plugin", "uninstall", legacy, "--scope", scope]);
53
54
  }
54
55
  }
@@ -58,39 +59,39 @@ function migrateLegacy(io) {
58
59
  }
59
60
 
60
61
  function install(io, scope = "user") {
61
- io.log("Claude Code 마켓플레이스 등록 중...");
62
+ io.log(t("ide.claude.marketAdding"));
62
63
  const add = io.run("claude", ["plugin", "marketplace", "add", MARKETPLACE]);
63
- io.log(add.code === 0 ? " 마켓플레이스 등록 완료" : " 마켓플레이스 이미 등록되어 있거나 등록 생략");
64
- io.log(`Claude Code 플러그인 설치 중 (scope: ${scope})...`);
64
+ io.log(t(add.code === 0 ? "ide.claude.marketAdded" : "ide.claude.marketSkipped"));
65
+ io.log(t("ide.claude.installing", { scope }));
65
66
  const ins = io.run("claude", ["plugin", "install", PLUGIN, "--scope", scope]);
66
- if (ins.code === 0) { io.log(` Claude Code 플러그인 설치 완료 (scope: ${scope})`); return true; }
67
- io.log(` 플러그인 설치 실패. 수동: claude plugin install ${PLUGIN} --scope ${scope}`);
67
+ if (ins.code === 0) { io.log(t("ide.claude.installed", { scope })); return true; }
68
+ io.log(t("ide.claude.installFailed", { plugin: PLUGIN, scope }));
68
69
  return false;
69
70
  }
70
71
 
71
72
  function update(io, scope) {
72
73
  const cacheRoot = join(io.home(), ".claude/plugins/cache/projectops-marketplace/projectops");
73
74
  const oldCache = latestCacheDir(cacheRoot);
74
- io.log("플러그인 업데이트 중...");
75
+ io.log(t("ide.claude.updating"));
75
76
  const up = io.run("claude", ["plugin", "update", PLUGIN, "--scope", scope]);
76
- if (up.code !== 0) { io.log(` 업데이트 실패. 수동: claude plugin update ${PLUGIN} --scope ${scope}`); return false; }
77
- io.log(` 업데이트 완료 (scope: ${scope})`);
77
+ if (up.code !== 0) { io.log(t("ide.claude.updateFailed", { plugin: PLUGIN, scope })); return false; }
78
+ io.log(t("ide.claude.updated", { scope }));
78
79
  migrateConfig(io, oldCache, latestCacheDir(cacheRoot));
79
80
  return true;
80
81
  }
81
82
 
82
83
  function remove(io) {
83
84
  const st = detect(io);
84
- if (st.cliMissing || !st.installed) { io.log(" 설치된 Claude Code 플러그인이 없어 건너뜁니다"); return true; }
85
- io.log(` 제거할 대상: ${PLUGIN} (scope: ${st.scope})`);
85
+ if (st.cliMissing || !st.installed) { io.log(t("ide.claude.removeNone")); return true; }
86
+ io.log(t("ide.claude.removeTarget", { plugin: PLUGIN, scope: st.scope }));
86
87
  const un = io.run("claude", ["plugin", "uninstall", PLUGIN, "--scope", st.scope]);
87
- if (un.code === 0) { io.log(" 플러그인 uninstall 완료"); removePluginData(io); return true; }
88
- io.log(` 삭제 실패. 수동: claude plugin uninstall ${PLUGIN} --scope ${st.scope}`);
88
+ if (un.code === 0) { io.log(t("ide.claude.removed")); removePluginData(io); return true; }
89
+ io.log(t("ide.claude.removeFailed", { plugin: PLUGIN, scope: st.scope }));
89
90
  return false;
90
91
  }
91
92
 
92
93
  function manualHint() {
93
- return ` 💡 Claude Code 사용자: claude plugin marketplace add ${MARKETPLACE}\n claude plugin install ${PLUGIN} --scope user`;
94
+ return t("ide.claude.manual", { market: MARKETPLACE, plugin: PLUGIN });
94
95
  }
95
96
 
96
97
  // ── 내부 헬퍼 ──
@@ -107,12 +108,12 @@ function migrateConfig(io, oldCache, newCache) {
107
108
  mkdirSync(newCfg, { recursive: true });
108
109
  let copied = 0;
109
110
  for (const f of readdirSync(oldCfg)) if (f.endsWith(".json")) { cpSync(join(oldCfg, f), join(newCfg, f)); copied++; }
110
- if (copied) io.log(" config.json 마이그레이션 완료 (이전 버전 설정 유지)");
111
+ if (copied) io.log(t("ide.claude.configMigrated"));
111
112
  } catch { /* 무해 */ }
112
113
  }
113
114
  function removePluginData(io) {
114
115
  const dataDir = join(io.home(), ".claude/plugins/data", PLUGIN);
115
- if (existsSync(dataDir)) { try { rmSync(dataDir, { recursive: true, force: true }); io.log(" 플러그인 데이터(config) 삭제 완료"); } catch { /* 무해 */ } }
116
+ if (existsSync(dataDir)) { try { rmSync(dataDir, { recursive: true, force: true }); io.log(t("ide.claude.dataRemoved")); } catch { /* 무해 */ } }
116
117
  }
117
118
 
118
119
  export const claudeAdapter = {
@@ -4,6 +4,7 @@
4
4
  import { join } from "node:path";
5
5
  import { existsSync, lstatSync, rmSync } from "node:fs";
6
6
  import { migrateConfigRoot } from "../legacy.js";
7
+ import { t } from "../../../i18n/index.js";
7
8
 
8
9
  const MARKETPLACE = "Cassiiopeia/projectops";
9
10
  const PLUGIN = "projectops";
@@ -16,20 +17,20 @@ function detect(io) {
16
17
  const tgt = nativeTarget(io);
17
18
  const nativeInstalled = existsSync(tgt);
18
19
  if (nativeInstalled) return { installed: true, version: null, cliMissing: false, note: "native skills" };
19
- if (!io.which("codex")) return { installed: false, version: null, cliMissing: true, note: "CLI 없음" };
20
- return { installed: false, version: null, cliMissing: false, note: "설치 가능 (CLI 감지됨)" };
20
+ if (!io.which("codex")) return { installed: false, version: null, cliMissing: true, note: t("ide.cliMissing") };
21
+ return { installed: false, version: null, cliMissing: false, note: t("ide.installable") };
21
22
  }
22
23
 
23
24
  function apply(io) {
24
25
  if (!io.which("codex")) { io.log(manualHint()); return false; }
25
26
  migrateLegacy(io);
26
27
  migrateConfigRoot(io);
27
- io.log("Codex plugin marketplace 등록 중...");
28
+ io.log(t("ide.codex.marketAdding"));
28
29
  const add = io.run("codex", ["plugin", "marketplace", "add", MARKETPLACE]);
29
- io.log(add.code === 0 ? " Codex marketplace 등록 완료" : " Codex marketplace 이미 등록되어 있거나 등록 생략");
30
- io.log("Codex plugin marketplace 업데이트 중...");
31
- if (io.run("codex", ["plugin", "marketplace", "upgrade", PLUGIN]).code === 0) { io.log(" Codex marketplace 등록 완료 (/plugins 확인)"); return true; }
32
- io.log(` Codex marketplace 관리 오류 — 수동: codex plugin marketplace add ${MARKETPLACE}`);
30
+ io.log(t(add.code === 0 ? "ide.codex.marketAdded" : "ide.codex.marketSkipped"));
31
+ io.log(t("ide.codex.marketUpgrading"));
32
+ if (io.run("codex", ["plugin", "marketplace", "upgrade", PLUGIN]).code === 0) { io.log(t("ide.codex.marketDone")); return true; }
33
+ io.log(t("ide.codex.marketError", { market: MARKETPLACE }));
33
34
  return false;
34
35
  }
35
36
 
@@ -37,10 +38,10 @@ function remove(io) {
37
38
  const tgt = nativeTarget(io);
38
39
  let removed = false;
39
40
  if (existsSync(tgt) || isSymlink(tgt)) {
40
- try { rmSync(tgt, { recursive: true, force: true }); io.log(` Codex native skills 제거 완료 (${tgt})`); removed = true; } catch { /* 무해 */ }
41
+ try { rmSync(tgt, { recursive: true, force: true }); io.log(t("ide.codex.skillsRemoved", { path: tgt })); removed = true; } catch { /* 무해 */ }
41
42
  }
42
- if (!removed) io.log(" 제거할 Codex skills가 없어 건너뜁니다");
43
- if (io.which("codex")) io.log(` marketplace 등록 해제는 수동: codex plugin marketplace remove ${PLUGIN}`);
43
+ if (!removed) io.log(t("ide.codex.removeNone"));
44
+ if (io.which("codex")) io.log(t("ide.codex.unregisterManual", { plugin: PLUGIN }));
44
45
  return true;
45
46
  }
46
47
 
@@ -51,7 +52,7 @@ function migrateLegacy(io) {
51
52
  for (const name of LEGACY_NATIVES) {
52
53
  const old = join(io.home(), ".agents/skills", name);
53
54
  if (existsSync(old) || isSymlink(old)) {
54
- try { rmSync(old, { recursive: true, force: true }); io.log(` 레거시 Codex skills 정리: ${name}`); } catch { /* 무시 */ }
55
+ try { rmSync(old, { recursive: true, force: true }); io.log(t("ide.codex.legacy", { name })); } catch { /* 무시 */ }
55
56
  }
56
57
  }
57
58
  if (io.which("codex")) for (const mp of LEGACY_MARKETPLACES) io.run("codex", ["plugin", "marketplace", "remove", mp]);
@@ -4,6 +4,7 @@
4
4
  import { join } from "node:path";
5
5
  import { existsSync, readFileSync, mkdirSync, cpSync, rmSync, writeFileSync, readdirSync } from "node:fs";
6
6
  import { migrateConfigRoot, isLegacyVersion } from "../legacy.js";
7
+ import { t } from "../../../i18n/index.js";
7
8
 
8
9
  const LEGACY_NAMES = ["cassiiopeia", "suh-devops-template"];
9
10
  const LEGACY_MAX_VERSION = "4.2.4";
@@ -30,9 +31,9 @@ function apply(io, ctx = {}) {
30
31
  migrateLegacy(io, ctx); // 옛 이름/버전 감지 시 projectops 소유 항목 선별 삭제
31
32
  migrateConfigRoot(io); // 공용 config 루트 이관
32
33
  const src = resolveSkillsSrc(ctx);
33
- if (!src) { io.log(" 설치할 스킬 소스를 찾지 못했습니다 (skills/ 폴더 필요)."); return false; }
34
+ if (!src) { io.log(t("ide.cursor.noSource")); return false; }
34
35
  const dest = join(io.home(), ".cursor/skills");
35
- io.log("Cursor Skills 복사 중...");
36
+ io.log(t("ide.cursor.copying"));
36
37
  try {
37
38
  mkdirSync(dest, { recursive: true });
38
39
  for (const e of readdirSync(src, { withFileTypes: true })) {
@@ -52,28 +53,28 @@ function apply(io, ctx = {}) {
52
53
  if (!/^(pro|suh)-/.test(name)) continue;
53
54
  try {
54
55
  rmSync(join(dest, name), { recursive: true, force: true });
55
- io.log(` 폐기된 스킬 제거: ${name}`);
56
+ io.log(t("ide.cursor.obsolete", { name }));
56
57
  } catch { /* 무시 — 지우지 못해도 설치는 계속한다 */ }
57
58
  }
58
59
  writeMeta(io, dest, ctx.templateVersion);
59
- io.log(` Cursor Skills 설치 완료 (${dest}/, v${ctx.templateVersion || "unknown"})`);
60
+ io.log(t("ide.cursor.installed", { dest, version: ctx.templateVersion || "unknown" }));
60
61
  return true;
61
62
  } catch {
62
- io.log(" Cursor Skills 복사 실패 — skills/ 폴더를 확인하세요.");
63
+ io.log(t("ide.cursor.copyFailed"));
63
64
  return false;
64
65
  }
65
66
  }
66
67
 
67
68
  function remove(io, ctx = {}) {
68
69
  const dir = join(io.home(), ".cursor/skills");
69
- if (!existsSync(join(dir, "cursor-skills-meta.json"))) { io.log(" 설치된 Cursor Skills가 없어 건너뜁니다"); return true; }
70
+ if (!existsSync(join(dir, "cursor-skills-meta.json"))) { io.log(t("ide.cursor.removeNone")); return true; }
70
71
  try {
71
72
  for (const name of ownedEntries(io, ctx)) rmSync(join(dir, name), { recursive: true, force: true });
72
73
  // 폴더가 비면 폴더 자체 제거, 타 스킬(somansa-tools 등) 남으면 유지
73
74
  if (existsSync(dir) && readdirSync(dir).length === 0) rmSync(dir, { recursive: true, force: true });
74
- io.log(` Cursor Skills 제거 완료 (projectops 소유만, ${dir}/)`);
75
+ io.log(t("ide.cursor.removed", { dir }));
75
76
  return true;
76
- } catch { io.log(` Cursor Skills 제거 실패 — 수동 확인: ${dir}`); return false; }
77
+ } catch { io.log(t("ide.cursor.removeFailed", { dir })); return false; }
77
78
  }
78
79
 
79
80
  // projectops가 이 폴더에 설치했다고 볼 항목만 골라낸다.
@@ -103,11 +104,11 @@ function migrateLegacy(io, ctx) {
103
104
  for (const name of ownedEntries(io, ctx)) {
104
105
  try { rmSync(join(io.home(), ".cursor/skills", name), { recursive: true, force: true }); } catch { /* 무시 */ }
105
106
  }
106
- io.log(` 레거시 Cursor Skills 정리(선별): name=${meta.name}, v=${meta.version} → 재설치`);
107
+ io.log(t("ide.cursor.legacy", { name: meta.name, version: meta.version }));
107
108
  }
108
109
 
109
110
  function manualHint() {
110
- return " 💡 Cursor: skills/ 폴더를 ~/.cursor/skills/ 로 복사하면 됩니다.";
111
+ return t("ide.cursor.manual");
111
112
  }
112
113
 
113
114
  // ── 헬퍼 ──
@@ -1,33 +1,34 @@
1
1
  // Gemini CLI 어댑터 (.sh _manage_gemini_extension / _remove_gemini_section 등가).
2
2
  // extension install/update/uninstall. 마켓플레이스 대신 git URL 확장.
3
3
  import { migrateConfigRoot } from "../legacy.js";
4
+ import { t } from "../../../i18n/index.js";
4
5
 
5
6
  const EXT = "projectops";
6
7
  const URL = "https://github.com/Cassiiopeia/projectops";
7
8
  const LEGACY_EXTS = ["SUH-DEVOPS-TEMPLATE"];
8
9
 
9
10
  function detect(io) {
10
- if (!io.which("gemini")) return { installed: false, version: null, cliMissing: true, note: "CLI 없음" };
11
+ if (!io.which("gemini")) return { installed: false, version: null, cliMissing: true, note: t("ide.cliMissing") };
11
12
  // .sh는 gemini 설치 상태를 정밀 조회하지 않고 "설치 가능"으로만 표기 → installed 미상.
12
- return { installed: false, version: null, cliMissing: false, note: "설치 가능 (CLI 감지됨)" };
13
+ return { installed: false, version: null, cliMissing: false, note: t("ide.installable") };
13
14
  }
14
15
 
15
16
  function apply(io) {
16
17
  if (!io.which("gemini")) { io.log(manualHint()); return false; }
17
18
  migrateLegacy(io);
18
19
  migrateConfigRoot(io);
19
- io.log("Gemini CLI extension 업데이트 중...");
20
- if (io.run("gemini", ["extensions", "update", EXT]).code === 0) { io.log(" Gemini CLI extension 업데이트 완료"); return true; }
21
- io.log("Gemini CLI extension 설치 중...");
22
- if (io.run("gemini", ["extensions", "install", URL]).code === 0) { io.log(" Gemini CLI extension 설치 완료"); return true; }
23
- io.log(` Gemini extension 관리 오류 — 수동: gemini extensions install ${URL}`);
20
+ io.log(t("ide.gemini.updating"));
21
+ if (io.run("gemini", ["extensions", "update", EXT]).code === 0) { io.log(t("ide.gemini.updated")); return true; }
22
+ io.log(t("ide.gemini.installing"));
23
+ if (io.run("gemini", ["extensions", "install", URL]).code === 0) { io.log(t("ide.gemini.installed")); return true; }
24
+ io.log(t("ide.gemini.error", { url: URL }));
24
25
  return false;
25
26
  }
26
27
 
27
28
  function remove(io) {
28
- if (!io.which("gemini")) { io.log(" gemini CLI 미감지 — 건너뜁니다"); return true; }
29
- if (io.run("gemini", ["extensions", "uninstall", EXT]).code === 0) { io.log(" Gemini CLI extension 제거 완료"); return true; }
30
- io.log(` 제거할 Gemini extension이 없거나 실패 — 수동: gemini extensions uninstall ${EXT}`);
29
+ if (!io.which("gemini")) { io.log(t("ide.gemini.notFound")); return true; }
30
+ if (io.run("gemini", ["extensions", "uninstall", EXT]).code === 0) { io.log(t("ide.gemini.removed")); return true; }
31
+ io.log(t("ide.gemini.removeFailed", { ext: EXT }));
31
32
  return true; // 미설치 제거는 실패로 보지 않음
32
33
  }
33
34
 
@@ -3,6 +3,7 @@
3
3
  // _pi_settings_path / _pi_harness_enabled 등가.
4
4
  import { join } from "node:path";
5
5
  import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
6
+ import { t } from "../../../i18n/index.js";
6
7
 
7
8
  export const PI_PACKAGE_URL = "https://github.com/Cassiiopeia/projectops";
8
9
 
@@ -80,5 +81,5 @@ export function migratePiLegacy(io) {
80
81
  }
81
82
  } catch { /* 무시 */ }
82
83
  }
83
- try { rmSync(oldDir, { recursive: true, force: true }); io.log(" 레거시 PI clone 정리: SUH-DEVOPS-TEMPLATE"); } catch { /* 무시 */ }
84
+ try { rmSync(oldDir, { recursive: true, force: true }); io.log(t("ide.pi.legacyClone")); } catch { /* 무시 */ }
84
85
  }
@@ -3,43 +3,40 @@
3
3
  // PI skill과 독립 — skill은 그대로 두고 harness 등록(settings.json extensions)만 켜고/끈다.
4
4
  import { existsSync } from "node:fs";
5
5
  import { harnessEnabled, harnessAdd, harnessRemove, harnessLoaderPath } from "./pi-common.js";
6
+ import { t } from "../../../i18n/index.js";
6
7
 
7
8
  // 이 어댑터는 "설치 가능" 여부가 pi 존재 + loader 파일 존재에 달림.
8
9
  function available(io) { return !!io.which("pi") && existsSync(harnessLoaderPath(io)); }
9
10
 
10
11
  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) ? "활성화됨" : "비활성화" };
12
+ if (!io.which("pi")) return { installed: false, version: null, cliMissing: true, note: t("ide.harness.noPi") };
13
+ if (!existsSync(harnessLoaderPath(io))) return { installed: false, version: null, cliMissing: true, note: t("ide.harness.noLoader") };
14
+ return { installed: harnessEnabled(io), version: null, cliMissing: false, note: t(harnessEnabled(io) ? "ide.harness.enabled" : "ide.harness.disabled") };
14
15
  }
15
16
 
16
17
  // apply = harness 활성화 (skill은 안 건드림).
17
18
  function apply(io) {
18
- if (!available(io)) { io.log(" harness loader가 없습니다 — 먼저 PI를 설치하세요."); return false; }
19
- if (harnessEnabled(io)) { io.log(" Persona Harness: 이미 활성화됨 (유지)"); return true; }
19
+ if (!available(io)) { io.log(t("ide.harness.needPi")); return false; }
20
+ if (harnessEnabled(io)) { io.log(t("ide.harness.already")); return true; }
20
21
  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 패키지를 설치/업데이트하세요.");
22
+ if (r.ok) { io.log(t("ide.harness.enabledDone")); return true; }
23
+ io.log(t(r.reason === "no-settings" ? "ide.harness.noSettings" : "ide.harness.needPackage"));
24
24
  return false;
25
25
  }
26
26
 
27
27
  // remove = harness만 해제 (PI skill 보존).
28
28
  function remove(io) {
29
- if (!harnessEnabled(io)) { io.log(" Persona Harness가 활성화돼 있지 않아 건너뜁니다"); return true; }
30
- io.log(" PI skill은 그대로 두고 harness 등록만 해제합니다.");
29
+ if (!harnessEnabled(io)) { io.log(t("ide.harness.removeNone")); return true; }
30
+ io.log(t("ide.harness.keepSkill"));
31
31
  harnessRemove(io);
32
- io.log(" Persona Harness 해제 완료 — PI 재시작 후 적용됩니다.");
32
+ io.log(t("ide.harness.removed"));
33
33
  return true;
34
34
  }
35
35
 
36
- function manualHint() { return " 💡 PI Persona Harness: PI 설치 후 settings.json extensions 에 harness-loader.ts 등록"; }
36
+ function manualHint() { return t("ide.harness.manual"); }
37
37
 
38
38
  // harness 설명 (프롬프트에서 재사용).
39
- export const HARNESS_DESC = [
40
- "Persona Harness는 PI가 대화를 시작할 때마다 '전문가 페르소나'와 'SDLC 워크플로우'를",
41
- "시스템 프롬프트에 자동 주입하는 기능입니다 (skill과 독립적으로 켜고/끌 수 있음).",
42
- ].join("\n");
39
+ export const harnessDesc = () => t("ide.harness.desc");
43
40
 
44
41
  export const piHarnessAdapter = {
45
42
  id: "pi-harness", label: "PI Persona Harness", order: 60,
@@ -2,9 +2,10 @@
2
2
  // pi install/update/remove. skill은 복사되지 않고 pi가 startup마다 스캔 → 설치 검증은 'pi list'.
3
3
  import { PI_PACKAGE_URL, piInstalled, harnessEnabled, harnessRemove, migratePiLegacy } from "./pi-common.js";
4
4
  import { migrateConfigRoot } from "../legacy.js";
5
+ import { t } from "../../../i18n/index.js";
5
6
 
6
7
  function detect(io) {
7
- if (!io.which("pi")) return { installed: false, version: null, cliMissing: true, note: "CLI 없음" };
8
+ if (!io.which("pi")) return { installed: false, version: null, cliMissing: true, note: t("ide.cliMissing") };
8
9
  return { installed: piInstalled(io), version: null, cliMissing: false };
9
10
  }
10
11
 
@@ -13,30 +14,30 @@ function apply(io) {
13
14
  migratePiLegacy(io);
14
15
  migrateConfigRoot(io);
15
16
  if (piInstalled(io)) {
16
- io.log("PI 패키지 업데이트 중...");
17
+ io.log(t("ide.pi.updating"));
17
18
  if (io.run("pi", ["update", PI_PACKAGE_URL]).code !== 0) io.run("pi", ["install", PI_PACKAGE_URL]);
18
19
  } else {
19
- io.log("PI 패키지 설치 중...");
20
+ io.log(t("ide.pi.installing"));
20
21
  io.run("pi", ["install", PI_PACKAGE_URL]);
21
22
  }
22
23
  if (piInstalled(io)) {
23
- io.log(" PI 패키지 설치 / 업데이트 완료");
24
- io.log(" → 'pi' 재실행 후 'pi list' 로 확인, 채팅창에서 /projectops:analyze 등 호출");
24
+ io.log(t("ide.pi.done"));
25
+ io.log(t("ide.pi.afterInstall"));
25
26
  return true;
26
27
  }
27
- io.log(` PI 설치/업데이트 실패 — 수동: pi install ${PI_PACKAGE_URL}`);
28
+ io.log(t("ide.pi.failed", { url: PI_PACKAGE_URL }));
28
29
  return false;
29
30
  }
30
31
 
31
32
  function remove(io) {
32
- if (!io.which("pi")) { io.log(" pi CLI 미감지 — 건너뜁니다"); return true; }
33
- if (!piInstalled(io)) { io.log(" 설치된 PI 패키지가 없어 건너뜁니다"); return true; }
33
+ if (!io.which("pi")) { io.log(t("ide.pi.notFound")); return true; }
34
+ if (!piInstalled(io)) { io.log(t("ide.pi.removeNone")); return true; }
34
35
  io.log(` pi remove ${PI_PACKAGE_URL}`);
35
36
  io.run("pi", ["remove", PI_PACKAGE_URL]);
36
- if (piInstalled(io)) io.log(" 제거 후에도 패키지가 남아있습니다 — 'pi list'로 확인하세요.");
37
- else io.log(" PI 패키지 제거 완료");
37
+ if (piInstalled(io)) io.log(t("ide.pi.stillThere"));
38
+ else io.log(t("ide.pi.removed"));
38
39
  // package 클론이 사라지면 harness loader 경로가 허공을 가리킴 → 함께 해제
39
- if (harnessEnabled(io)) { io.log(" Persona Harness 등록도 함께 해제됩니다."); harnessRemove(io); }
40
+ if (harnessEnabled(io)) { io.log(t("ide.pi.harnessAlso")); harnessRemove(io); }
40
41
  return true;
41
42
  }
42
43
 
@@ -0,0 +1,78 @@
1
+ // 상태 라벨 표기(#776): 영문 표준("en")과 기존 한글("ko").
2
+ // 템플릿 원본은 영문 하나다. 한글 모드는 복사 직후 이름만 되돌려 만든다 —
3
+ // 한글 사본을 따로 두면 두 벌이 어긋난다.
4
+ import { join } from "node:path";
5
+ import { existsSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
6
+
7
+ export const LABEL_STYLES = ["en", "ko"];
8
+
9
+ // [영문 표준, 한글 기존]
10
+ export const LABEL_PAIRS = [
11
+ ["status: todo", "작업전"],
12
+ ["status: in progress", "작업중"],
13
+ ["status: needs review", "담당자확인"],
14
+ ["status: feedback", "피드백"],
15
+ ["status: done", "작업완료"],
16
+ ["status: on hold", "보류"],
17
+ ["status: cancelled", "취소"],
18
+ ["priority: urgent", "긴급"],
19
+ ["documentation", "문서"],
20
+ ];
21
+ const EN_TO_KO = new Map(LABEL_PAIRS);
22
+
23
+ // version.yml 에 저장값이 없을 때의 결정. 신규 설치는 영문, 이미 설치된 레포는 한글 유지.
24
+ // 이미 쓰는 레포의 라벨 이름이 업데이트만으로 바뀌면 Projects 보드 매핑이 조용히 멈추기 때문이다.
25
+ export function resolveLabelStyle({ flag, stored, existing }) {
26
+ if (LABEL_STYLES.includes(flag)) return flag;
27
+ if (LABEL_STYLES.includes(stored)) return stored;
28
+ return existing ? "ko" : "en";
29
+ }
30
+
31
+ function rewrite(file, fn) {
32
+ if (!existsSync(file)) return false;
33
+ const before = readFileSync(file, "utf8");
34
+ const after = fn(before);
35
+ if (after === before) return false;
36
+ writeFileSync(file, after);
37
+ return true;
38
+ }
39
+
40
+ const quoted = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
41
+
42
+ // 한글 모드: 복사된 파일의 라벨 이름을 한글로 되돌린다. 영문 모드는 아무것도 하지 않는다.
43
+ // 반환: 바뀐 파일의 상대경로 목록.
44
+ export function applyLabelStyle(targetRoot, style) {
45
+ if (style !== "ko") return [];
46
+ const changed = [];
47
+ const gh = join(targetRoot, ".github");
48
+
49
+ // 라벨 정의: name 줄만 바꾸고, 이름 변경용 from_name 줄은 지운다(이미 한글이라 필요 없다).
50
+ const labelsFile = join(gh, "config", "issue-labels.yml");
51
+ if (rewrite(labelsFile, (txt) => {
52
+ let out = txt.replace(/^\s*from_name:.*\n/gm, "");
53
+ for (const [en, ko] of LABEL_PAIRS) {
54
+ out = out.replace(new RegExp(`^(- name:\\s*)"?${quoted(en)}"?\\s*$`, "gm"), `$1${ko}`);
55
+ }
56
+ return out;
57
+ })) changed.push(".github/config/issue-labels.yml");
58
+
59
+ // 이슈 템플릿 기본 라벨: front matter 의 labels 줄만 대상으로 한다.
60
+ const tplDir = join(gh, "ISSUE_TEMPLATE");
61
+ if (existsSync(tplDir)) {
62
+ for (const name of readdirSync(tplDir).filter((f) => f.endsWith(".md"))) {
63
+ const f = join(tplDir, name);
64
+ if (rewrite(f, (txt) => txt.replace(/^labels:\s*\[(.*)\]\s*$/m, (m, inner) => {
65
+ let list = inner;
66
+ for (const [en, ko] of LABEL_PAIRS) list = list.split(`"${en}"`).join(ko).split(`'${en}'`).join(ko);
67
+ return `labels: [${list}]`;
68
+ }))) changed.push(`.github/ISSUE_TEMPLATE/${name}`);
69
+ }
70
+ }
71
+
72
+ // QA 이슈 생성 봇이 붙이는 기본 라벨
73
+ const bot = join(gh, "workflows", "PROJECT-COMMON-QA-ISSUE-CREATION-BOT.yaml");
74
+ if (rewrite(bot, (txt) => txt.replace(/labels:\s*\['status: todo'\]/g, `labels: ['${EN_TO_KO.get("status: todo")}']`))) {
75
+ changed.push(".github/workflows/PROJECT-COMMON-QA-ISSUE-CREATION-BOT.yaml");
76
+ }
77
+ return changed;
78
+ }