revdeck 0.1.0 → 0.2.0

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
@@ -50,6 +50,15 @@ function daemonInfoPath() {
50
50
  function logPath() {
51
51
  return path.join(stateDir(), "daemon.log");
52
52
  }
53
+ async function ensureDir(dir, mode = 448) {
54
+ await mkdir(dir, { recursive: true, mode });
55
+ const info = await lstat(dir);
56
+ if (!info.isDirectory()) throw new Error(`${dir}: expected a directory`);
57
+ if (process.platform !== "win32") {
58
+ const { chmod } = await import("node:fs/promises");
59
+ await chmod(dir, mode);
60
+ }
61
+ }
53
62
  async function readTextNoFollow(file, maxBytes = 4 * 1024 * 1024) {
54
63
  const info = await lstat(file);
55
64
  if (info.isSymbolicLink()) throw new Error(`${path.basename(file)}: symbolic link, read refused`);
@@ -146,7 +155,7 @@ var VERSION;
146
155
  var init_version = __esm({
147
156
  "src/version.ts"() {
148
157
  "use strict";
149
- VERSION = "0.1.0";
158
+ VERSION = "0.2.0";
150
159
  }
151
160
  });
152
161
 
@@ -182,6 +191,19 @@ async function daemonInfo() {
182
191
  return null;
183
192
  }
184
193
  }
194
+ function isDevContainer() {
195
+ return process.env.REVDECK_DEV_CONTAINER === "1";
196
+ }
197
+ function devcontainerHint() {
198
+ const runtime = process.env.REVDECK_RUNTIME_DIR ?? "(REVDECK_RUNTIME_DIR)";
199
+ const state = process.env.REVDECK_STATE_DIR ?? "(REVDECK_STATE_DIR)";
200
+ return [
201
+ "\u0434\u0435\u043C\u043E\u043D revdeck \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D \u043D\u0430 \u0445\u043E\u0441\u0442\u0435",
202
+ "\u043D\u0430 \u0445\u043E\u0441\u0442\u0435: revdeck daemon start",
203
+ `\u043F\u0440\u043E\u0432\u0435\u0440\u044C\u0442\u0435 mounts \u0438\u0437 revdeck devcontainer --write: ${runtime} \u0438 ${state}`,
204
+ "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"
205
+ ].join("; ");
206
+ }
185
207
  async function ensureDaemon(options = {}) {
186
208
  const url = process.env.REVDECK_URL;
187
209
  if (url) {
@@ -192,9 +214,12 @@ async function ensureDaemon(options = {}) {
192
214
  try {
193
215
  return { client: await connectSocket(), started: false };
194
216
  } catch (error) {
217
+ if (isDevContainer()) {
218
+ 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");
219
+ }
195
220
  if (options.autoStart === false) throw error;
196
221
  }
197
- await spawnDaemon();
222
+ await spawnDaemon(options.daemonArgs ?? []);
198
223
  const deadline = Date.now() + 15e3;
199
224
  let lastError = null;
200
225
  while (Date.now() < deadline) {
@@ -211,9 +236,9 @@ async function ensureDaemon(options = {}) {
211
236
  "\u0437\u0430\u043F\u0443\u0441\u0442\u0438\u0442\u0435 \u0432\u0440\u0443\u0447\u043D\u0443\u044E: revdeck daemon start --foreground"
212
237
  );
213
238
  }
214
- async function spawnDaemon() {
239
+ async function spawnDaemon(extraArgs = []) {
215
240
  const entry = daemonEntry();
216
- const child = spawn(entry.command, entry.args, {
241
+ const child = spawn(entry.command, [...entry.args, ...extraArgs], {
217
242
  detached: true,
218
243
  stdio: "ignore",
219
244
  cwd: process.cwd(),
@@ -234,6 +259,11 @@ function daemonEntry() {
234
259
  if (existsSync4(sibling)) return { command: process.execPath, args: [sibling] };
235
260
  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
261
  }
262
+ function uiBaseUrl(info) {
263
+ const host = process.env.REVDECK_UI_HOST;
264
+ if (!isDevContainer() || !host) return info.url;
265
+ return `http://${host}:${info.port}`;
266
+ }
237
267
  async function isDaemonRunning() {
238
268
  try {
239
269
  const client2 = await connectSocket(void 0, 400);
@@ -1058,10 +1088,10 @@ ${failed.map((item) => `- ${item.id}: ${item.error}`).join("\n")}` : ""),
1058
1088
 
1059
1089
  // src/cli/main.ts
1060
1090
  init_paths();
1061
- import { readFile as readFile5 } from "node:fs/promises";
1091
+ import { readFile as readFile7, stat } from "node:fs/promises";
1062
1092
  import { readFileSync } from "node:fs";
1063
1093
  import { parseArgs } from "node:util";
1064
- import path7 from "node:path";
1094
+ import path9 from "node:path";
1065
1095
 
1066
1096
  // src/core/config.ts
1067
1097
  init_paths();
@@ -1134,8 +1164,8 @@ function globToRegExp(glob) {
1134
1164
  function escapeRegExp(value) {
1135
1165
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1136
1166
  }
1137
- function classifyFile(path8) {
1138
- const lower = path8.toLowerCase();
1167
+ function classifyFile(path10) {
1168
+ const lower = path10.toLowerCase();
1139
1169
  if (/(^|\/)(node_modules|dist|build|coverage|vendor)\//.test(lower)) return "generated";
1140
1170
  if (/\.(md|mdx|rst|txt|adoc)$/.test(lower) || /(^|\/)(docs?|adr)\//.test(lower)) return "docs";
1141
1171
  if (/(^|\/)(__tests__|tests?|spec|e2e)\//.test(lower) || /\.(test|spec)\.[cm]?[jt]sx?$/.test(lower) || /_test\.go$/.test(lower)) return "test";
@@ -1556,7 +1586,7 @@ init_format();
1556
1586
  // src/cli/init.ts
1557
1587
  init_paths();
1558
1588
  import { existsSync as existsSync5 } from "node:fs";
1559
- import { appendFile, cp, mkdir as mkdir2, readFile as readFile3, writeFile } from "node:fs/promises";
1589
+ import { appendFile, cp, lstat as lstat2, mkdir as mkdir2, readdir, readFile as readFile3, readlink, rm, symlink, writeFile } from "node:fs/promises";
1560
1590
  import path5 from "node:path";
1561
1591
  init_redact();
1562
1592
  init_locale();
@@ -1604,8 +1634,9 @@ async function runInit(rootInput, options) {
1604
1634
  await ensureGitignore(root);
1605
1635
  const adapters = options.adapters.length ? options.adapters : detectAdapters(root);
1606
1636
  const mcp = mcpServer(options.locale);
1637
+ const canonicalSkill = await installSkill(root, options.locale);
1607
1638
  for (const adapter of adapters) {
1608
- await installAdapter(root, adapter, mcp, options.locale);
1639
+ await installAdapter(root, adapter, mcp, options.locale, canonicalSkill);
1609
1640
  }
1610
1641
  if (!options.noService) {
1611
1642
  writers.out("");
@@ -1618,6 +1649,7 @@ async function runInit(rootInput, options) {
1618
1649
  writers.out(` revdeck open ${tr("<\u043D\u043E\u043C\u0435\u0440 \u0438\u043B\u0438 \u0441\u0441\u044B\u043B\u043A\u0430>", "<number or URL>")}`);
1619
1650
  writers.out(" revdeck ui");
1620
1651
  }
1652
+ var CANONICAL_SKILL = ".agents/skills/revdeck";
1621
1653
  function commandMd(locale2) {
1622
1654
  if (locale2 === "ru") {
1623
1655
  return `---
@@ -1643,10 +1675,10 @@ function commandToml(locale2) {
1643
1675
  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
1676
  }
1645
1677
  var SPECS = {
1646
- agents: { skills: ".agents/skills/revdeck" },
1647
- dsh: { skills: ".agents/skills/revdeck" },
1678
+ agents: {},
1679
+ dsh: {},
1648
1680
  claude: {
1649
- skills: ".claude/skills/revdeck",
1681
+ linkSkill: true,
1650
1682
  mcp: ".mcp.json",
1651
1683
  files: [{ path: ".claude/commands/revdeck.md", content: commandMd }]
1652
1684
  },
@@ -1670,25 +1702,13 @@ function detectAdapters(root) {
1670
1702
  if (existsSync5(path5.join(root, ".opencode"))) adapters.add("opencode");
1671
1703
  return [...adapters];
1672
1704
  }
1673
- async function installAdapter(root, adapter, mcp, locale2) {
1705
+ async function installAdapter(root, adapter, mcp, locale2, canonicalSkill) {
1674
1706
  const spec = SPECS[adapter];
1675
1707
  if (!spec) {
1676
1708
  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
1709
  return;
1678
1710
  }
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
- }
1711
+ if (spec.linkSkill && canonicalSkill) await linkSkill(root, canonicalSkill);
1692
1712
  if (spec.mcp) await mergeMcpJson(path5.join(root, spec.mcp), root, mcp);
1693
1713
  for (const file of spec.files ?? []) {
1694
1714
  const target = path5.join(root, file.path);
@@ -1714,9 +1734,67 @@ async function installAdapter(root, adapter, mcp, locale2) {
1714
1734
  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
1735
  }
1716
1736
  }
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)));
1737
+ async function installSkill(root, locale2) {
1738
+ const source = path5.join(packageRoot(), "skills", "revdeck", locale2);
1739
+ if (!existsSync5(source)) {
1740
+ 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")}`);
1741
+ return null;
1742
+ }
1743
+ const target = path5.join(root, CANONICAL_SKILL);
1744
+ await copySkill(source, target);
1745
+ writers.out(`\u2714 ${tr("\u0441\u043A\u0438\u043B\u043B", "skill")}: ${CANONICAL_SKILL}`);
1746
+ return target;
1747
+ }
1748
+ async function copySkill(source, target) {
1749
+ await mkdir2(target, { recursive: true });
1750
+ for (const entry of await skillFiles(source)) {
1751
+ const file = path5.join(target, entry);
1752
+ await mkdir2(path5.dirname(file), { recursive: true });
1753
+ await cp(path5.join(source, entry), file, { force: true });
1754
+ }
1755
+ }
1756
+ async function linkSkill(root, canonicalSkill) {
1757
+ const target = path5.join(root, ".claude", "skills", "revdeck");
1758
+ const relative = path5.relative(path5.dirname(target), canonicalSkill);
1759
+ await mkdir2(path5.dirname(target), { recursive: true });
1760
+ const existing = await lstat2(target).catch(() => null);
1761
+ if (existing) {
1762
+ if (existing.isSymbolicLink()) {
1763
+ const current2 = await readlink(target).catch(() => "");
1764
+ if (current2 === relative) {
1765
+ writers.out(`\u2714 ${tr("\u0441\u043A\u0438\u043B\u043B", "skill")}: .claude/skills/revdeck -> ${CANONICAL_SKILL}`);
1766
+ return;
1767
+ }
1768
+ await rm(target, { force: true });
1769
+ } else if (existing.isDirectory() && await isManagedSkillDir(target)) {
1770
+ await rm(target, { recursive: true, force: true });
1771
+ } else {
1772
+ 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")}`);
1773
+ return;
1774
+ }
1775
+ }
1776
+ if (process.platform === "win32") {
1777
+ await copySkill(canonicalSkill, target);
1778
+ } else {
1779
+ await symlink(relative, target, "dir");
1780
+ }
1781
+ writers.out(`\u2714 ${tr("\u0441\u043A\u0438\u043B\u043B", "skill")}: .claude/skills/revdeck -> ${CANONICAL_SKILL}`);
1782
+ }
1783
+ async function isManagedSkillDir(dir) {
1784
+ const skill = path5.join(dir, "SKILL.md");
1785
+ if (!existsSync5(skill)) return false;
1786
+ const text = await readFile3(skill, "utf8").catch(() => "");
1787
+ return /^name:\s*revdeck\s*$/m.test(text);
1788
+ }
1789
+ async function skillFiles(dir, prefix = "") {
1790
+ const entries = await readdir(path5.join(dir, prefix), { withFileTypes: true });
1791
+ const files = [];
1792
+ for (const entry of entries) {
1793
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
1794
+ if (entry.isDirectory()) files.push(...await skillFiles(dir, relative));
1795
+ else if (entry.isFile()) files.push(relative);
1796
+ }
1797
+ return files.sort();
1720
1798
  }
1721
1799
  async function mergeMcpJson(file, root, mcp) {
1722
1800
  let data = {};
@@ -1802,12 +1880,368 @@ function describeError(error) {
1802
1880
  return redactLine(error instanceof Error ? error.message : error);
1803
1881
  }
1804
1882
 
1883
+ // src/cli/eject.ts
1884
+ init_paths();
1885
+ import { existsSync as existsSync7 } from "node:fs";
1886
+ import { lstat as lstat3, readFile as readFile5, rm as rm2, writeFile as writeFile3 } from "node:fs/promises";
1887
+ import path7 from "node:path";
1888
+
1889
+ // src/core/jsonc.ts
1890
+ function parseJsonc(text) {
1891
+ let withoutComments = "";
1892
+ let inString = false;
1893
+ let escaped = false;
1894
+ let lineComment = false;
1895
+ let blockComment = false;
1896
+ for (let index = 0; index < text.length; index++) {
1897
+ const char = text[index];
1898
+ const next = text[index + 1];
1899
+ if (lineComment) {
1900
+ if (char === "\n") {
1901
+ lineComment = false;
1902
+ withoutComments += char;
1903
+ }
1904
+ continue;
1905
+ }
1906
+ if (blockComment) {
1907
+ if (char === "*" && next === "/") {
1908
+ blockComment = false;
1909
+ index += 1;
1910
+ } else if (char === "\n") {
1911
+ withoutComments += char;
1912
+ }
1913
+ continue;
1914
+ }
1915
+ if (inString) {
1916
+ withoutComments += char;
1917
+ if (escaped) escaped = false;
1918
+ else if (char === "\\") escaped = true;
1919
+ else if (char === '"') inString = false;
1920
+ continue;
1921
+ }
1922
+ if (char === '"') {
1923
+ inString = true;
1924
+ withoutComments += char;
1925
+ continue;
1926
+ }
1927
+ if (char === "/" && next === "/") {
1928
+ lineComment = true;
1929
+ index += 1;
1930
+ continue;
1931
+ }
1932
+ if (char === "/" && next === "*") {
1933
+ blockComment = true;
1934
+ index += 1;
1935
+ continue;
1936
+ }
1937
+ withoutComments += char;
1938
+ }
1939
+ let cleaned = "";
1940
+ inString = false;
1941
+ escaped = false;
1942
+ for (let index = 0; index < withoutComments.length; index++) {
1943
+ const char = withoutComments[index];
1944
+ if (inString) {
1945
+ cleaned += char;
1946
+ if (escaped) escaped = false;
1947
+ else if (char === "\\") escaped = true;
1948
+ else if (char === '"') inString = false;
1949
+ continue;
1950
+ }
1951
+ if (char === '"') {
1952
+ inString = true;
1953
+ cleaned += char;
1954
+ continue;
1955
+ }
1956
+ if (char === ",") {
1957
+ let lookahead = index + 1;
1958
+ while (lookahead < withoutComments.length && /\s/.test(withoutComments[lookahead])) lookahead += 1;
1959
+ const next = withoutComments[lookahead];
1960
+ if (next === "}" || next === "]") continue;
1961
+ }
1962
+ cleaned += char;
1963
+ }
1964
+ return JSON.parse(cleaned);
1965
+ }
1966
+ function isRecord(value) {
1967
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1968
+ }
1969
+
1970
+ // src/cli/devcontainer.ts
1971
+ init_paths();
1972
+ import { existsSync as existsSync6 } from "node:fs";
1973
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile2 } from "node:fs/promises";
1974
+ import path6 from "node:path";
1975
+ init_locale();
1976
+ init_format();
1977
+ var CONTAINER_RUNTIME_DIR = "/run/revdeck";
1978
+ var CONTAINER_STATE_DIR = "/var/lib/revdeck";
1979
+ var CONTAINER_UI_HOST = "host.docker.internal";
1980
+ function devcontainerPatch() {
1981
+ const mountSource = { runtime: runtimeDir(), state: stateDir() };
1982
+ return {
1983
+ mounts: [
1984
+ { type: "bind", source: mountSource.runtime, target: CONTAINER_RUNTIME_DIR },
1985
+ { type: "bind", source: mountSource.state, target: CONTAINER_STATE_DIR, readonly: true }
1986
+ ],
1987
+ containerEnv: {
1988
+ REVDECK_RUNTIME_DIR: CONTAINER_RUNTIME_DIR,
1989
+ REVDECK_STATE_DIR: CONTAINER_STATE_DIR,
1990
+ REVDECK_DEV_CONTAINER: "1",
1991
+ REVDECK_UI_HOST: CONTAINER_UI_HOST
1992
+ },
1993
+ remoteEnv: {
1994
+ REVDECK_RUNTIME_DIR: CONTAINER_RUNTIME_DIR,
1995
+ REVDECK_STATE_DIR: CONTAINER_STATE_DIR,
1996
+ REVDECK_DEV_CONTAINER: "1",
1997
+ REVDECK_UI_HOST: CONTAINER_UI_HOST
1998
+ },
1999
+ runArgs: [`--add-host=${CONTAINER_UI_HOST}:host-gateway`]
2000
+ };
2001
+ }
2002
+ function mergeDevcontainerConfig(existing, patch) {
2003
+ const result = { ...existing };
2004
+ const currentMounts = Array.isArray(existing.mounts) ? [...existing.mounts] : [];
2005
+ for (const mount of patch.mounts) {
2006
+ const index = currentMounts.findIndex((item) => isRecord(item) && item.target === mount.target);
2007
+ if (index >= 0) currentMounts[index] = mount;
2008
+ else currentMounts.push(mount);
2009
+ }
2010
+ result.mounts = currentMounts;
2011
+ const containerEnv = isRecord(existing.containerEnv) ? { ...existing.containerEnv } : {};
2012
+ Object.assign(containerEnv, patch.containerEnv);
2013
+ result.containerEnv = containerEnv;
2014
+ const remoteEnv = isRecord(existing.remoteEnv) ? { ...existing.remoteEnv } : {};
2015
+ Object.assign(remoteEnv, patch.remoteEnv);
2016
+ result.remoteEnv = remoteEnv;
2017
+ const runArgs = Array.isArray(existing.runArgs) ? existing.runArgs.filter((item) => typeof item === "string") : [];
2018
+ for (const arg of patch.runArgs) if (!runArgs.includes(arg)) runArgs.push(arg);
2019
+ result.runArgs = runArgs;
2020
+ return result;
2021
+ }
2022
+ async function runDevcontainer(rootInput, options) {
2023
+ const root = findRepoRoot(rootInput);
2024
+ const patch = devcontainerPatch();
2025
+ await ensureDir(runtimeDir());
2026
+ await ensureDir(stateDir());
2027
+ const dir = path6.join(root, ".devcontainer");
2028
+ const file = path6.join(dir, "devcontainer.json");
2029
+ writers.out(tr("\u0425\u043E\u0441\u0442-\u043A\u0430\u0442\u0430\u043B\u043E\u0433\u0438 revdeck:", "revdeck host directories:"));
2030
+ writers.out(` ${tr("\u0441\u043E\u043A\u0435\u0442", "socket")}: ${runtimeDir()} -> ${CONTAINER_RUNTIME_DIR}`);
2031
+ writers.out(` ${tr("\u0441\u043E\u0441\u0442\u043E\u044F\u043D\u0438\u0435", "state")}: ${stateDir()} -> ${CONTAINER_STATE_DIR} (${tr("\u0442\u043E\u043B\u044C\u043A\u043E \u0447\u0442\u0435\u043D\u0438\u0435", "read-only")})`);
2032
+ 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."));
2033
+ if (!options.write) {
2034
+ writers.out("");
2035
+ writers.out(tr("\u0424\u0440\u0430\u0433\u043C\u0435\u043D\u0442 \u0434\u043B\u044F .devcontainer/devcontainer.json:", "Snippet for .devcontainer/devcontainer.json:"));
2036
+ writers.out(JSON.stringify(patch, null, 2));
2037
+ writers.out("");
2038
+ 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"));
2039
+ return;
2040
+ }
2041
+ await mkdir3(dir, { recursive: true });
2042
+ const existed = existsSync6(file);
2043
+ let existing = {};
2044
+ if (existed) {
2045
+ const text = await readFile4(file, "utf8");
2046
+ let parsed;
2047
+ try {
2048
+ parsed = parseJsonc(text);
2049
+ } catch (error) {
2050
+ throw new Error(
2051
+ tr(
2052
+ `${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`,
2053
+ `${path6.relative(root, file)}: could not parse JSONC (${error.message}); add the mounts manually`
2054
+ )
2055
+ );
2056
+ }
2057
+ if (!isRecord(parsed)) throw new Error(`${path6.relative(root, file)}: ${tr("\u043E\u0436\u0438\u0434\u0430\u043B\u0441\u044F \u043E\u0431\u044A\u0435\u043A\u0442", "expected an object")}`);
2058
+ existing = parsed;
2059
+ } else {
2060
+ existing = { image: "mcr.microsoft.com/devcontainers/base:bookworm" };
2061
+ }
2062
+ const merged = mergeDevcontainerConfig(existing, patch);
2063
+ await writeFile2(file, `${JSON.stringify(merged, null, 2)}
2064
+ `);
2065
+ writers.out(`\u2714 ${path6.relative(root, file)}`);
2066
+ if (existed) {
2067
+ writers.out(tr(" \u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u0438 \u0432 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044E\u0449\u0435\u043C JSONC \u043D\u0435 \u0441\u043E\u0445\u0440\u0430\u043D\u044F\u044E\u0442\u0441\u044F: \u043F\u0440\u043E\u0432\u0435\u0440\u044C\u0442\u0435 diff.", " comments in an existing JSONC are not preserved: review the diff."));
2068
+ }
2069
+ writers.out("");
2070
+ writers.out(tr("\u0414\u0430\u043B\u044C\u0448\u0435:", "Next:"));
2071
+ writers.out(tr(" 1. \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.", " 1. Rebuild/reopen the devcontainer."));
2072
+ writers.out(tr(" 2. \u041D\u0430 \u0445\u043E\u0441\u0442\u0435: revdeck daemon start.", " 2. On the host: revdeck daemon start."));
2073
+ 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."));
2074
+ 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."));
2075
+ }
2076
+
2077
+ // src/cli/eject.ts
2078
+ init_locale();
2079
+ init_format();
2080
+ var CONFIG_FILES2 = [".revdeck.yml", ".revdeck.yaml"];
2081
+ var COMMAND_FILES = [
2082
+ ".claude/commands/revdeck.md",
2083
+ ".cursor/commands/revdeck.md",
2084
+ ".codex/prompts/revdeck.md",
2085
+ ".gemini/commands/revdeck.toml",
2086
+ ".opencode/command/revdeck.md",
2087
+ ".github/prompts/revdeck.prompt.md"
2088
+ ];
2089
+ var SKILL_DIRS = [".agents/skills/revdeck", ".claude/skills/revdeck"];
2090
+ var MCP_FILES = [".mcp.json", ".cursor/mcp.json"];
2091
+ async function runEject(rootInput, options) {
2092
+ const root = findRepoRoot(rootInput);
2093
+ const changes = [];
2094
+ const warnings = [];
2095
+ if (!options.keepConfig) {
2096
+ for (const name of CONFIG_FILES2) {
2097
+ const file = path7.join(root, name);
2098
+ 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 }) });
2099
+ }
2100
+ }
2101
+ for (const name of SKILL_DIRS) {
2102
+ const target = path7.join(root, name);
2103
+ if (!existsSync7(target)) continue;
2104
+ const managed = await isManagedSkill(target);
2105
+ if (!managed) {
2106
+ 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")}`);
2107
+ continue;
2108
+ }
2109
+ 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 }) });
2110
+ }
2111
+ for (const name of COMMAND_FILES) {
2112
+ const file = path7.join(root, name);
2113
+ if (!existsSync7(file)) continue;
2114
+ const text = await readFile5(file, "utf8").catch(() => "");
2115
+ if (!/revdeck/i.test(text)) {
2116
+ 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")}`);
2117
+ continue;
2118
+ }
2119
+ 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 }) });
2120
+ }
2121
+ for (const name of MCP_FILES) {
2122
+ const file = path7.join(root, name);
2123
+ if (!existsSync7(file)) continue;
2124
+ const text = await readFile5(file, "utf8").catch(() => "");
2125
+ let data;
2126
+ try {
2127
+ data = JSON.parse(text);
2128
+ } catch {
2129
+ warnings.push(`${name}: ${tr("\u043D\u0435 JSON \u2014 \u043D\u0435 \u0442\u0440\u043E\u0433\u0430\u044E", "not JSON \u2014 leaving it untouched")}`);
2130
+ continue;
2131
+ }
2132
+ const servers = data.mcpServers;
2133
+ if (!servers || !("revdeck" in servers)) continue;
2134
+ changes.push({
2135
+ path: name,
2136
+ action: tr("\u0443\u0431\u0440\u0430\u0442\u044C MCP-\u0441\u0435\u0440\u0432\u0435\u0440 revdeck", "remove the revdeck MCP server"),
2137
+ remove: async () => {
2138
+ delete servers.revdeck;
2139
+ await writeFile3(file, `${JSON.stringify(data, null, 2)}
2140
+ `);
2141
+ }
2142
+ });
2143
+ }
2144
+ const devcontainer = path7.join(root, ".devcontainer", "devcontainer.json");
2145
+ if (existsSync7(devcontainer)) {
2146
+ const text = await readFile5(devcontainer, "utf8");
2147
+ try {
2148
+ const parsed = parseJsonc(text);
2149
+ if (isRecord(parsed)) {
2150
+ const next = { ...parsed };
2151
+ let changed = false;
2152
+ if (Array.isArray(next.mounts)) {
2153
+ const mounts = next.mounts.filter((item) => !(isRecord(item) && (item.target === CONTAINER_RUNTIME_DIR || item.target === CONTAINER_STATE_DIR)));
2154
+ if (mounts.length !== next.mounts.length) {
2155
+ next.mounts = mounts;
2156
+ changed = true;
2157
+ }
2158
+ }
2159
+ for (const key of ["containerEnv", "remoteEnv"]) {
2160
+ if (!isRecord(next[key])) continue;
2161
+ const env = { ...next[key] };
2162
+ for (const name of ["REVDECK_RUNTIME_DIR", "REVDECK_STATE_DIR", "REVDECK_DEV_CONTAINER", "REVDECK_UI_HOST"]) delete env[name];
2163
+ if (Object.keys(env).length !== Object.keys(next[key]).length) {
2164
+ next[key] = env;
2165
+ changed = true;
2166
+ }
2167
+ }
2168
+ if (Array.isArray(next.runArgs)) {
2169
+ const args = next.runArgs.filter((item) => item !== "--add-host=host.docker.internal:host-gateway");
2170
+ if (args.length !== next.runArgs.length) {
2171
+ next.runArgs = args;
2172
+ changed = true;
2173
+ }
2174
+ }
2175
+ if (changed) {
2176
+ changes.push({
2177
+ path: ".devcontainer/devcontainer.json",
2178
+ 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"),
2179
+ remove: () => writeFile3(devcontainer, `${JSON.stringify(next, null, 2)}
2180
+ `)
2181
+ });
2182
+ }
2183
+ }
2184
+ } catch {
2185
+ warnings.push(`.devcontainer/devcontainer.json: ${tr("\u043D\u0435 JSONC \u2014 \u043D\u0435 \u0442\u0440\u043E\u0433\u0430\u044E", "not JSONC \u2014 leaving it untouched")}`);
2186
+ }
2187
+ }
2188
+ const cache = path7.join(root, ".revdeck");
2189
+ if (existsSync7(cache)) {
2190
+ 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 }) });
2191
+ }
2192
+ const gitignore = path7.join(root, ".gitignore");
2193
+ if (existsSync7(gitignore)) {
2194
+ const text = await readFile5(gitignore, "utf8");
2195
+ if (/(^|\n)\.revdeck\/?\s*(\n|$)/.test(text)) {
2196
+ changes.push({
2197
+ path: ".gitignore",
2198
+ action: tr("\u0443\u0431\u0440\u0430\u0442\u044C \u0441\u0442\u0440\u043E\u043A\u0443 .revdeck/", "remove the .revdeck/ line"),
2199
+ remove: async () => {
2200
+ const next = text.split("\n").filter((line) => line.trim() !== ".revdeck/" && line.trim() !== ".revdeck").join("\n");
2201
+ await writeFile3(gitignore, next);
2202
+ }
2203
+ });
2204
+ }
2205
+ }
2206
+ if (!changes.length) {
2207
+ 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"));
2208
+ for (const warning of warnings) writers.err(`! ${warning}`);
2209
+ return;
2210
+ }
2211
+ writers.out(tr("\u0411\u0443\u0434\u0435\u0442 \u0438\u0437\u043C\u0435\u043D\u0435\u043D\u043E:", "Will change:"));
2212
+ for (const change of changes) writers.out(` ${change.action}: ${change.path}`);
2213
+ for (const warning of warnings) writers.err(`! ${warning}`);
2214
+ if (!options.yes) {
2215
+ writers.out("");
2216
+ 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."));
2217
+ return;
2218
+ }
2219
+ for (const change of changes) {
2220
+ await change.remove();
2221
+ writers.out(`\u2714 ${change.action}: ${change.path}`);
2222
+ }
2223
+ 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."));
2224
+ }
2225
+ async function isManagedSkill(target) {
2226
+ try {
2227
+ const info = await lstat3(target);
2228
+ if (info.isSymbolicLink()) return true;
2229
+ if (!info.isDirectory()) return false;
2230
+ const skill = path7.join(target, "SKILL.md");
2231
+ if (!existsSync7(skill)) return false;
2232
+ const text = await readFile5(skill, "utf8");
2233
+ return /^name:\s*revdeck\s*$/m.test(text) || /revdeck/i.test(text.slice(0, 2e3));
2234
+ } catch {
2235
+ return false;
2236
+ }
2237
+ }
2238
+
1805
2239
  // src/cli/export.ts
