@mechanai/deepreview 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,15 +6,28 @@ implementation plan.
6
6
 
7
7
  ## Install
8
8
 
9
- Add to your `opencode.json` (project-level or global):
9
+ Run the setup script:
10
10
 
11
- ```jsonc
12
- {
13
- "plugin": ["@mechanai/deepreview"],
14
- }
11
+ ```bash
12
+ bunx @mechanai/deepreview@latest/setup # Global install (~/.config/opencode/)
13
+ bunx @mechanai/deepreview@latest/setup --local # Project-level install (.opencode/)
14
+ ```
15
+
16
+ Or with Node.js (v22+):
17
+
18
+ ```bash
19
+ npx @mechanai/deepreview@latest/setup
20
+ npx @mechanai/deepreview@latest/setup --local
15
21
  ```
16
22
 
17
- OpenCode installs the package automatically at startup.
23
+ This will:
24
+
25
+ 1. Add `@mechanai/deepreview` to the `plugin` array in your `opencode.json` (creates the file if needed)
26
+ 2. Symlink agents and commands into the appropriate config directory
27
+
28
+ > [!NOTE]
29
+ > The symlinks are needed because OpenCode does not yet auto-discover
30
+ > agents and commands from installed plugin packages.
18
31
 
19
32
  ## Usage
20
33
 
@@ -62,12 +75,16 @@ its own context, keeping token usage minimal.
62
75
  ## Requirements
63
76
 
64
77
  - [OpenCode](https://opencode.ai)
78
+ - [Bun](https://bun.sh/) >= 1.2 or [Node.js](https://nodejs.org/) >= 22
65
79
  - `git`
66
80
  - `gh` CLI (only for PR commands)
67
81
 
68
82
  > [!NOTE]
69
83
  > If upgrading from the old `npx @anthropic/deepreview install` workflow, remove
70
- > `~/.config/opencode/agents/deepreview*` files — they are no longer used.
84
+ > the old copied files first (`rm ~/.config/opencode/agents/deepreview*
85
+ ~/.config/opencode/commands/deepreview*`), then run the setup script above.
86
+ > The setup script uses symlinks instead of copies, so future upgrades only
87
+ > require re-running the script.
71
88
 
72
89
  ## Development
73
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mechanai/deepreview",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "Multi-agent parallel code/spec review for OpenCode",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -9,15 +9,15 @@
9
9
  },
10
10
  "files": [
11
11
  "src/",
12
- "agents/",
13
- "commands/",
12
+ "dist/",
14
13
  ".opencode/"
15
14
  ],
16
15
  "type": "module",
17
16
  "main": "./.opencode/plugins/deepreview.ts",
18
17
  "exports": {
19
18
  ".": "./.opencode/plugins/deepreview.ts",
20
- "./api": "./src/post-review.ts"
19
+ "./api": "./src/post-review.ts",
20
+ "./setup": "./dist/setup.mjs"
21
21
  },
22
22
  "publishConfig": {
23
23
  "access": "public"
@@ -25,6 +25,7 @@
25
25
  "dependencies": {
26
26
  "gray-matter": "4.0.3",
27
27
  "js-yaml": "4.1.0",
28
+ "jsonc-parser": "3.3.1",
28
29
  "parse-diff": "0.11.1"
29
30
  },
30
31
  "devDependencies": {
@@ -44,6 +45,7 @@
44
45
  }
45
46
  },
46
47
  "engines": {
47
- "bun": ">=1.2.0"
48
+ "bun": ">=1.2.0",
49
+ "node": ">=22.0.0"
48
50
  }
49
51
  }
@@ -0,0 +1,159 @@
1
+ import { describe, expect, test, beforeEach, afterEach } from "bun:test";
2
+ import {
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ symlinkSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import path from "node:path";
11
+ import os from "node:os";
12
+
13
+ const setupScript = path.resolve(import.meta.dirname, "setup.ts");
14
+
15
+ function makeTempDir(): string {
16
+ const dir = path.join(
17
+ os.tmpdir(),
18
+ `deepreview-setup-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
19
+ );
20
+ mkdirSync(dir, { recursive: true });
21
+ return dir;
22
+ }
23
+
24
+ function run(cwd: string, args: string[] = []) {
25
+ return Bun.spawnSync(["bun", "run", setupScript, ...args], {
26
+ cwd,
27
+ env: { ...process.env, XDG_CONFIG_HOME: cwd },
28
+ });
29
+ }
30
+
31
+ function readConfig(filePath: string): { plugin?: unknown } {
32
+ const parsed: unknown = JSON.parse(readFileSync(filePath, "utf-8"));
33
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
34
+ throw new Error("config is not an object");
35
+ }
36
+ return parsed as { plugin?: unknown };
37
+ }
38
+
39
+ describe("setup script - config", () => {
40
+ let tempDir: string;
41
+
42
+ beforeEach(() => {
43
+ tempDir = makeTempDir();
44
+ });
45
+
46
+ afterEach(async () => {
47
+ await Bun.$`rm -rf ${tempDir}`;
48
+ });
49
+
50
+ test("creates opencode.json with plugin when no config exists (global)", () => {
51
+ const result = run(tempDir);
52
+ expect(result.exitCode).toBe(0);
53
+
54
+ const configPath = path.join(tempDir, "opencode", "opencode.json");
55
+ expect(existsSync(configPath)).toBe(true);
56
+
57
+ const config = readConfig(configPath);
58
+ expect(config.plugin).toContain("@mechanai/deepreview");
59
+ });
60
+
61
+ test("--local installs into .opencode/ in cwd", () => {
62
+ const result = run(tempDir, ["--local"]);
63
+ expect(result.exitCode).toBe(0);
64
+
65
+ const configPath = path.join(tempDir, "opencode.json");
66
+ expect(existsSync(configPath)).toBe(true);
67
+
68
+ const agentsDir = path.join(tempDir, ".opencode", "agents");
69
+ expect(existsSync(path.join(agentsDir, "deepreview-synthesizer.md"))).toBe(true);
70
+ });
71
+
72
+ test("adds plugin to existing config without duplicating", () => {
73
+ const configPath = path.join(tempDir, "opencode");
74
+ mkdirSync(configPath, { recursive: true });
75
+ writeFileSync(
76
+ path.join(configPath, "opencode.json"),
77
+ JSON.stringify({ plugin: ["other-plugin"] }, null, 2),
78
+ );
79
+
80
+ run(tempDir);
81
+ const config = readConfig(path.join(configPath, "opencode.json"));
82
+ expect(config.plugin).toEqual(["other-plugin", "@mechanai/deepreview"]);
83
+
84
+ // Run again — should not duplicate
85
+ run(tempDir);
86
+ const config2 = readConfig(path.join(configPath, "opencode.json"));
87
+ expect(config2.plugin).toEqual(["other-plugin", "@mechanai/deepreview"]);
88
+ });
89
+
90
+ test("preserves JSONC comments", () => {
91
+ const configDir = path.join(tempDir, "opencode");
92
+ mkdirSync(configDir, { recursive: true });
93
+ const content = '{\n // My comment\n "provider": {}\n}\n';
94
+ writeFileSync(path.join(configDir, "opencode.jsonc"), content);
95
+
96
+ run(tempDir);
97
+ const result = readFileSync(path.join(configDir, "opencode.jsonc"), "utf-8");
98
+ expect(result).toContain("// My comment");
99
+ expect(result).toContain("@mechanai/deepreview");
100
+ });
101
+ });
102
+
103
+ describe("setup script - symlinks", () => {
104
+ let tempDir: string;
105
+
106
+ beforeEach(() => {
107
+ tempDir = makeTempDir();
108
+ });
109
+
110
+ afterEach(async () => {
111
+ await Bun.$`rm -rf ${tempDir}`;
112
+ });
113
+
114
+ test("creates symlinks for agents and commands (global)", () => {
115
+ const result = run(tempDir);
116
+ expect(result.exitCode).toBe(0);
117
+
118
+ const agentsDir = path.join(tempDir, "opencode", "agents");
119
+ const synthesizer = path.join(agentsDir, "deepreview-synthesizer.md");
120
+ expect(existsSync(synthesizer)).toBe(true);
121
+ expect(lstatSync(synthesizer).isSymbolicLink()).toBe(true);
122
+
123
+ const commandsDir = path.join(tempDir, "opencode", "commands");
124
+ const mainCmd = path.join(commandsDir, "deepreview.md");
125
+ expect(existsSync(mainCmd)).toBe(true);
126
+ expect(lstatSync(mainCmd).isSymbolicLink()).toBe(true);
127
+ });
128
+
129
+ test("removes stale symlinks on upgrade", () => {
130
+ const agentsDir = path.join(tempDir, "opencode", "agents");
131
+ mkdirSync(agentsDir, { recursive: true });
132
+
133
+ // Create a fake stale symlink that contains "deepreview" but doesn't exist in package
134
+ symlinkSync("/nonexistent", path.join(agentsDir, "deepreview-old-agent.md"));
135
+
136
+ run(tempDir);
137
+
138
+ // Stale symlink should be removed
139
+ expect(existsSync(path.join(agentsDir, "deepreview-old-agent.md"))).toBe(false);
140
+ // But real agents should exist
141
+ expect(existsSync(path.join(agentsDir, "deepreview-synthesizer.md"))).toBe(true);
142
+ });
143
+
144
+ test("handles dangling symlinks at dest gracefully", () => {
145
+ const agentsDir = path.join(tempDir, "opencode", "agents");
146
+ mkdirSync(agentsDir, { recursive: true });
147
+
148
+ // Create a dangling symlink where a real agent should go
149
+ symlinkSync("/nonexistent", path.join(agentsDir, "deepreview-synthesizer.md"));
150
+
151
+ const result = run(tempDir);
152
+ expect(result.exitCode).toBe(0);
153
+
154
+ // Should have replaced the dangling symlink
155
+ const dest = path.join(agentsDir, "deepreview-synthesizer.md");
156
+ expect(lstatSync(dest).isSymbolicLink()).toBe(true);
157
+ expect(existsSync(dest)).toBe(true);
158
+ });
159
+ });
package/src/setup.ts ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Setup script for @mechanai/deepreview.
3
+ *
4
+ * Ensures the plugin is registered in opencode.json and symlinks agents/commands
5
+ * into the OpenCode config directory.
6
+ *
7
+ * Usage:
8
+ * bunx @mechanai/deepreview/setup # Install globally (~/.config/opencode/)
9
+ * bunx @mechanai/deepreview/setup --local # Install into current project (.opencode/)
10
+ */
11
+
12
+ import {
13
+ existsSync,
14
+ lstatSync,
15
+ mkdirSync,
16
+ readdirSync,
17
+ symlinkSync,
18
+ unlinkSync,
19
+ readFileSync,
20
+ writeFileSync,
21
+ } from "node:fs";
22
+ import path from "node:path";
23
+ import os from "node:os";
24
+ import { applyEdits, modify, parse as parseJsonc } from "jsonc-parser";
25
+
26
+ const PACKAGE_NAME = "@mechanai/deepreview";
27
+ const local = process.argv.includes("--local");
28
+ const cwd = process.cwd();
29
+
30
+ const globalConfigDir = path.join(
31
+ process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"),
32
+ "opencode",
33
+ );
34
+ const targetDir = local ? path.join(cwd, ".opencode") : globalConfigDir;
35
+
36
+ // Resolve the package directory (where this script lives)
37
+ const packageDir = path.resolve(import.meta.dirname, "..");
38
+ const packageOpencode = path.join(packageDir, ".opencode");
39
+
40
+ function ensurePluginInConfig() {
41
+ const configFiles = ["opencode.jsonc", "opencode.json"];
42
+ const searchDir = local ? cwd : globalConfigDir;
43
+ let configPath: string | undefined;
44
+
45
+ for (const file of configFiles) {
46
+ const candidate = path.join(searchDir, file);
47
+ if (existsSync(candidate)) {
48
+ configPath = candidate;
49
+ break;
50
+ }
51
+ }
52
+
53
+ if (configPath === undefined) {
54
+ configPath = path.join(searchDir, "opencode.json");
55
+ mkdirSync(searchDir, { recursive: true });
56
+ writeFileSync(configPath, JSON.stringify({ plugin: [PACKAGE_NAME] }, null, 2) + "\n");
57
+ console.log(`Created ${configPath} with plugin entry.`);
58
+ return;
59
+ }
60
+
61
+ const raw = readFileSync(configPath, "utf-8");
62
+
63
+ // Check if plugin is already registered
64
+ let config: Record<string, unknown>;
65
+ try {
66
+ const parsed: unknown = parseJsonc(raw);
67
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
68
+ throw new Error("not an object");
69
+ }
70
+ // oxlint-disable-next-line no-unsafe-type-assertion -- Why: validated above with type guards
71
+ config = parsed as Record<string, unknown>;
72
+ } catch {
73
+ console.error(`Could not parse ${configPath}. Add the plugin manually:`);
74
+ console.error(` "plugin": ["${PACKAGE_NAME}"]`);
75
+ process.exitCode = 1;
76
+ return;
77
+ }
78
+
79
+ const pluginArray = Array.isArray(config.plugin) ? config.plugin : [];
80
+ const plugins = pluginArray.filter((p): p is string => typeof p === "string");
81
+ if (plugins.includes(PACKAGE_NAME)) {
82
+ console.log(`Plugin already registered in ${configPath}.`);
83
+ return;
84
+ }
85
+
86
+ // Use jsonc-parser to insert into the plugin array without stripping comments
87
+ const formatting = { formattingOptions: { insertSpaces: true, tabSize: 2 } };
88
+ const edits = Array.isArray(config.plugin)
89
+ ? modify(raw, ["plugin", pluginArray.length], PACKAGE_NAME, formatting)
90
+ : modify(raw, ["plugin"], [PACKAGE_NAME], formatting);
91
+
92
+ writeFileSync(configPath, applyEdits(raw, edits));
93
+ console.log(`Added "${PACKAGE_NAME}" to plugin array in ${configPath}.`);
94
+ }
95
+
96
+ function symlinkDirectory(kind: "agents" | "commands") {
97
+ const sourceDir = path.join(packageOpencode, kind);
98
+ if (!existsSync(sourceDir)) return;
99
+
100
+ const destDir = path.join(targetDir, kind);
101
+ mkdirSync(destDir, { recursive: true });
102
+
103
+ const sourceFiles = new Set(readdirSync(sourceDir).filter((f) => f.endsWith(".md")));
104
+ let created = 0;
105
+
106
+ // Remove stale deepreview symlinks that no longer exist in the package
107
+ for (const file of readdirSync(destDir)) {
108
+ if (!file.startsWith("deepreview-") && !file.startsWith("_deepreview-")) continue;
109
+ const dest = path.join(destDir, file);
110
+ try {
111
+ if (lstatSync(dest).isSymbolicLink() && !sourceFiles.has(file)) {
112
+ unlinkSync(dest);
113
+ }
114
+ } catch (err: unknown) {
115
+ if (err instanceof Error && !("code" in err && err.code === "ENOENT")) {
116
+ console.warn(`Could not check ${dest}: ${err.message}`);
117
+ }
118
+ }
119
+ }
120
+
121
+ for (const file of sourceFiles) {
122
+ const dest = path.join(destDir, file);
123
+ const source = path.relative(destDir, path.join(sourceDir, file));
124
+
125
+ try {
126
+ // Use lstatSync to detect both regular files and dangling symlinks
127
+ const stat = lstatSync(dest);
128
+ if (!stat.isSymbolicLink()) {
129
+ console.warn(`Skipping ${dest}: not a symlink (would overwrite regular file)`);
130
+ continue;
131
+ }
132
+ unlinkSync(dest);
133
+ } catch (err: unknown) {
134
+ if (err instanceof Error && "code" in err && err.code !== "ENOENT") {
135
+ throw err;
136
+ }
137
+ }
138
+ symlinkSync(source, dest);
139
+ created++;
140
+ }
141
+
142
+ const label = local ? `.opencode/${kind}/` : path.join(targetDir, kind) + "/";
143
+ console.log(`Linked ${created} ${kind} into ${label}`);
144
+ }
145
+
146
+ // Run
147
+ ensurePluginInConfig();
148
+ symlinkDirectory("agents");
149
+ symlinkDirectory("commands");
150
+ const scope = local ? "project" : "global";
151
+ console.log(`Done (${scope}). Run opencode to use /deepreview commands.`);
File without changes
File without changes