revdeck 0.1.0 → 0.2.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.
package/dist/cli.js CHANGED
@@ -47,9 +47,31 @@ function socketPath() {
47
47
  function daemonInfoPath() {
48
48
  return path.join(stateDir(), "daemon.json");
49
49
  }
50
+ function devcontainerBridgeDir() {
51
+ if (process.env.REVDECK_DEVCONTAINER_DIR) return path.resolve(process.env.REVDECK_DEVCONTAINER_DIR);
52
+ return path.join(os.homedir(), ".local", "state", "revdeck", "devcontainer");
53
+ }
54
+ function devcontainerRunDir() {
55
+ return path.join(devcontainerBridgeDir(), "run");
56
+ }
57
+ function devcontainerStateDir() {
58
+ return path.join(devcontainerBridgeDir(), "state");
59
+ }
60
+ function devcontainerSocketPath() {
61
+ return path.join(devcontainerRunDir(), "daemon.sock");
62
+ }
50
63
  function logPath() {
51
64
  return path.join(stateDir(), "daemon.log");
52
65
  }
66
+ async function ensureDir(dir, mode = 448) {
67
+ await mkdir(dir, { recursive: true, mode });
68
+ const info = await lstat(dir);
69
+ if (!info.isDirectory()) throw new Error(`${dir}: expected a directory`);
70
+ if (process.platform !== "win32") {
71
+ const { chmod } = await import("node:fs/promises");
72
+ await chmod(dir, mode);
73
+ }
74
+ }
53
75
  async function readTextNoFollow(file, maxBytes = 4 * 1024 * 1024) {
54
76
  const info = await lstat(file);
55
77
  if (info.isSymbolicLink()) throw new Error(`${path.basename(file)}: symbolic link, read refused`);
@@ -146,7 +168,7 @@ var VERSION;
146
168
  var init_version = __esm({
147
169
  "src/version.ts"() {
148
170
  "use strict";
149
- VERSION = "0.1.0";
171
+ VERSION = "0.2.1";
150
172
  }
151
173
  });
152
174
 
@@ -182,6 +204,19 @@ async function daemonInfo() {
182
204
  return null;
183
205
  }
184
206
  }
207
+ function isDevContainer() {
208
+ return process.env.REVDECK_DEV_CONTAINER === "1";
209
+ }
210
+ function devcontainerHint() {
211
+ const runtime = process.env.REVDECK_RUNTIME_DIR ?? "(REVDECK_RUNTIME_DIR)";
212
+ const state = process.env.REVDECK_STATE_DIR ?? "(REVDECK_STATE_DIR)";
213
+ return [
214
+ "\u0434\u0435\u043C\u043E\u043D revdeck \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D \u043D\u0430 \u0445\u043E\u0441\u0442\u0435",
215
+ "\u043D\u0430 \u0445\u043E\u0441\u0442\u0435: revdeck daemon start",
216
+ `\u043F\u0440\u043E\u0432\u0435\u0440\u044C\u0442\u0435 mounts \u0438\u0437 revdeck devcontainer --write: ${runtime} \u0438 ${state}`,
217
+ "gh/glab \u0434\u043E\u043B\u0436\u043D\u044B \u043E\u0441\u0442\u0430\u0432\u0430\u0442\u044C\u0441\u044F \u043D\u0430 \u0445\u043E\u0441\u0442\u0435: \u0432\u043D\u0443\u0442\u0440\u0438 \u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440\u0430 \u0438\u0445 \u0437\u0430\u043F\u0443\u0441\u043A\u0430\u0442\u044C \u043D\u0435 \u043D\u0443\u0436\u043D\u043E"
218
+ ].join("; ");
219
+ }
185
220
  async function ensureDaemon(options = {}) {
186
221
  const url = process.env.REVDECK_URL;
187
222
  if (url) {
@@ -192,9 +227,12 @@ async function ensureDaemon(options = {}) {
192
227
  try {
193
228
  return { client: await connectSocket(), started: false };
194
229
  } catch (error) {
230
+ if (isDevContainer()) {
231
+ throw new RpcError(`devcontainer: ${devcontainerHint()}`, "DEVCONTAINER", "revdeck devcontainer --write \u0438 \u043F\u0435\u0440\u0435\u0441\u043E\u0431\u0435\u0440\u0438\u0442\u0435 \u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440");
232
+ }
195
233
  if (options.autoStart === false) throw error;
196
234
  }
197
- await spawnDaemon();
235
+ await spawnDaemon(options.daemonArgs ?? []);
198
236
  const deadline = Date.now() + 15e3;
199
237
  let lastError = null;
200
238
  while (Date.now() < deadline) {
@@ -211,9 +249,9 @@ async function ensureDaemon(options = {}) {
211
249
  "\u0437\u0430\u043F\u0443\u0441\u0442\u0438\u0442\u0435 \u0432\u0440\u0443\u0447\u043D\u0443\u044E: revdeck daemon start --foreground"
212
250
  );
213
251
  }
214
- async function spawnDaemon() {
252
+ async function spawnDaemon(extraArgs = []) {
215
253
  const entry = daemonEntry();
216
- const child = spawn(entry.command, entry.args, {
254
+ const child = spawn(entry.command, [...entry.args, ...extraArgs], {
217
255
  detached: true,
218
256
  stdio: "ignore",
219
257
  cwd: process.cwd(),
@@ -234,6 +272,11 @@ function daemonEntry() {
234
272
  if (existsSync4(sibling)) return { command: process.execPath, args: [sibling] };
235
273
  throw new RpcError("\u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D \u0432\u0445\u043E\u0434 \u0434\u0435\u043C\u043E\u043D\u0430 (dist/daemon.js)", "DAEMON");
236
274
  }
275
+ function uiBaseUrl(info) {
276
+ const host = process.env.REVDECK_UI_HOST;
277
+ if (!isDevContainer() || !host) return info.url;
278
+ return `http://${host}:${info.port}`;
279
+ }
237
280
  async function isDaemonRunning() {
238
281
  try {
239
282
  const client2 = await connectSocket(void 0, 400);
@@ -1058,10 +1101,10 @@ ${failed.map((item) => `- ${item.id}: ${item.error}`).join("\n")}` : ""),
1058
1101
 
1059
1102
  // src/cli/main.ts
1060
1103
  init_paths();
1061
- import { readFile as readFile5 } from "node:fs/promises";
1104
+ import { readFile as readFile7, stat } from "node:fs/promises";
1062
1105
  import { readFileSync } from "node:fs";
1063
1106
  import { parseArgs } from "node:util";
1064
- import path7 from "node:path";
1107
+ import path9 from "node:path";
1065
1108
 
1066
1109
  // src/core/config.ts
1067
1110
  init_paths();
@@ -1134,8 +1177,8 @@ function globToRegExp(glob) {
1134
1177
  function escapeRegExp(value) {
1135
1178
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1136
1179
  }
1137
- function classifyFile(path8) {
1138
- const lower = path8.toLowerCase();
1180
+ function classifyFile(path10) {
1181
+ const lower = path10.toLowerCase();
1139
1182
  if (/(^|\/)(node_modules|dist|build|coverage|vendor)\//.test(lower)) return "generated";
1140
1183
  if (/\.(md|mdx|rst|txt|adoc)$/.test(lower) || /(^|\/)(docs?|adr)\//.test(lower)) return "docs";
1141
1184
  if (/(^|\/)(__tests__|tests?|spec|e2e)\//.test(lower) || /\.(test|spec)\.[cm]?[jt]sx?$/.test(lower) || /_test\.go$/.test(lower)) return "test";
@@ -1556,7 +1599,7 @@ init_format();
1556
1599
  // src/cli/init.ts
1557
1600
  init_paths();
1558
1601
  import { existsSync as existsSync5 } from "node:fs";
1559
- import { appendFile, cp, mkdir as mkdir2, readFile as readFile3, writeFile } from "node:fs/promises";
1602
+ import { appendFile, cp, lstat as lstat2, mkdir as mkdir2, readdir, readFile as readFile3, readlink, rm, symlink, writeFile } from "node:fs/promises";
1560
1603
  import path5 from "node:path";
1561
1604
  init_redact();
1562
1605
  init_locale();
@@ -1604,8 +1647,9 @@ async function runInit(rootInput, options) {
1604
1647
  await ensureGitignore(root);
1605
1648
  const adapters = options.adapters.length ? options.adapters : detectAdapters(root);
1606
1649
  const mcp = mcpServer(options.locale);
1650
+ const canonicalSkill = await installSkill(root, options.locale);
1607
1651
  for (const adapter of adapters) {
1608
- await installAdapter(root, adapter, mcp, options.locale);
1652
+ await installAdapter(root, adapter, mcp, options.locale, canonicalSkill);
1609
1653
  }
1610
1654
  if (!options.noService) {
1611
1655
  writers.out("");
@@ -1618,6 +1662,7 @@ async function runInit(rootInput, options) {
1618
1662
  writers.out(` revdeck open ${tr("<\u043D\u043E\u043C\u0435\u0440 \u0438\u043B\u0438 \u0441\u0441\u044B\u043B\u043A\u0430>", "<number or URL>")}`);
1619
1663
  writers.out(" revdeck ui");
1620
1664
  }
1665
+ var CANONICAL_SKILL = ".agents/skills/revdeck";
1621
1666
  function commandMd(locale2) {
1622
1667
  if (locale2 === "ru") {
1623
1668
  return `---
@@ -1643,10 +1688,10 @@ function commandToml(locale2) {
1643
1688
  return 'description = "Open a MR/PR review and work through the discussion"\nprompt = """Run `revdeck context <number>` (or MCP revdeck_context) and follow the revdeck skill."""\n';
1644
1689
  }
1645
1690
  var SPECS = {
1646
- agents: { skills: ".agents/skills/revdeck" },
1647
- dsh: { skills: ".agents/skills/revdeck" },
1691
+ agents: {},
1692
+ dsh: {},
1648
1693
  claude: {
1649
- skills: ".claude/skills/revdeck",
1694
+ linkSkill: true,
1650
1695
  mcp: ".mcp.json",
1651
1696
  files: [{ path: ".claude/commands/revdeck.md", content: commandMd }]
1652
1697
  },
@@ -1670,25 +1715,13 @@ function detectAdapters(root) {
1670
1715
  if (existsSync5(path5.join(root, ".opencode"))) adapters.add("opencode");
1671
1716
  return [...adapters];
1672
1717
  }
1673
- async function installAdapter(root, adapter, mcp, locale2) {
1718
+ async function installAdapter(root, adapter, mcp, locale2, canonicalSkill) {
1674
1719
  const spec = SPECS[adapter];
1675
1720
  if (!spec) {
1676
1721
  writers.err(`! ${tr("\u043D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u044B\u0439 \u0430\u0434\u0430\u043F\u0442\u0435\u0440", "unknown adapter")}: ${adapter} (${Object.keys(SPECS).join(", ")})`);
1677
1722
  return;
1678
1723
  }
1679
- if (spec.skills) {
1680
- const skills = path5.join(packageRoot(), "skills", "revdeck", locale2);
1681
- if (!existsSync5(skills)) {
1682
- writers.err(`! ${tr("\u043A\u0430\u0442\u0430\u043B\u043E\u0433 skills/revdeck \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D: \u043F\u0430\u043A\u0435\u0442 \u0441\u043E\u0431\u0440\u0430\u043D \u043D\u0435 \u043F\u043E\u043B\u043D\u043E\u0441\u0442\u044C\u044E", "skills/revdeck not found: the package is incomplete")}`);
1683
- } else {
1684
- const target = path5.join(root, spec.skills);
1685
- await mkdir2(target, { recursive: true });
1686
- for (const entry of await skillFiles(skills)) {
1687
- await cp(path5.join(skills, entry), path5.join(target, entry), { force: true });
1688
- }
1689
- writers.out(`\u2714 ${tr("\u0441\u043A\u0438\u043B\u043B", "skill")}: ${path5.relative(root, target)}`);
1690
- }
1691
- }
1724
+ if (spec.linkSkill && canonicalSkill) await linkSkill(root, canonicalSkill);
1692
1725
  if (spec.mcp) await mergeMcpJson(path5.join(root, spec.mcp), root, mcp);
1693
1726
  for (const file of spec.files ?? []) {
1694
1727
  const target = path5.join(root, file.path);
@@ -1714,9 +1747,67 @@ async function installAdapter(root, adapter, mcp, locale2) {
1714
1747
  writers.out(` ${tr("\u041A\u043E\u043C\u0430\u043D\u0434\u0430 /revdeck \u0432 DSH \u043F\u043E\u044F\u0432\u0438\u0442\u0441\u044F \u0438\u0437 \u043F\u043B\u0430\u0433\u0438\u043D\u0430-\u043E\u0431\u0451\u0440\u0442\u043A\u0438; MCP-\u0438\u043D\u0441\u0442\u0440\u0443\u043C\u0435\u043D\u0442\u044B \u0434\u043E\u0441\u0442\u0443\u043F\u043D\u044B \u0441\u0440\u0430\u0437\u0443.", "A /revdeck command in DSH requires a host plugin; the MCP tools are available immediately.")}`);
1715
1748
  }
1716
1749
  }
1717
- async function skillFiles(dir) {
1718
- const names = ["SKILL.md", "Review.md", "Feedback.md", "Criteria.md", "Rules.md", "Sources.md"];
1719
- return names.filter((name) => existsSync5(path5.join(dir, name)));
1750
+ async function installSkill(root, locale2) {
1751
+ const source = path5.join(packageRoot(), "skills", "revdeck", locale2);
1752
+ if (!existsSync5(source)) {
1753
+ writers.err(`! ${tr("\u043A\u0430\u0442\u0430\u043B\u043E\u0433 skills/revdeck \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D: \u043F\u0430\u043A\u0435\u0442 \u0441\u043E\u0431\u0440\u0430\u043D \u043D\u0435 \u043F\u043E\u043B\u043D\u043E\u0441\u0442\u044C\u044E", "skills/revdeck not found: the package is incomplete")}`);
1754
+ return null;
1755
+ }
1756
+ const target = path5.join(root, CANONICAL_SKILL);
1757
+ await copySkill(source, target);
1758
+ writers.out(`\u2714 ${tr("\u0441\u043A\u0438\u043B\u043B", "skill")}: ${CANONICAL_SKILL}`);
1759
+ return target;
1760
+ }
1761
+ async function copySkill(source, target) {
1762
+ await mkdir2(target, { recursive: true });
1763
+ for (const entry of await skillFiles(source)) {
1764
+ const file = path5.join(target, entry);
1765
+ await mkdir2(path5.dirname(file), { recursive: true });
1766
+ await cp(path5.join(source, entry), file, { force: true });
1767
+ }
1768
+ }
1769
+ async function linkSkill(root, canonicalSkill) {
1770
+ const target = path5.join(root, ".claude", "skills", "revdeck");
1771
+ const relative = path5.relative(path5.dirname(target), canonicalSkill);
1772
+ await mkdir2(path5.dirname(target), { recursive: true });
1773
+ const existing = await lstat2(target).catch(() => null);
1774
+ if (existing) {
1775
+ if (existing.isSymbolicLink()) {
1776
+ const current2 = await readlink(target).catch(() => "");
1777
+ if (current2 === relative) {
1778
+ writers.out(`\u2714 ${tr("\u0441\u043A\u0438\u043B\u043B", "skill")}: .claude/skills/revdeck -> ${CANONICAL_SKILL}`);
1779
+ return;
1780
+ }
1781
+ await rm(target, { force: true });
1782
+ } else if (existing.isDirectory() && await isManagedSkillDir(target)) {
1783
+ await rm(target, { recursive: true, force: true });
1784
+ } else {
1785
+ writers.out(`! .claude/skills/revdeck ${tr("\u0443\u0436\u0435 \u0435\u0441\u0442\u044C: \u043D\u0435 \u043F\u0435\u0440\u0435\u0437\u0430\u043F\u0438\u0441\u044B\u0432\u0430\u044E", "already exists: not overwriting")}`);
1786
+ return;
1787
+ }
1788
+ }
1789
+ if (process.platform === "win32") {
1790
+ await copySkill(canonicalSkill, target);
1791
+ } else {
1792
+ await symlink(relative, target, "dir");
1793
+ }
1794
+ writers.out(`\u2714 ${tr("\u0441\u043A\u0438\u043B\u043B", "skill")}: .claude/skills/revdeck -> ${CANONICAL_SKILL}`);
1795
+ }
1796
+ async function isManagedSkillDir(dir) {
1797
+ const skill = path5.join(dir, "SKILL.md");
1798
+ if (!existsSync5(skill)) return false;
1799
+ const text = await readFile3(skill, "utf8").catch(() => "");
1800
+ return /^name:\s*revdeck\s*$/m.test(text);
1801
+ }
1802
+ async function skillFiles(dir, prefix = "") {
1803
+ const entries = await readdir(path5.join(dir, prefix), { withFileTypes: true });
1804
+ const files = [];
1805
+ for (const entry of entries) {
1806
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
1807
+ if (entry.isDirectory()) files.push(...await skillFiles(dir, relative));
1808
+ else if (entry.isFile()) files.push(relative);
1809
+ }
1810
+ return files.sort();
1720
1811
  }
1721
1812
  async function mergeMcpJson(file, root, mcp) {
1722
1813
  let data = {};
@@ -1802,12 +1893,407 @@ function describeError(error) {
1802
1893
  return redactLine(error instanceof Error ? error.message : error);
1803
1894
  }
1804
1895
 
1896
+ // src/cli/eject.ts
1897
+ init_paths();
1898
+ import { existsSync as existsSync7 } from "node:fs";
1899
+ import { lstat as lstat3, readFile as readFile5, rm as rm2, writeFile as writeFile3 } from "node:fs/promises";
1900
+ import path7 from "node:path";
1901
+
1902
+ // src/core/jsonc.ts
1903
+ function parseJsonc(text) {
1904
+ let withoutComments = "";
1905
+ let inString = false;
1906
+ let escaped = false;
1907
+ let lineComment = false;
1908
+ let blockComment = false;
1909
+ for (let index = 0; index < text.length; index++) {
1910
+ const char = text[index];
1911
+ const next = text[index + 1];
1912
+ if (lineComment) {
1913
+ if (char === "\n") {
1914
+ lineComment = false;
1915
+ withoutComments += char;
1916
+ }
1917
+ continue;
1918
+ }
1919
+ if (blockComment) {
1920
+ if (char === "*" && next === "/") {
1921
+ blockComment = false;
1922
+ index += 1;
1923
+ } else if (char === "\n") {
1924
+ withoutComments += char;
1925
+ }
1926
+ continue;
1927
+ }
1928
+ if (inString) {
1929
+ withoutComments += char;
1930
+ if (escaped) escaped = false;
1931
+ else if (char === "\\") escaped = true;
1932
+ else if (char === '"') inString = false;
1933
+ continue;
1934
+ }
1935
+ if (char === '"') {
1936
+ inString = true;
1937
+ withoutComments += char;
1938
+ continue;
1939
+ }
1940
+ if (char === "/" && next === "/") {
1941
+ lineComment = true;
1942
+ index += 1;
1943
+ continue;
1944
+ }
1945
+ if (char === "/" && next === "*") {
1946
+ blockComment = true;
1947
+ index += 1;
1948
+ continue;
1949
+ }
1950
+ withoutComments += char;
1951
+ }
1952
+ let cleaned = "";
1953
+ inString = false;
1954
+ escaped = false;
1955
+ for (let index = 0; index < withoutComments.length; index++) {
1956
+ const char = withoutComments[index];
1957
+ if (inString) {
1958
+ cleaned += char;
1959
+ if (escaped) escaped = false;
1960
+ else if (char === "\\") escaped = true;
1961
+ else if (char === '"') inString = false;
1962
+ continue;
1963
+ }
1964
+ if (char === '"') {
1965
+ inString = true;
1966
+ cleaned += char;
1967
+ continue;
1968
+ }
1969
+ if (char === ",") {
1970
+ let lookahead = index + 1;
1971
+ while (lookahead < withoutComments.length && /\s/.test(withoutComments[lookahead])) lookahead += 1;
1972
+ const next = withoutComments[lookahead];
1973
+ if (next === "}" || next === "]") continue;
1974
+ }
1975
+ cleaned += char;
1976
+ }
1977
+ return JSON.parse(cleaned);
1978
+ }
1979
+ function isRecord(value) {
1980
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1981
+ }
1982
+
1983
+ // src/cli/eject.ts
1984
+ import { applyEdits as applyEdits2, modify as modify2 } from "jsonc-parser";
1985
+
1986
+ // src/cli/devcontainer.ts
1987
+ init_paths();
1988
+ import { existsSync as existsSync6 } from "node:fs";
1989
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile2 } from "node:fs/promises";
1990
+ import path6 from "node:path";
1991
+ import { applyEdits, modify, parse as parseJsonc2 } from "jsonc-parser";
1992
+ init_locale();
1993
+ init_format();
1994
+ var CONTAINER_RUNTIME_DIR = "/run/revdeck";
1995
+ var CONTAINER_STATE_DIR = "/var/lib/revdeck";
1996
+ var CONTAINER_UI_HOST = "host.docker.internal";
1997
+ function devcontainerPatch() {
1998
+ const bridge = "${localEnv:HOME}${localEnv:USERPROFILE}/.local/state/revdeck/devcontainer";
1999
+ return {
2000
+ mounts: [
2001
+ { type: "bind", source: `${bridge}/run`, target: CONTAINER_RUNTIME_DIR },
2002
+ { type: "bind", source: `${bridge}/state`, target: CONTAINER_STATE_DIR, readonly: true }
2003
+ ],
2004
+ containerEnv: {
2005
+ REVDECK_RUNTIME_DIR: CONTAINER_RUNTIME_DIR,
2006
+ REVDECK_STATE_DIR: CONTAINER_STATE_DIR,
2007
+ REVDECK_DEV_CONTAINER: "1",
2008
+ REVDECK_UI_HOST: CONTAINER_UI_HOST
2009
+ },
2010
+ remoteEnv: {
2011
+ REVDECK_RUNTIME_DIR: CONTAINER_RUNTIME_DIR,
2012
+ REVDECK_STATE_DIR: CONTAINER_STATE_DIR,
2013
+ REVDECK_DEV_CONTAINER: "1",
2014
+ REVDECK_UI_HOST: CONTAINER_UI_HOST
2015
+ },
2016
+ runArgs: [`--add-host=${CONTAINER_UI_HOST}:host-gateway`]
2017
+ };
2018
+ }
2019
+ function mergeDevcontainerConfig(existing, patch) {
2020
+ const result = { ...existing };
2021
+ const currentMounts = Array.isArray(existing.mounts) ? [...existing.mounts] : [];
2022
+ for (const mount of patch.mounts) {
2023
+ const index = currentMounts.findIndex((item) => isRecord(item) && item.target === mount.target);
2024
+ if (index >= 0) currentMounts[index] = mount;
2025
+ else currentMounts.push(mount);
2026
+ }
2027
+ result.mounts = currentMounts;
2028
+ const containerEnv = isRecord(existing.containerEnv) ? { ...existing.containerEnv } : {};
2029
+ Object.assign(containerEnv, patch.containerEnv);
2030
+ result.containerEnv = containerEnv;
2031
+ const remoteEnv = isRecord(existing.remoteEnv) ? { ...existing.remoteEnv } : {};
2032
+ Object.assign(remoteEnv, patch.remoteEnv);
2033
+ result.remoteEnv = remoteEnv;
2034
+ const runArgs = Array.isArray(existing.runArgs) ? existing.runArgs.filter((item) => typeof item === "string") : [];
2035
+ for (const arg of patch.runArgs) if (!runArgs.includes(arg)) runArgs.push(arg);
2036
+ result.runArgs = runArgs;
2037
+ return result;
2038
+ }
2039
+ function applyDevcontainerPatch(text, patch) {
2040
+ let current2 = text;
2041
+ const parsed = parseJsonc2(current2);
2042
+ if (!isRecord(parsed)) throw new Error("devcontainer.json: expected an object");
2043
+ const formatting = { formattingOptions: { insertSpaces: true, tabSize: 2 } };
2044
+ const mounts = Array.isArray(parsed.mounts) ? [...parsed.mounts] : [];
2045
+ for (const mount of patch.mounts) {
2046
+ const index = mounts.findIndex((item) => isRecord(item) && item.target === mount.target);
2047
+ const path10 = ["mounts", index >= 0 ? index : mounts.length];
2048
+ current2 = applyEdits(current2, modify(current2, path10, mount, formatting));
2049
+ if (index >= 0) mounts[index] = mount;
2050
+ else mounts.push(mount);
2051
+ }
2052
+ for (const [key, env] of [
2053
+ ["containerEnv", patch.containerEnv],
2054
+ ["remoteEnv", patch.remoteEnv]
2055
+ ]) {
2056
+ for (const [name, value] of Object.entries(env)) {
2057
+ current2 = applyEdits(current2, modify(current2, [key, name], value, formatting));
2058
+ }
2059
+ }
2060
+ const runArgs = Array.isArray(parsed.runArgs) ? parsed.runArgs.filter((item) => typeof item === "string") : [];
2061
+ for (const arg of patch.runArgs) {
2062
+ if (runArgs.includes(arg)) continue;
2063
+ current2 = applyEdits(current2, modify(current2, ["runArgs", runArgs.length], arg, formatting));
2064
+ runArgs.push(arg);
2065
+ }
2066
+ return current2.endsWith("\n") ? current2 : `${current2}
2067
+ `;
2068
+ }
2069
+ async function runDevcontainer(rootInput, options) {
2070
+ const root = findRepoRoot(rootInput);
2071
+ const patch = devcontainerPatch();
2072
+ await ensureDir(devcontainerRunDir());
2073
+ await ensureDir(devcontainerStateDir());
2074
+ const dir = path6.join(root, ".devcontainer");
2075
+ const file = path6.join(dir, "devcontainer.json");
2076
+ writers.out(tr("\u041C\u043E\u0441\u0442 revdeck \u0434\u043B\u044F \u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440\u0430:", "revdeck bridge for the container:"));
2077
+ writers.out(` ${tr("\u0441\u043E\u043A\u0435\u0442", "socket")}: ${devcontainerRunDir()} -> ${CONTAINER_RUNTIME_DIR}`);
2078
+ writers.out(` ${tr("\u0441\u043E\u0441\u0442\u043E\u044F\u043D\u0438\u0435", "state")}: ${devcontainerStateDir()} -> ${CONTAINER_STATE_DIR} (${tr("\u0442\u043E\u043B\u044C\u043A\u043E \u0447\u0442\u0435\u043D\u0438\u0435", "read-only")})`);
2079
+ if (!existsSync6(devcontainerSocketPath())) {
2080
+ writers.err(
2081
+ tr(
2082
+ "! \u0441\u043E\u043A\u0435\u0442 \u043C\u043E\u0441\u0442\u0430 \u0435\u0449\u0451 \u043D\u0435 \u0441\u043E\u0437\u0434\u0430\u043D: \u043D\u0430 \u0445\u043E\u0441\u0442\u0435 \u0432\u044B\u043F\u043E\u043B\u043D\u0438\u0442\u0435 revdeck daemon restart",
2083
+ "! the bridge socket does not exist yet: run revdeck daemon restart on the host"
2084
+ )
2085
+ );
2086
+ }
2087
+ writers.out(tr("\u0412 \u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440\u0435: \u0434\u0435\u043C\u043E\u043D, gh \u0438 glab \u043E\u0441\u0442\u0430\u044E\u0442\u0441\u044F \u043D\u0430 \u0445\u043E\u0441\u0442\u0435.", "In the container: the daemon, gh and glab stay on the host."));
2088
+ if (!options.write) {
2089
+ writers.out("");
2090
+ writers.out(tr("\u0424\u0440\u0430\u0433\u043C\u0435\u043D\u0442 \u0434\u043B\u044F .devcontainer/devcontainer.json:", "Snippet for .devcontainer/devcontainer.json:"));
2091
+ writers.out(JSON.stringify(patch, null, 2));
2092
+ writers.out("");
2093
+ writers.out(tr("\u041F\u0440\u0438\u043C\u0435\u043D\u0438\u0442\u044C \u0430\u0432\u0442\u043E\u043C\u0430\u0442\u0438\u0447\u0435\u0441\u043A\u0438: revdeck devcontainer --write", "Apply automatically: revdeck devcontainer --write"));
2094
+ return;
2095
+ }
2096
+ await mkdir3(dir, { recursive: true });
2097
+ const existed = existsSync6(file);
2098
+ let output;
2099
+ if (existed) {
2100
+ const text = await readFile4(file, "utf8");
2101
+ try {
2102
+ output = applyDevcontainerPatch(text, patch);
2103
+ } catch (error) {
2104
+ throw new Error(
2105
+ tr(
2106
+ `${path6.relative(root, file)}: \u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0440\u0430\u0437\u043E\u0431\u0440\u0430\u0442\u044C JSONC (${error.message}); \u0434\u043E\u0431\u0430\u0432\u044C\u0442\u0435 mounts \u0432\u0440\u0443\u0447\u043D\u0443\u044E`,
2107
+ `${path6.relative(root, file)}: could not parse JSONC (${error.message}); add the mounts manually`
2108
+ )
2109
+ );
2110
+ }
2111
+ } else {
2112
+ const existing = { image: "mcr.microsoft.com/devcontainers/base:bookworm" };
2113
+ output = `${JSON.stringify(mergeDevcontainerConfig(existing, patch), null, 2)}
2114
+ `;
2115
+ }
2116
+ await writeFile2(file, output);
2117
+ writers.out(`\u2714 ${path6.relative(root, file)}`);
2118
+ if (existed) writers.out(tr(" \u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u0438 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u044B.", " comments were preserved."));
2119
+ writers.out("");
2120
+ writers.out(tr("\u0414\u0430\u043B\u044C\u0448\u0435:", "Next:"));
2121
+ writers.out(tr(" 1. \u041D\u0430 \u0445\u043E\u0441\u0442\u0435: revdeck daemon restart \u2014 \u043E\u043D \u0441\u043E\u0437\u0434\u0430\u0441\u0442 \u0441\u043E\u043A\u0435\u0442 \u043C\u043E\u0441\u0442\u0430.", " 1. On the host: revdeck daemon restart \u2014 it creates the bridge socket."));
2122
+ writers.out(tr(" 2. \u041F\u0435\u0440\u0435\u0441\u043E\u0431\u0435\u0440\u0438\u0442\u0435/\u043F\u0435\u0440\u0435\u043E\u0442\u043A\u0440\u043E\u0439\u0442\u0435 devcontainer.", " 2. Rebuild/reopen the devcontainer."));
2123
+ writers.out(tr(" 3. \u0412 \u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440\u0435: revdeck doctor \u2014 \u043E\u043D \u043D\u0435 \u0431\u0443\u0434\u0435\u0442 \u0438\u0441\u043A\u0430\u0442\u044C glab/gh \u0432\u043D\u0443\u0442\u0440\u0438.", " 3. In the container: revdeck doctor \u2014 it will not look for glab/gh inside."));
2124
+ writers.out(tr(" \u0414\u043B\u044F UI \u0438\u0437 \u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440\u0430 \u0437\u0430\u043F\u0443\u0441\u0442\u0438\u0442\u0435 \u0434\u0435\u043C\u043E\u043D \u043D\u0430 \u0445\u043E\u0441\u0442\u0435 \u0441 --host 0.0.0.0.", " For the UI from the container, start the daemon on the host with --host 0.0.0.0."));
2125
+ }
2126
+
2127
+ // src/cli/eject.ts
2128
+ init_locale();
2129
+ init_format();
2130
+ var CONFIG_FILES2 = [".revdeck.yml", ".revdeck.yaml"];
2131
+ var COMMAND_FILES = [
2132
+ ".claude/commands/revdeck.md",
2133
+ ".cursor/commands/revdeck.md",
2134
+ ".codex/prompts/revdeck.md",
2135
+ ".gemini/commands/revdeck.toml",
2136
+ ".opencode/command/revdeck.md",
2137
+ ".github/prompts/revdeck.prompt.md"
2138
+ ];
2139
+ var SKILL_DIRS = [".agents/skills/revdeck", ".claude/skills/revdeck"];
2140
+ var MCP_FILES = [".mcp.json", ".cursor/mcp.json"];
2141
+ async function runEject(rootInput, options) {
2142
+ const root = findRepoRoot(rootInput);
2143
+ const changes = [];
2144
+ const warnings = [];
2145
+ if (!options.keepConfig) {
2146
+ for (const name of CONFIG_FILES2) {
2147
+ const file = path7.join(root, name);
2148
+ if (existsSync7(file)) changes.push({ path: name, action: tr("\u0443\u0434\u0430\u043B\u0438\u0442\u044C \u043A\u043E\u043D\u0444\u0438\u0433", "remove config"), remove: () => rm2(file, { force: true }) });
2149
+ }
2150
+ }
2151
+ for (const name of SKILL_DIRS) {
2152
+ const target = path7.join(root, name);
2153
+ if (!existsSync7(target)) continue;
2154
+ const managed = await isManagedSkill(target);
2155
+ if (!managed) {
2156
+ warnings.push(`${name}: ${tr("\u0441\u043A\u0438\u043B\u043B \u043D\u0435 \u043F\u043E\u0445\u043E\u0436 \u043D\u0430 revdeck \u2014 \u043E\u0441\u0442\u0430\u0432\u043B\u044F\u044E", "does not look like the revdeck skill \u2014 leaving it")}`);
2157
+ continue;
2158
+ }
2159
+ changes.push({ path: name, action: tr("\u0443\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u043A\u0438\u043B\u043B", "remove skill"), remove: () => rm2(target, { recursive: true, force: true }) });
2160
+ }
2161
+ for (const name of COMMAND_FILES) {
2162
+ const file = path7.join(root, name);
2163
+ if (!existsSync7(file)) continue;
2164
+ const text = await readFile5(file, "utf8").catch(() => "");
2165
+ if (!/revdeck/i.test(text)) {
2166
+ warnings.push(`${name}: ${tr("\u0444\u0430\u0439\u043B \u043D\u0435 \u0441\u043E\u0434\u0435\u0440\u0436\u0438\u0442 revdeck \u2014 \u043E\u0441\u0442\u0430\u0432\u043B\u044F\u044E", "file does not mention revdeck \u2014 leaving it")}`);
2167
+ continue;
2168
+ }
2169
+ changes.push({ path: name, action: tr("\u0443\u0434\u0430\u043B\u0438\u0442\u044C \u043A\u043E\u043C\u0430\u043D\u0434\u0443", "remove command"), remove: () => rm2(file, { force: true }) });
2170
+ }
2171
+ for (const name of MCP_FILES) {
2172
+ const file = path7.join(root, name);
2173
+ if (!existsSync7(file)) continue;
2174
+ const text = await readFile5(file, "utf8").catch(() => "");
2175
+ let data;
2176
+ try {
2177
+ data = JSON.parse(text);
2178
+ } catch {
2179
+ warnings.push(`${name}: ${tr("\u043D\u0435 JSON \u2014 \u043D\u0435 \u0442\u0440\u043E\u0433\u0430\u044E", "not JSON \u2014 leaving it untouched")}`);
2180
+ continue;
2181
+ }
2182
+ const servers = data.mcpServers;
2183
+ if (!servers || !("revdeck" in servers)) continue;
2184
+ changes.push({
2185
+ path: name,
2186
+ action: tr("\u0443\u0431\u0440\u0430\u0442\u044C MCP-\u0441\u0435\u0440\u0432\u0435\u0440 revdeck", "remove the revdeck MCP server"),
2187
+ remove: async () => {
2188
+ delete servers.revdeck;
2189
+ await writeFile3(file, `${JSON.stringify(data, null, 2)}
2190
+ `);
2191
+ }
2192
+ });
2193
+ }
2194
+ const devcontainer = path7.join(root, ".devcontainer", "devcontainer.json");
2195
+ if (existsSync7(devcontainer)) {
2196
+ const text = await readFile5(devcontainer, "utf8");
2197
+ try {
2198
+ const parsed = parseJsonc(text);
2199
+ if (isRecord(parsed)) {
2200
+ const formatting = { formattingOptions: { insertSpaces: true, tabSize: 2 } };
2201
+ let current2 = text;
2202
+ let changed = false;
2203
+ if (Array.isArray(parsed.mounts)) {
2204
+ const mounts = parsed.mounts.filter((item) => !(isRecord(item) && (item.target === CONTAINER_RUNTIME_DIR || item.target === CONTAINER_STATE_DIR)));
2205
+ if (mounts.length !== parsed.mounts.length) {
2206
+ current2 = applyEdits2(current2, modify2(current2, ["mounts"], mounts, formatting));
2207
+ changed = true;
2208
+ }
2209
+ }
2210
+ for (const key of ["containerEnv", "remoteEnv"]) {
2211
+ if (!isRecord(parsed[key])) continue;
2212
+ const env = { ...parsed[key] };
2213
+ for (const name of ["REVDECK_RUNTIME_DIR", "REVDECK_STATE_DIR", "REVDECK_DEV_CONTAINER", "REVDECK_UI_HOST"]) delete env[name];
2214
+ if (Object.keys(env).length !== Object.keys(parsed[key]).length) {
2215
+ current2 = applyEdits2(current2, modify2(current2, [key], env, formatting));
2216
+ changed = true;
2217
+ }
2218
+ }
2219
+ if (Array.isArray(parsed.runArgs)) {
2220
+ const args = parsed.runArgs.filter((item) => item !== "--add-host=host.docker.internal:host-gateway");
2221
+ if (args.length !== parsed.runArgs.length) {
2222
+ current2 = applyEdits2(current2, modify2(current2, ["runArgs"], args, formatting));
2223
+ changed = true;
2224
+ }
2225
+ }
2226
+ if (changed) {
2227
+ const output = current2.endsWith("\n") ? current2 : `${current2}
2228
+ `;
2229
+ changes.push({
2230
+ path: ".devcontainer/devcontainer.json",
2231
+ action: tr("\u0443\u0431\u0440\u0430\u0442\u044C \u043F\u043E\u0434\u043A\u043B\u044E\u0447\u0435\u043D\u0438\u0435 \u043A \u0434\u0435\u043C\u043E\u043D\u0443 \u0445\u043E\u0441\u0442\u0430", "remove the host daemon connection"),
2232
+ remove: () => writeFile3(devcontainer, output)
2233
+ });
2234
+ }
2235
+ }
2236
+ } catch {
2237
+ warnings.push(`.devcontainer/devcontainer.json: ${tr("\u043D\u0435 JSONC \u2014 \u043D\u0435 \u0442\u0440\u043E\u0433\u0430\u044E", "not JSONC \u2014 leaving it untouched")}`);
2238
+ }
2239
+ }
2240
+ const cache = path7.join(root, ".revdeck");
2241
+ if (existsSync7(cache)) {
2242
+ changes.push({ path: ".revdeck/", action: tr("\u0443\u0434\u0430\u043B\u0438\u0442\u044C \u043B\u043E\u043A\u0430\u043B\u044C\u043D\u044B\u0439 \u043A\u0435\u0448", "remove local cache"), remove: () => rm2(cache, { recursive: true, force: true }) });
2243
+ }
2244
+ const gitignore = path7.join(root, ".gitignore");
2245
+ if (existsSync7(gitignore)) {
2246
+ const text = await readFile5(gitignore, "utf8");
2247
+ if (/(^|\n)\.revdeck\/?\s*(\n|$)/.test(text)) {
2248
+ changes.push({
2249
+ path: ".gitignore",
2250
+ action: tr("\u0443\u0431\u0440\u0430\u0442\u044C \u0441\u0442\u0440\u043E\u043A\u0443 .revdeck/", "remove the .revdeck/ line"),
2251
+ remove: async () => {
2252
+ const next = text.split("\n").filter((line) => line.trim() !== ".revdeck/" && line.trim() !== ".revdeck").join("\n");
2253
+ await writeFile3(gitignore, next);
2254
+ }
2255
+ });
2256
+ }
2257
+ }
2258
+ if (!changes.length) {
2259
+ writers.out(tr("\u0432 \u0440\u0435\u043F\u043E\u0437\u0438\u0442\u043E\u0440\u0438\u0438 \u043D\u0435\u0442 \u0443\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D\u043D\u043E\u0433\u043E revdeck", "revdeck is not installed in this repository"));
2260
+ for (const warning of warnings) writers.err(`! ${warning}`);
2261
+ return;
2262
+ }
2263
+ writers.out(tr("\u0411\u0443\u0434\u0435\u0442 \u0438\u0437\u043C\u0435\u043D\u0435\u043D\u043E:", "Will change:"));
2264
+ for (const change of changes) writers.out(` ${change.action}: ${change.path}`);
2265
+ for (const warning of warnings) writers.err(`! ${warning}`);
2266
+ if (!options.yes) {
2267
+ writers.out("");
2268
+ writers.out(tr("\u041D\u0438\u0447\u0435\u0433\u043E \u043D\u0435 \u0443\u0434\u0430\u043B\u0435\u043D\u043E: \u0434\u043E\u0431\u0430\u0432\u044C\u0442\u0435 --yes, \u0447\u0442\u043E\u0431\u044B \u043F\u0440\u0438\u043C\u0435\u043D\u0438\u0442\u044C.", "Nothing was removed: add --yes to apply."));
2269
+ return;
2270
+ }
2271
+ for (const change of changes) {
2272
+ await change.remove();
2273
+ writers.out(`\u2714 ${change.action}: ${change.path}`);
2274
+ }
2275
+ writers.out(tr("revdeck \u0443\u0434\u0430\u043B\u0451\u043D \u0438\u0437 \u0440\u0435\u043F\u043E\u0437\u0438\u0442\u043E\u0440\u0438\u044F.", "revdeck was removed from the repository."));
2276
+ }
2277
+ async function isManagedSkill(target) {
2278
+ try {
2279
+ const info = await lstat3(target);
2280
+ if (info.isSymbolicLink()) return true;
2281
+ if (!info.isDirectory()) return false;
2282
+ const skill = path7.join(target, "SKILL.md");
2283
+ if (!existsSync7(skill)) return false;
2284
+ const text = await readFile5(skill, "utf8");
2285
+ return /^name:\s*revdeck\s*$/m.test(text) || /revdeck/i.test(text.slice(0, 2e3));
2286
+ } catch {
2287
+ return false;
2288
+ }
2289
+ }
2290
+
1805
2291
  // src/cli/export.ts
1806
2292
  init_client();
1807
2293
  init_format();
1808
2294
  init_locale();
1809
- import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile2 } from "node:fs/promises";
1810
- import path6 from "node:path";
2295
+ import { mkdir as mkdir4, readFile as readFile6, writeFile as writeFile4 } from "node:fs/promises";
2296
+ import path8 from "node:path";
1811
2297
  async function reviewFor(client2, root, arg) {
1812
2298
  if (arg && /^(gitlab|github):/.test(arg)) {
1813
2299
  const { review } = await client2.request("reviews.get", { review: arg });
@@ -1863,13 +2349,13 @@ async function exportReview(root, arg, dir) {
1863
2349
  const { client: client2 } = await ensureDaemon();
1864
2350
  try {
1865
2351
  const review = await reviewFor(client2, root, arg);
1866
- const target = path6.resolve(root, dir);
1867
- await mkdir3(target, { recursive: true });
2352
+ const target = path8.resolve(root, dir);
2353
+ await mkdir4(target, { recursive: true });
1868
2354
  const base = `mr-${review.info.ref.number}`;
1869
2355
  const files = [];
1870
2356
  const write = async (name, data) => {
1871
- const file = path6.join(target, name);
1872
- await writeFile2(file, `${JSON.stringify(data, null, 2)}
2357
+ const file = path8.join(target, name);
2358
+ await writeFile4(file, `${JSON.stringify(data, null, 2)}
1873
2359
  `, { mode: 420 });
1874
2360
  files.push(name);
1875
2361
  };
@@ -1934,9 +2420,9 @@ async function exportReview(root, arg, dir) {
1934
2420
  }
1935
2421
  }
1936
2422
  await write(`${base}-published.json`, published);
1937
- await writeFile2(path6.join(target, `${base}.diff`), await serializeDiff(client2, review), { mode: 420 });
2423
+ await writeFile4(path8.join(target, `${base}.diff`), await serializeDiff(client2, review), { mode: 420 });
1938
2424
  files.push(`${base}.diff`);
1939
- writers.out(`${tr("\u044D\u043A\u0441\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D\u043E \u0432", "exported to")} ${path6.relative(root, target)}: ${files.join(", ")}`);
2425
+ writers.out(`${tr("\u044D\u043A\u0441\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D\u043E \u0432", "exported to")} ${path8.relative(root, target)}: ${files.join(", ")}`);
1940
2426
  } finally {
1941
2427
  client2.close();
1942
2428
  }
@@ -1945,11 +2431,11 @@ async function importReview(root, arg, dir) {
1945
2431
  const { client: client2 } = await ensureDaemon();
1946
2432
  try {
1947
2433
  const review = await reviewFor(client2, root, arg);
1948
- const target = path6.resolve(root, dir);
2434
+ const target = path8.resolve(root, dir);
1949
2435
  const base = `mr-${review.info.ref.number}`;
1950
2436
  const read = async (name) => {
1951
2437
  try {
1952
- return JSON.parse(await readFile4(path6.join(target, name), "utf8"));
2438
+ return JSON.parse(await readFile6(path8.join(target, name), "utf8"));
1953
2439
  } catch {
1954
2440
  return null;
1955
2441
  }
@@ -1997,6 +2483,53 @@ async function importReview(root, arg, dir) {
1997
2483
  }
1998
2484
  }
1999
2485
 
2486
+ // src/core/attachments.ts
2487
+ var MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
2488
+ var MIME_BY_EXTENSION = {
2489
+ ".png": "image/png",
2490
+ ".jpg": "image/jpeg",
2491
+ ".jpeg": "image/jpeg",
2492
+ ".gif": "image/gif",
2493
+ ".webp": "image/webp",
2494
+ ".avif": "image/avif",
2495
+ ".mp4": "video/mp4",
2496
+ ".m4v": "video/mp4",
2497
+ ".webm": "video/webm",
2498
+ ".ogv": "video/ogg",
2499
+ ".ogg": "video/ogg",
2500
+ ".mov": "video/quicktime"
2501
+ };
2502
+ function extensionOf(name) {
2503
+ const index = name.lastIndexOf(".");
2504
+ return index >= 0 ? name.slice(index).toLowerCase() : "";
2505
+ }
2506
+ function attachmentSpec(name, contentType = "") {
2507
+ if (/[/\\]/.test(name)) return null;
2508
+ const base = name.replace(/[\u0000-\u001f\u007f]/g, "").trim();
2509
+ if (!base || base.length > 240) return null;
2510
+ const extension = extensionOf(base);
2511
+ const expected = MIME_BY_EXTENSION[extension];
2512
+ if (!expected) return null;
2513
+ const provided = normalizeContentType(String(contentType));
2514
+ if (provided && provided !== expected) return null;
2515
+ return {
2516
+ name: base,
2517
+ contentType: expected,
2518
+ extension,
2519
+ kind: expected.startsWith("image/") ? "image" : "video"
2520
+ };
2521
+ }
2522
+ var CONTENT_TYPE_ALIASES = {
2523
+ "image/jpg": "image/jpeg",
2524
+ "image/pjpeg": "image/jpeg",
2525
+ "video/x-m4v": "video/mp4",
2526
+ "video/x-ogg": "video/ogg"
2527
+ };
2528
+ function normalizeContentType(value) {
2529
+ const type = value.split(";")[0].trim().toLowerCase();
2530
+ return CONTENT_TYPE_ALIASES[type] ?? type;
2531
+ }
2532
+
2000
2533
  // src/cli/main.ts
2001
2534
  init_context();
2002
2535
  var OPTIONS = {
@@ -2027,10 +2560,13 @@ var OPTIONS = {
2027
2560
  force: { type: "boolean", default: false },
2028
2561
  adapters: { type: "string" },
2029
2562
  "no-service": { type: "boolean", default: false },
2563
+ "keep-config": { type: "boolean", default: false },
2564
+ write: { type: "boolean", default: false },
2030
2565
  explain: { type: "string" },
2031
2566
  limit: { type: "string" },
2032
2567
  decision: { type: "string" },
2033
2568
  note: { type: "string" },
2569
+ emoji: { type: "string" },
2034
2570
  text: { type: "string" },
2035
2571
  line: { type: "string" },
2036
2572
  id: { type: "string" },
@@ -2049,6 +2585,8 @@ function parse(argv) {
2049
2585
  var HELP_RU = `revdeck ${VERSION} \u2014 \u0440\u0435\u0432\u044C\u044E GitLab/GitHub \u0434\u043B\u044F \u043B\u044E\u0434\u0435\u0439 \u0438 \u0430\u0433\u0435\u043D\u0442\u043E\u0432
2050
2586
 
2051
2587
  revdeck init \u043D\u0430\u0441\u0442\u0440\u043E\u0438\u0442\u044C \u043F\u0440\u043E\u0435\u043A\u0442: .revdeck.yml, \u0430\u0434\u0430\u043F\u0442\u0435\u0440\u044B, MCP
2588
+ revdeck eject \u0443\u0434\u0430\u043B\u0438\u0442\u044C revdeck \u0438\u0437 \u043F\u0440\u043E\u0435\u043A\u0442\u0430 (--yes; --keep-config)
2589
+ revdeck devcontainer \u043F\u043E\u0434\u043A\u043B\u044E\u0447\u0438\u0442\u044C devcontainer \u043A \u0434\u0435\u043C\u043E\u043D\u0443 \u0445\u043E\u0441\u0442\u0430 (--write)
2052
2590
  revdeck doctor \u043F\u0440\u043E\u0432\u0435\u0440\u0438\u0442\u044C \u043E\u043A\u0440\u0443\u0436\u0435\u043D\u0438\u0435 \u0438 \u0434\u043E\u0441\u0442\u0443\u043F\u044B
2053
2591
  revdeck daemon <start|stop|status|logs|restart>
2054
2592
  revdeck list \u043E\u0442\u043A\u0440\u044B\u0442\u044B\u0435 \u0440\u0435\u0432\u044C\u044E (\u043B\u043E\u043A\u0430\u043B\u044C\u043D\u044B\u0439 \u043A\u0435\u0448), --remote \u2014 \u0441 \u043F\u043B\u0430\u0442\u0444\u043E\u0440\u043C\u044B
@@ -2061,6 +2599,8 @@ var HELP_RU = `revdeck ${VERSION} \u2014 \u0440\u0435\u0432\u044C\u044E GitLab/G
2061
2599
  revdeck summary <N> [--file <json>] \u0432\u044B\u0436\u0438\u043C\u043A\u0430 MR
2062
2600
  revdeck reply <N> --thread <id> (--body <\u0442\u0435\u043A\u0441\u0442> | --body-file <\u0444\u0430\u0439\u043B> | -) [--resolve] [--publish --yes]
2063
2601
  revdeck notes <N> <list|add|update|delete|reply>
2602
+ revdeck react <N> --note <id> --emoji <name> \u043F\u043E\u0441\u0442\u0430\u0432\u0438\u0442\u044C \u0438\u043B\u0438 \u0441\u043D\u044F\u0442\u044C \u0440\u0435\u0430\u043A\u0446\u0438\u044E
2603
+ revdeck attach <N> --file <\u043F\u0443\u0442\u044C> \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u043A\u0430\u0440\u0442\u0438\u043D\u043A\u0443/\u0432\u0438\u0434\u0435\u043E \u0432 \u043F\u043B\u0430\u0442\u0444\u043E\u0440\u043C\u0443
2064
2604
  revdeck publish <N> [--kind reply|finding|note] [--ids a,b] [--yes]
2065
2605
  revdeck rules [<N>] [--explain <\u043F\u0443\u0442\u044C>]
2066
2606
  revdeck checks <N> [--run --yes]
@@ -2074,6 +2614,8 @@ var HELP_RU = `revdeck ${VERSION} \u2014 \u0440\u0435\u0432\u044C\u044E GitLab/G
2074
2614
  var HELP_EN = `revdeck ${VERSION} \u2014 code review for GitLab/GitHub, for humans and agents
2075
2615
 
2076
2616
  revdeck init set up a project: .revdeck.yml, adapters, MCP
2617
+ revdeck eject remove revdeck from the project (--yes; --keep-config)
2618
+ revdeck devcontainer connect a devcontainer to the host daemon (--write)
2077
2619
  revdeck doctor check the environment and access
2078
2620
  revdeck daemon <start|stop|status|logs|restart>
2079
2621
  revdeck list open reviews (local cache), --remote \u2014 from the platform
@@ -2086,6 +2628,8 @@ var HELP_EN = `revdeck ${VERSION} \u2014 code review for GitLab/GitHub, for huma
2086
2628
  revdeck summary <N> [--file <json>] review summary
2087
2629
  revdeck reply <N> --thread <id> (--body <text> | --body-file <file> | -) [--resolve] [--publish --yes]
2088
2630
  revdeck notes <N> <list|add|update|delete|reply>
2631
+ revdeck react <N> --note <id> --emoji <name> add or remove a reaction
2632
+ revdeck attach <N> --file <path> upload an image/video to the platform
2089
2633
  revdeck publish <N> [--kind reply|finding|note] [--ids a,b] [--yes]
2090
2634
  revdeck rules [<N>] [--explain <path>]
2091
2635
  revdeck checks <N> [--run --yes]
@@ -2120,10 +2664,14 @@ async function main2() {
2120
2664
  switch (command) {
2121
2665
  case "init":
2122
2666
  return runInit(root, { force: values.force === true, adapters: parseList(values.adapters), noService: values["no-service"] === true, locale: values.lang === "ru" || values.lang === "en" ? values.lang : detectLocale(), outputLanguage: values.lang === "ru" || values.lang === "en" ? values.lang : "auto" });
2667
+ case "eject":
2668
+ return runEject(root, { yes: values.yes === true && values["dry-run"] !== true, keepConfig: values["keep-config"] === true });
2669
+ case "devcontainer":
2670
+ return runDevcontainer(root, { write: values.write === true });
2123
2671
  case "doctor":
2124
2672
  return doctor(root);
2125
2673
  case "daemon":
2126
- return daemon(positionals[0] ?? "status");
2674
+ return daemon(positionals[0] ?? "status", values);
2127
2675
  case "list":
2128
2676
  return list(root, values);
2129
2677
  case "open":
@@ -2147,6 +2695,10 @@ async function main2() {
2147
2695
  return reply(positionals[0], values);
2148
2696
  case "notes":
2149
2697
  return notes(positionals[0], positionals[1] ?? "list", values);
2698
+ case "react":
2699
+ return react(positionals[0], values);
2700
+ case "attach":
2701
+ return attach(positionals[0], values);
2150
2702
  case "publish":
2151
2703
  return publish(positionals[0], values);
2152
2704
  case "rules":
@@ -2259,13 +2811,14 @@ async function ui(arg, values) {
2259
2811
  });
2260
2812
  const info = await daemonInfo();
2261
2813
  if (!info) fail(tr("\u0434\u0435\u043C\u043E\u043D \u043D\u0435 \u0437\u0430\u043F\u0443\u0449\u0435\u043D", "the daemon is not running"), "revdeck daemon start");
2262
- writers.out(`${info.url}/?t=${info.token}`);
2263
- if (values.open) openBrowser(`${info.url}/?t=${info.token}`);
2814
+ const url = `${uiBaseUrl(info)}/?t=${info.token}`;
2815
+ writers.out(url);
2816
+ if (values.open) openBrowser(url);
2264
2817
  }
2265
2818
  function printUi(reviewId, values) {
2266
2819
  const info = daemonInfoSync();
2267
2820
  if (!info) return writers.err(tr("\u0434\u0435\u043C\u043E\u043D \u043D\u0435 \u0437\u0430\u043F\u0443\u0449\u0435\u043D: revdeck daemon start", "the daemon is not running: revdeck daemon start"));
2268
- const url = `${info.url}/?t=${info.token}${reviewId ? `&review=${encodeURIComponent(reviewId)}` : ""}`;
2821
+ const url = `${uiBaseUrl(info)}/?t=${info.token}${reviewId ? `&review=${encodeURIComponent(reviewId)}` : ""}`;
2269
2822
  writers.out(`UI: ${url}`);
2270
2823
  if (values.open) openBrowser(url);
2271
2824
  }
@@ -2283,14 +2836,25 @@ function openBrowser(url) {
2283
2836
  spawn2(command, args, { detached: true, stdio: "ignore" }).unref();
2284
2837
  });
2285
2838
  }
2286
- async function daemon(action) {
2839
+ async function daemon(action, values = {}) {
2840
+ if (isDevContainer() && ["start", "stop", "restart", "install", "uninstall"].includes(action)) {
2841
+ fail(
2842
+ tr(
2843
+ "\u0432 devcontainer \u0434\u0435\u043C\u043E\u043D\u043E\u043C \u0443\u043F\u0440\u0430\u0432\u043B\u044F\u0435\u0442 \u0445\u043E\u0441\u0442: \u0432\u044B\u043F\u043E\u043B\u043D\u0438\u0442\u0435 \u043A\u043E\u043C\u0430\u043D\u0434\u0443 \u043D\u0430 \u0445\u043E\u0441\u0442\u0435",
2844
+ "the daemon is managed by the host in a devcontainer: run this command on the host"
2845
+ ),
2846
+ tr("revdeck daemon restart \u043D\u0430 \u0445\u043E\u0441\u0442\u0435", "revdeck daemon restart on the host")
2847
+ );
2848
+ }
2287
2849
  if (action === "start") {
2288
- const { client: client2, started } = await ensureDaemon();
2850
+ const { client: client2, started } = await ensureDaemon({
2851
+ ...values.host ? { daemonArgs: ["--host", String(values.host)] } : {}
2852
+ });
2289
2853
  const ping = await client2.request("ping");
2290
2854
  client2.close();
2291
2855
  const info = await daemonInfo();
2292
2856
  writers.out(`${started ? tr("\u0437\u0430\u043F\u0443\u0449\u0435\u043D", "started") : tr("\u0443\u0436\u0435 \u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442", "already running")} ${tr("\u0434\u0435\u043C\u043E\u043D", "daemon")} pid ${ping.pid}, ${tr("\u0432\u0435\u0440\u0441\u0438\u044F", "version")} ${ping.version}`);
2293
- if (info) writers.out(`${info.url}/?t=${info.token}`);
2857
+ if (info) writers.out(`${uiBaseUrl(info)}/?t=${info.token}`);
2294
2858
  return;
2295
2859
  }
2296
2860
  if (action === "status") {
@@ -2330,11 +2894,11 @@ async function daemon(action) {
2330
2894
  }
2331
2895
  if (action === "restart") {
2332
2896
  await daemon("stop");
2333
- return daemon("start");
2897
+ return daemon("start", values);
2334
2898
  }
2335
2899
  if (action === "logs") {
2336
2900
  try {
2337
- const text = await readFile5(logPath(), "utf8");
2901
+ const text = await readFile7(logPath(), "utf8");
2338
2902
  writers.out(text.split("\n").slice(-80).join("\n"));
2339
2903
  } catch {
2340
2904
  writers.out(tr("\u043B\u043E\u0433 \u043F\u0443\u0441\u0442", "the log is empty"));
@@ -2399,7 +2963,7 @@ async function findings(arg, action, values) {
2399
2963
  const { review } = await resolveReview(client2, findRepoRoot(), reviewArg(values, arg), values);
2400
2964
  if (action === "set") {
2401
2965
  if (!values.file) fail(tr("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 --file \u0441 JSON \u0437\u0430\u043C\u0435\u0447\u0430\u043D\u0438\u0439", "pass --file with findings JSON"));
2402
- const raw = JSON.parse(await readFile5(String(values.file), "utf8"));
2966
+ const raw = JSON.parse(await readFile7(String(values.file), "utf8"));
2403
2967
  const list2 = Array.isArray(raw) ? raw : raw.findings;
2404
2968
  if (!Array.isArray(list2)) fail(tr("\u043E\u0436\u0438\u0434\u0430\u043B\u0441\u044F \u043C\u0430\u0441\u0441\u0438\u0432 \u0438\u043B\u0438 {findings: [...]}", "expected an array or {findings: [...]}"));
2405
2969
  const { findings: saved } = await client2.request("findings.put", { review: review.id, findings: list2 });
@@ -2426,7 +2990,7 @@ async function findings(arg, action, values) {
2426
2990
  }
2427
2991
  async function summary(arg, values) {
2428
2992
  if (!values.file && process.stdin.isTTY) fail(tr("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 --file \u0441 JSON \u0432\u044B\u0436\u0438\u043C\u043A\u0438 \u0438\u043B\u0438 \u043F\u0435\u0440\u0435\u0434\u0430\u0439\u0442\u0435 \u0435\u0451 \u0447\u0435\u0440\u0435\u0437 stdin", "pass --file with summary JSON or pipe it via stdin"));
2429
- const raw = values.file ? JSON.parse(await readFile5(String(values.file), "utf8")) : JSON.parse(await readStdin());
2993
+ const raw = values.file ? JSON.parse(await readFile7(String(values.file), "utf8")) : JSON.parse(await readStdin());
2430
2994
  await withClient(async ({ client: client2 }) => {
2431
2995
  const { review } = await resolveReview(client2, findRepoRoot(), reviewArg(values, arg), values);
2432
2996
  const result = await client2.request("summary.put", { review: review.id, summary: raw });
@@ -2498,6 +3062,45 @@ async function notes(arg, action, values) {
2498
3062
  }
2499
3063
  });
2500
3064
  }
3065
+ async function react(arg, values) {
3066
+ if (!values.note || !values.emoji) fail(tr("\u043D\u0443\u0436\u043D\u044B --note <id> \u0438 --emoji <name>", "--note <id> and --emoji <name> are required"));
3067
+ await withClient(async ({ client: client2 }) => {
3068
+ const { review } = await resolveReview(client2, findRepoRoot(), reviewArg(values, arg), values);
3069
+ const result = await client2.request("reactions.toggle", {
3070
+ review: review.id,
3071
+ noteId: String(values.note),
3072
+ emoji: String(values.emoji)
3073
+ });
3074
+ if (values.json) return printJson(result);
3075
+ writers.out(`${result.active ? "\u2714" : "\u25CB"} ${values.emoji}${result.active ? ` \xB7 ${tr("\u043F\u043E\u0441\u0442\u0430\u0432\u043B\u0435\u043D\u0430", "added")}` : ` \xB7 ${tr("\u0441\u043D\u044F\u0442\u0430", "removed")}`}`);
3076
+ for (const reaction of result.reactions) writers.out(` ${reaction.name}${reaction.mine ? " \xB7" : ""} ${reaction.count}`);
3077
+ });
3078
+ }
3079
+ async function attach(arg, values) {
3080
+ if (!values.file) fail(tr("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 --file \u0441 \u043A\u0430\u0440\u0442\u0438\u043D\u043A\u043E\u0439 \u0438\u043B\u0438 \u0432\u0438\u0434\u0435\u043E", "pass --file with an image or video"));
3081
+ const name = path9.basename(String(values.file));
3082
+ const spec = attachmentSpec(name);
3083
+ if (!spec) fail(tr("\u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043D\u044B PNG, JPEG, GIF, WebP, AVIF, MP4, WebM, OGV \u0438 MOV", "PNG, JPEG, GIF, WebP, AVIF, MP4, WebM, OGV and MOV are allowed"));
3084
+ const file = String(values.file);
3085
+ const info = await stat(file).catch(() => null);
3086
+ if (!info?.isFile()) fail(tr(`\u043D\u0435\u0442 \u0444\u0430\u0439\u043B\u0430 ${file}`, `file ${file} does not exist`));
3087
+ if (info.size > MAX_ATTACHMENT_BYTES) {
3088
+ fail(tr(`\u0444\u0430\u0439\u043B \u0431\u043E\u043B\u044C\u0448\u0435 ${Math.floor(MAX_ATTACHMENT_BYTES / (1024 * 1024))} \u041C\u0411`, `file is larger than ${Math.floor(MAX_ATTACHMENT_BYTES / (1024 * 1024))} MB`));
3089
+ }
3090
+ const data = await readFile7(file);
3091
+ await withClient(async ({ client: client2 }) => {
3092
+ const { review } = await resolveReview(client2, findRepoRoot(), reviewArg(values, arg), values);
3093
+ const result = await client2.request("attachments.upload", {
3094
+ review: review.id,
3095
+ name,
3096
+ contentType: spec.contentType,
3097
+ data: data.toString("base64")
3098
+ });
3099
+ if (values.json) return printJson(result.attachment);
3100
+ writers.out(`${tr("\u0437\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u043E", "uploaded")}: ${result.attachment.url}`);
3101
+ writers.out(result.attachment.markdown);
3102
+ });
3103
+ }
2501
3104
  async function publish(arg, values) {
2502
3105
  await withClient(async ({ client: client2 }) => {
2503
3106
  const { review } = await resolveReview(client2, findRepoRoot(), reviewArg(values, arg), values);
@@ -2606,7 +3209,7 @@ async function readBodyInput(values) {
2606
3209
  if (values["body-file"] !== void 0) {
2607
3210
  const file = String(values["body-file"]);
2608
3211
  if (file === "-") return readStdin();
2609
- return readFile5(file, "utf8");
3212
+ return readFile7(file, "utf8");
2610
3213
  }
2611
3214
  if (process.stdin.isTTY) return null;
2612
3215
  return readStdin();
@@ -2618,12 +3221,20 @@ async function readStdin() {
2618
3221
  }
2619
3222
  async function doctor(root) {
2620
3223
  const rows = [];
3224
+ const container = isDevContainer();
2621
3225
  const major = Number(process.version.slice(1).split(".")[0]);
2622
3226
  rows.push(["node", major >= 22 ? "ok" : "fail", process.version]);
3227
+ if (container) {
3228
+ rows.push(["mode", "ok", tr("devcontainer: revdeck \u0438 gh/glab \u0440\u0430\u0431\u043E\u0442\u0430\u044E\u0442 \u043D\u0430 \u0445\u043E\u0441\u0442\u0435", "devcontainer: revdeck and gh/glab run on the host")]);
3229
+ }
2623
3230
  const binaries = {};
2624
3231
  for (const bin of ["glab", "gh", "git"]) {
2625
3232
  const present = await which(bin);
2626
3233
  binaries[bin] = present;
3234
+ if (container && (bin === "glab" || bin === "gh")) {
3235
+ rows.push([bin, "ok", present ? tr("\u0435\u0441\u0442\u044C \u0432 \u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440\u0435, \u043D\u043E \u043D\u0435 \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0435\u0442\u0441\u044F", "present in the container but unused") : tr("\u043D\u0435 \u043D\u0443\u0436\u0435\u043D \u0432 \u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440\u0435: \u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442 \u043D\u0430 \u0445\u043E\u0441\u0442\u0435", "not needed in the container: the host runs it")]);
3236
+ continue;
3237
+ }
2627
3238
  rows.push([bin, present ? "ok" : "warn", present ? tr("\u043D\u0430\u0439\u0434\u0435\u043D", "found") : tr("\u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D", "not found")]);
2628
3239
  }
2629
3240
  let host;
@@ -2635,28 +3246,35 @@ async function doctor(root) {
2635
3246
  } catch (error) {
2636
3247
  rows.push(["config", "fail", redactLine(error)]);
2637
3248
  }
2638
- if (host && binaries.glab) {
2639
- try {
2640
- await run("glab", ["auth", "status", "--hostname", host], { timeoutMs: 15e3 });
2641
- rows.push(["glab auth", "ok", host]);
2642
- } catch (error) {
2643
- rows.push(["glab auth", "warn", `${tr("\u043D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u0430 \u043A", "no access to")} ${host}: ${redactLine(error).slice(0, 120)}`]);
3249
+ if (!container) {
3250
+ if (host && binaries.glab) {
3251
+ try {
3252
+ await run("glab", ["auth", "status", "--hostname", host], { timeoutMs: 15e3 });
3253
+ rows.push(["glab auth", "ok", host]);
3254
+ } catch (error) {
3255
+ rows.push(["glab auth", "warn", `${tr("\u043D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u0430 \u043A", "no access to")} ${host}: ${redactLine(error).slice(0, 120)}`]);
3256
+ }
2644
3257
  }
2645
- }
2646
- if (host && binaries.gh) {
2647
- try {
2648
- await run("gh", ["auth", "status"], { timeoutMs: 15e3, env: { GH_HOST: host } });
2649
- rows.push(["gh auth", "ok", host]);
2650
- } catch (error) {
2651
- rows.push(["gh auth", "warn", `${tr("\u043D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u0430 \u043A", "no access to")} ${host}: ${redactLine(error).slice(0, 120)}`]);
3258
+ if (host && binaries.gh) {
3259
+ try {
3260
+ await run("gh", ["auth", "status"], { timeoutMs: 15e3, env: { GH_HOST: host } });
3261
+ rows.push(["gh auth", "ok", host]);
3262
+ } catch (error) {
3263
+ rows.push(["gh auth", "warn", `${tr("\u043D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u0430 \u043A", "no access to")} ${host}: ${redactLine(error).slice(0, 120)}`]);
3264
+ }
2652
3265
  }
2653
3266
  }
2654
3267
  const running = await isDaemonRunning();
2655
3268
  const info = await daemonInfo();
2656
- rows.push(["daemon", running ? "ok" : "warn", running && info ? `${info.url} (pid ${info.pid})` : tr("\u043D\u0435 \u0437\u0430\u043F\u0443\u0449\u0435\u043D (\u0437\u0430\u043F\u0443\u0441\u0442\u0438\u0442\u0441\u044F \u0441\u0430\u043C)", "not running (starts on demand)")]);
2657
- rows.push(["ui", "ok", info?.url ?? tr("\u0431\u0443\u0434\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D \u043F\u043E\u0441\u043B\u0435 \u0437\u0430\u043F\u0443\u0441\u043A\u0430", "available after the daemon starts")]);
2658
- const { existsSync: existsSync6 } = await import("node:fs");
2659
- const skill = existsSync6(path7.join(root, ".agents", "skills", "revdeck", "SKILL.md")) || existsSync6(path7.join(root, ".claude", "skills", "revdeck", "SKILL.md")) || existsSync6(path7.join(root, ".cursor", "commands", "revdeck.md"));
3269
+ const daemonStatus = running ? "ok" : container ? "fail" : "warn";
3270
+ rows.push([
3271
+ "daemon",
3272
+ daemonStatus,
3273
+ running && info ? `${uiBaseUrl(info)} (pid ${info.pid})` : container ? tr("\u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D: \u043D\u0430 \u0445\u043E\u0441\u0442\u0435 \u0432\u044B\u043F\u043E\u043B\u043D\u0438\u0442\u0435 revdeck daemon start", "not found: run revdeck daemon start on the host") : tr("\u043D\u0435 \u0437\u0430\u043F\u0443\u0449\u0435\u043D (\u0437\u0430\u043F\u0443\u0441\u0442\u0438\u0442\u0441\u044F \u0441\u0430\u043C)", "not running (starts on demand)")
3274
+ ]);
3275
+ rows.push(["ui", running ? "ok" : "warn", info ? uiBaseUrl(info) : tr("\u0431\u0443\u0434\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D \u043F\u043E\u0441\u043B\u0435 \u0437\u0430\u043F\u0443\u0441\u043A\u0430", "available after the daemon starts")]);
3276
+ const { existsSync: existsSync8 } = await import("node:fs");
3277
+ const skill = existsSync8(path9.join(root, ".agents", "skills", "revdeck", "SKILL.md")) || existsSync8(path9.join(root, ".claude", "skills", "revdeck", "SKILL.md")) || existsSync8(path9.join(root, ".cursor", "commands", "revdeck.md"));
2660
3278
  rows.push(["adapter", skill ? "ok" : "warn", skill ? tr("\u0443\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D", "installed") : tr("\u043D\u0435\u0442: revdeck init", "missing: revdeck init")]);
2661
3279
  for (const [name, status2, detail] of rows) {
2662
3280
  writers.out(`${status2 === "ok" ? "\u2714" : status2 === "warn" ? "!" : "\u2716"} ${name.padEnd(10)} ${detail}`);