1806
2240
  init_client();
1807
2241
  init_format();
1808
2242
  init_locale();
1809
- import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile2 } from "node:fs/promises";
1810
- import path6 from "node:path";
2243
+ import { mkdir as mkdir4, readFile as readFile6, writeFile as writeFile4 } from "node:fs/promises";
2244
+ import path8 from "node:path";
1811
2245
  async function reviewFor(client2, root, arg) {
1812
2246
  if (arg && /^(gitlab|github):/.test(arg)) {
1813
2247
  const { review } = await client2.request("reviews.get", { review: arg });
@@ -1863,13 +2297,13 @@ async function exportReview(root, arg, dir) {
1863
2297
  const { client: client2 } = await ensureDaemon();
1864
2298
  try {
1865
2299
  const review = await reviewFor(client2, root, arg);
1866
- const target = path6.resolve(root, dir);
1867
- await mkdir3(target, { recursive: true });
2300
+ const target = path8.resolve(root, dir);
2301
+ await mkdir4(target, { recursive: true });
1868
2302
  const base = `mr-${review.info.ref.number}`;
1869
2303
  const files = [];
1870
2304
  const write = async (name, data) => {
1871
- const file = path6.join(target, name);
1872
- await writeFile2(file, `${JSON.stringify(data, null, 2)}
2305
+ const file = path8.join(target, name);
2306
+ await writeFile4(file, `${JSON.stringify(data, null, 2)}
1873
2307
  `, { mode: 420 });
1874
2308
  files.push(name);
1875
2309
  };
@@ -1934,9 +2368,9 @@ async function exportReview(root, arg, dir) {
1934
2368
  }
1935
2369
  }
1936
2370
  await write(`${base}-published.json`, published);
1937
- await writeFile2(path6.join(target, `${base}.diff`), await serializeDiff(client2, review), { mode: 420 });
2371
+ await writeFile4(path8.join(target, `${base}.diff`), await serializeDiff(client2, review), { mode: 420 });
1938
2372
  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(", ")}`);
2373
+ 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
2374
  } finally {
1941
2375
  client2.close();
1942
2376
  }
@@ -1945,11 +2379,11 @@ async function importReview(root, arg, dir) {
1945
2379
  const { client: client2 } = await ensureDaemon();
1946
2380
  try {
1947
2381
  const review = await reviewFor(client2, root, arg);
1948
- const target = path6.resolve(root, dir);
2382
+ const target = path8.resolve(root, dir);
1949
2383
  const base = `mr-${review.info.ref.number}`;
1950
2384
  const read = async (name) => {
1951
2385
  try {
1952
- return JSON.parse(await readFile4(path6.join(target, name), "utf8"));
2386
+ return JSON.parse(await readFile6(path8.join(target, name), "utf8"));
1953
2387
  } catch {
1954
2388
  return null;
1955
2389
  }
@@ -1997,6 +2431,53 @@ async function importReview(root, arg, dir) {
1997
2431
  }
1998
2432
  }
1999
2433
 
2434
+ // src/core/attachments.ts
2435
+ var MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
2436
+ var MIME_BY_EXTENSION = {
2437
+ ".png": "image/png",
2438
+ ".jpg": "image/jpeg",
2439
+ ".jpeg": "image/jpeg",
2440
+ ".gif": "image/gif",
2441
+ ".webp": "image/webp",
2442
+ ".avif": "image/avif",
2443
+ ".mp4": "video/mp4",
2444
+ ".m4v": "video/mp4",
2445
+ ".webm": "video/webm",
2446
+ ".ogv": "video/ogg",
2447
+ ".ogg": "video/ogg",
2448
+ ".mov": "video/quicktime"
2449
+ };
2450
+ function extensionOf(name) {
2451
+ const index = name.lastIndexOf(".");
2452
+ return index >= 0 ? name.slice(index).toLowerCase() : "";
2453
+ }
2454
+ function attachmentSpec(name, contentType = "") {
2455
+ if (/[/\\]/.test(name)) return null;
2456
+ const base = name.replace(/[\u0000-\u001f\u007f]/g, "").trim();
2457
+ if (!base || base.length > 240) return null;
2458
+ const extension = extensionOf(base);
2459
+ const expected = MIME_BY_EXTENSION[extension];
2460
+ if (!expected) return null;
2461
+ const provided = normalizeContentType(String(contentType));
2462
+ if (provided && provided !== expected) return null;
2463
+ return {
2464
+ name: base,
2465
+ contentType: expected,
2466
+ extension,
2467
+ kind: expected.startsWith("image/") ? "image" : "video"
2468
+ };
2469
+ }
2470
+ var CONTENT_TYPE_ALIASES = {
2471
+ "image/jpg": "image/jpeg",
2472
+ "image/pjpeg": "image/jpeg",
2473
+ "video/x-m4v": "video/mp4",
2474
+ "video/x-ogg": "video/ogg"
2475
+ };
2476
+ function normalizeContentType(value) {
2477
+ const type = value.split(";")[0].trim().toLowerCase();
2478
+ return CONTENT_TYPE_ALIASES[type] ?? type;
2479
+ }
2480
+
2000
2481
  // src/cli/main.ts
2001
2482
  init_context();
2002
2483
  var OPTIONS = {
@@ -2027,10 +2508,13 @@ var OPTIONS = {
2027
2508
  force: { type: "boolean", default: false },
2028
2509
  adapters: { type: "string" },
2029
2510
  "no-service": { type: "boolean", default: false },
2511
+ "keep-config": { type: "boolean", default: false },
2512
+ write: { type: "boolean", default: false },
2030
2513
  explain: { type: "string" },
2031
2514
  limit: { type: "string" },
2032
2515
  decision: { type: "string" },
2033
2516
  note: { type: "string" },
2517
+ emoji: { type: "string" },
2034
2518
  text: { type: "string" },
2035
2519
  line: { type: "string" },
2036
2520
  id: { type: "string" },
@@ -2049,6 +2533,8 @@ function parse(argv) {
2049
2533
  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
2534
 
2051
2535
  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
2536
+ revdeck eject \u0443\u0434\u0430\u043B\u0438\u0442\u044C revdeck \u0438\u0437 \u043F\u0440\u043E\u0435\u043A\u0442\u0430 (--yes; --keep-config)
2537
+ 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
2538
  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
2539
  revdeck daemon <start|stop|status|logs|restart>
2054
2540
  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 +2547,8 @@ var HELP_RU = `revdeck ${VERSION} \u2014 \u0440\u0435\u0432\u044C\u044E GitLab/G
2061
2547
  revdeck summary <N> [--file <json>] \u0432\u044B\u0436\u0438\u043C\u043A\u0430 MR
2062
2548
  revdeck reply <N> --thread <id> (--body <\u0442\u0435\u043A\u0441\u0442> | --body-file <\u0444\u0430\u0439\u043B> | -) [--resolve] [--publish --yes]
2063
2549
  revdeck notes <N> <list|add|update|delete|reply>
2550
+ 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
2551
+ 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
2552
  revdeck publish <N> [--kind reply|finding|note] [--ids a,b] [--yes]
2065
2553
  revdeck rules [<N>] [--explain <\u043F\u0443\u0442\u044C>]
2066
2554
  revdeck checks <N> [--run --yes]
@@ -2074,6 +2562,8 @@ var HELP_RU = `revdeck ${VERSION} \u2014 \u0440\u0435\u0432\u044C\u044E GitLab/G
2074
2562
  var HELP_EN = `revdeck ${VERSION} \u2014 code review for GitLab/GitHub, for humans and agents
2075
2563
 
2076
2564
  revdeck init set up a project: .revdeck.yml, adapters, MCP
2565
+ revdeck eject remove revdeck from the project (--yes; --keep-config)
2566
+ revdeck devcontainer connect a devcontainer to the host daemon (--write)
2077
2567
  revdeck doctor check the environment and access
2078
2568
  revdeck daemon <start|stop|status|logs|restart>
2079
2569
  revdeck list open reviews (local cache), --remote \u2014 from the platform
@@ -2086,6 +2576,8 @@ var HELP_EN = `revdeck ${VERSION} \u2014 code review for GitLab/GitHub, for huma
2086
2576
  revdeck summary <N> [--file <json>] review summary
2087
2577
  revdeck reply <N> --thread <id> (--body <text> | --body-file <file> | -) [--resolve] [--publish --yes]
2088
2578
  revdeck notes <N> <list|add|update|delete|reply>
2579
+ revdeck react <N> --note <id> --emoji <name> add or remove a reaction
2580
+ revdeck attach <N> --file <path> upload an image/video to the platform
2089
2581
  revdeck publish <N> [--kind reply|finding|note] [--ids a,b] [--yes]
2090
2582
  revdeck rules [<N>] [--explain <path>]
2091
2583
  revdeck checks <N> [--run --yes]
@@ -2120,10 +2612,14 @@ async function main2() {
2120
2612
  switch (command) {
2121
2613
  case "init":
2122
2614
  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" });
2615
+ case "eject":
2616
+ return runEject(root, { yes: values.yes === true && values["dry-run"] !== true, keepConfig: values["keep-config"] === true });
2617
+ case "devcontainer":
2618
+ return runDevcontainer(root, { write: values.write === true });
2123
2619
  case "doctor":
2124
2620
  return doctor(root);
2125
2621
  case "daemon":
2126
- return daemon(positionals[0] ?? "status");
2622
+ return daemon(positionals[0] ?? "status", values);
2127
2623
  case "list":
2128
2624
  return list(root, values);
2129
2625
  case "open":
@@ -2147,6 +2643,10 @@ async function main2() {
2147
2643
  return reply(positionals[0], values);
2148
2644
  case "notes":
2149
2645
  return notes(positionals[0], positionals[1] ?? "list", values);
2646
+ case "react":
2647
+ return react(positionals[0], values);
2648
+ case "attach":
2649
+ return attach(positionals[0], values);
2150
2650
  case "publish":
2151
2651
  return publish(positionals[0], values);
2152
2652
  case "rules":
@@ -2259,13 +2759,14 @@ async function ui(arg, values) {
2259
2759
  });
2260
2760
  const info = await daemonInfo();
2261
2761
  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}`);
2762
+ const url = `${uiBaseUrl(info)}/?t=${info.token}`;
2763
+ writers.out(url);
2764
+ if (values.open) openBrowser(url);
2264
2765
  }
2265
2766
  function printUi(reviewId, values) {
2266
2767
  const info = daemonInfoSync();
2267
2768
  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)}` : ""}`;
2769
+ const url = `${uiBaseUrl(info)}/?t=${info.token}${reviewId ? `&review=${encodeURIComponent(reviewId)}` : ""}`;
2269
2770
  writers.out(`UI: ${url}`);
2270
2771
  if (values.open) openBrowser(url);
2271
2772
  }
@@ -2283,14 +2784,16 @@ function openBrowser(url) {
2283
2784
  spawn2(command, args, { detached: true, stdio: "ignore" }).unref();
2284
2785
  });
2285
2786
  }
2286
- async function daemon(action) {
2787
+ async function daemon(action, values = {}) {
2287
2788
  if (action === "start") {
2288
- const { client: client2, started } = await ensureDaemon();
2789
+ const { client: client2, started } = await ensureDaemon({
2790
+ ...values.host ? { daemonArgs: ["--host", String(values.host)] } : {}
2791
+ });
2289
2792
  const ping = await client2.request("ping");
2290
2793
  client2.close();
2291
2794
  const info = await daemonInfo();
2292
2795
  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}`);
2796
+ if (info) writers.out(`${uiBaseUrl(info)}/?t=${info.token}`);
2294
2797
  return;
2295
2798
  }
2296
2799
  if (action === "status") {
@@ -2330,11 +2833,11 @@ async function daemon(action) {
2330
2833
  }
2331
2834
  if (action === "restart") {
2332
2835
  await daemon("stop");
2333
- return daemon("start");
2836
+ return daemon("start", values);
2334
2837
  }
2335
2838
  if (action === "logs") {
2336
2839
  try {
2337
- const text = await readFile5(logPath(), "utf8");
2840
+ const text = await readFile7(logPath(), "utf8");
2338
2841
  writers.out(text.split("\n").slice(-80).join("\n"));
2339
2842
  } catch {
2340
2843
  writers.out(tr("\u043B\u043E\u0433 \u043F\u0443\u0441\u0442", "the log is empty"));
@@ -2399,7 +2902,7 @@ async function findings(arg, action, values) {
2399
2902
  const { review } = await resolveReview(client2, findRepoRoot(), reviewArg(values, arg), values);
2400
2903
  if (action === "set") {
2401
2904
  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"));
2905
+ const raw = JSON.parse(await readFile7(String(values.file), "utf8"));
2403
2906
  const list2 = Array.isArray(raw) ? raw : raw.findings;
2404
2907
  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
2908
  const { findings: saved } = await client2.request("findings.put", { review: review.id, findings: list2 });
@@ -2426,7 +2929,7 @@ async function findings(arg, action, values) {
2426
2929
  }
2427
2930
  async function summary(arg, values) {
2428
2931
  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());
2932
+ const raw = values.file ? JSON.parse(await readFile7(String(values.file), "utf8")) : JSON.parse(await readStdin());
2430
2933
  await withClient(async ({ client: client2 }) => {
2431
2934
  const { review } = await resolveReview(client2, findRepoRoot(), reviewArg(values, arg), values);
2432
2935
  const result = await client2.request("summary.put", { review: review.id, summary: raw });
@@ -2498,6 +3001,45 @@ async function notes(arg, action, values) {
2498
3001
  }
2499
3002
  });
2500
3003
  }
3004
+ async function react(arg, values) {
3005
+ if (!values.note || !values.emoji) fail(tr("\u043D\u0443\u0436\u043D\u044B --note <id> \u0438 --emoji <name>", "--note <id> and --emoji <name> are required"));
3006
+ await withClient(async ({ client: client2 }) => {
3007
+ const { review } = await resolveReview(client2, findRepoRoot(), reviewArg(values, arg), values);
3008
+ const result = await client2.request("reactions.toggle", {
3009
+ review: review.id,
3010
+ noteId: String(values.note),
3011
+ emoji: String(values.emoji)
3012
+ });
3013
+ if (values.json) return printJson(result);
3014
+ 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")}`}`);
3015
+ for (const reaction of result.reactions) writers.out(` ${reaction.name}${reaction.mine ? " \xB7" : ""} ${reaction.count}`);
3016
+ });
3017
+ }
3018
+ async function attach(arg, values) {
3019
+ 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"));
3020
+ const name = path9.basename(String(values.file));
3021
+ const spec = attachmentSpec(name);
3022
+ 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"));
3023
+ const file = String(values.file);
3024
+ const info = await stat(file).catch(() => null);
3025
+ if (!info?.isFile()) fail(tr(`\u043D\u0435\u0442 \u0444\u0430\u0439\u043B\u0430 ${file}`, `file ${file} does not exist`));
3026
+ if (info.size > MAX_ATTACHMENT_BYTES) {
3027
+ 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`));
3028
+ }
3029
+ const data = await readFile7(file);
3030
+ await withClient(async ({ client: client2 }) => {
3031
+ const { review } = await resolveReview(client2, findRepoRoot(), reviewArg(values, arg), values);
3032
+ const result = await client2.request("attachments.upload", {
3033
+ review: review.id,
3034
+ name,
3035
+ contentType: spec.contentType,
3036
+ data: data.toString("base64")
3037
+ });
3038
+ if (values.json) return printJson(result.attachment);
3039
+ writers.out(`${tr("\u0437\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u043E", "uploaded")}: ${result.attachment.url}`);
3040
+ writers.out(result.attachment.markdown);
3041
+ });
3042
+ }
2501
3043
  async function publish(arg, values) {
2502
3044
  await withClient(async ({ client: client2 }) => {
2503
3045
  const { review } = await resolveReview(client2, findRepoRoot(), reviewArg(values, arg), values);
@@ -2606,7 +3148,7 @@ async function readBodyInput(values) {
2606
3148
  if (values["body-file"] !== void 0) {
2607
3149
  const file = String(values["body-file"]);
2608
3150
  if (file === "-") return readStdin();
2609
- return readFile5(file, "utf8");
3151
+ return readFile7(file, "utf8");
2610
3152
  }
2611
3153
  if (process.stdin.isTTY) return null;
2612
3154
  return readStdin();
@@ -2618,12 +3160,20 @@ async function readStdin() {
2618
3160
  }
2619
3161
  async function doctor(root) {
2620
3162
  const rows = [];
3163
+ const container = isDevContainer();
2621
3164
  const major = Number(process.version.slice(1).split(".")[0]);
2622
3165
  rows.push(["node", major >= 22 ? "ok" : "fail", process.version]);
3166
+ if (container) {
3167
+ 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")]);
3168
+ }
2623
3169
  const binaries = {};
2624
3170
  for (const bin of ["glab", "gh", "git"]) {
2625
3171
  const present = await which(bin);
2626
3172
  binaries[bin] = present;
3173
+ if (container && (bin === "glab" || bin === "gh")) {
3174
+ 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")]);
3175
+ continue;
3176
+ }
2627
3177
  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
3178
  }
2629
3179
  let host;
@@ -2635,28 +3185,35 @@ async function doctor(root) {
2635
3185
  } catch (error) {
2636
3186
  rows.push(["config", "fail", redactLine(error)]);
2637
3187
  }
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)}`]);
3188
+ if (!container) {
3189
+ if (host && binaries.glab) {
3190
+ try {
3191
+ await run("glab", ["auth", "status", "--hostname", host], { timeoutMs: 15e3 });
3192
+ rows.push(["glab auth", "ok", host]);
3193
+ } catch (error) {
3194
+ 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)}`]);
3195
+ }
2644
3196
  }
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)}`]);
3197
+ if (host && binaries.gh) {
3198
+ try {
3199
+ await run("gh", ["auth", "status"], { timeoutMs: 15e3, env: { GH_HOST: host } });
3200
+ rows.push(["gh auth", "ok", host]);
3201
+ } catch (error) {
3202
+ 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)}`]);
3203
+ }
2652
3204
  }
2653
3205
  }
2654
3206
  const running = await isDaemonRunning();
2655
3207
  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"));
3208
+ const daemonStatus = running ? "ok" : container ? "fail" : "warn";
3209
+ rows.push([
3210
+ "daemon",
3211
+ daemonStatus,
3212
+ 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)")
3213
+ ]);
3214
+ 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")]);
3215
+ const { existsSync: existsSync8 } = await import("node:fs");
3216
+ 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
3217
  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
3218
  for (const [name, status2, detail] of rows) {
2662
3219
  writers.out(`${status2 === "ok" ? "\u2714" : status2 === "warn" ? "!" : "\u2716"} ${name.padEnd(10)} ${detail}`);