opencode-resolve 0.1.7 → 0.1.9

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.
@@ -0,0 +1,56 @@
1
+ export type PermissionValue = "ask" | "allow" | "deny";
2
+ export type ResolveAgentName = "coder" | "reviewer" | "resolver" | "codex" | "glm" | "architect" | "gpt-coder" | "debugger" | "researcher" | "explorer" | "deep-reviewer" | "planner";
3
+ export type ModelAlias = ResolveAgentName | "glm" | "gpt" | "quick" | "deep" | "fast" | "strong" | "mini" | "codex" | "bronze" | "silver" | "gold" | "gpt-bronze" | "gpt-silver" | "gpt-gold" | "glm-bronze" | "glm-silver" | "glm-gold";
4
+ export type AgentMode = "subagent" | "primary" | "all";
5
+ export type ResolveAgentConfig = {
6
+ enabled?: boolean;
7
+ model?: string;
8
+ mode?: AgentMode;
9
+ description?: string;
10
+ prompt?: string;
11
+ color?: string;
12
+ maxSteps?: number;
13
+ tools?: Record<string, boolean>;
14
+ permission?: {
15
+ edit?: PermissionValue;
16
+ bash?: PermissionValue | Record<string, PermissionValue>;
17
+ webfetch?: PermissionValue;
18
+ doom_loop?: PermissionValue;
19
+ external_directory?: PermissionValue;
20
+ };
21
+ };
22
+ export type ProfileName = "mix" | "glm" | "gpt";
23
+ export type TierName = "bronze" | "silver" | "gold";
24
+ export type ResolveConfig = {
25
+ profile?: ProfileName;
26
+ tier?: TierName;
27
+ enabled?: ResolveAgentName[];
28
+ models?: Partial<Record<ModelAlias, string>>;
29
+ agents?: Partial<Record<ResolveAgentName, ResolveAgentConfig>>;
30
+ preserveNative?: boolean;
31
+ context7?: boolean;
32
+ commands?: boolean;
33
+ autoApprove?: boolean;
34
+ maxParallelSubagents?: number;
35
+ autoUpdate?: boolean;
36
+ };
37
+ export type ResolvePluginOptions = ResolveConfig & {
38
+ config?: string;
39
+ };
40
+ export type UnknownRecord = Record<string, unknown>;
41
+ export type ProjectContext = {
42
+ /** Project knowledge files or directories that exist */
43
+ knowledgeFiles: string[];
44
+ /** Pattern/context documents discovered under committed context directories */
45
+ contextFiles: string[];
46
+ /** Package manager detected (npm, yarn, pnpm, bun) */
47
+ packageManager: string | undefined;
48
+ /** Verification commands available (e.g. "npx tsc --noEmit", "npm run lint") */
49
+ verifyCommands: string[];
50
+ /** Whether this is a TypeScript project */
51
+ hasTypeScript: boolean;
52
+ /** Whether HARNESS.md exists */
53
+ hasHarness: boolean;
54
+ /** Whether AGENTS.md exists */
55
+ hasAgents: boolean;
56
+ };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ import { ProjectContext, ResolveConfig } from "./types.js";
2
+ export declare const PLUGIN_VERSION: string;
3
+ export declare const UPDATE_CHECK_INTERVAL_MS: number;
4
+ export declare const UPDATE_CHECK_FILE: string;
5
+ export declare const PLUGIN_CACHE_DIR: string;
6
+ export declare function runCommand(command: string, cwd: string, timeoutMs: number): Promise<{
7
+ stdout: string;
8
+ stderr: string;
9
+ exitCode: number;
10
+ }>;
11
+ export declare function truncateOutput(text: string, maxLen: number): string;
12
+ /** Sanitize a string for safe use as a shell argument. Strips dangerous metacharacters. */
13
+ export declare function sanitizeShellArg(input: string): string;
14
+ export declare function isMissingFileError(error: unknown): boolean;
15
+ export declare function formatError(error: unknown): string;
16
+ export declare function classifyBashCommand(pattern: string): "allow" | "deny" | "ask";
17
+ export declare function existsFile(path: string): Promise<boolean>;
18
+ export declare function existsPath(path: string): Promise<boolean>;
19
+ export declare function existsDirectory(path: string): Promise<boolean>;
20
+ export declare function detectProjectContext(directory: string): Promise<ProjectContext>;
21
+ export declare function collectContextFiles(rootDirectory: string, relativeDirectory: string, maxFiles?: number): Promise<string[]>;
22
+ export declare function readPluginVersion(): string;
23
+ export declare function readUpdateCheckCache(): {
24
+ checkedAt: number;
25
+ latest: string;
26
+ } | undefined;
27
+ export declare function isNewerVersion(candidate: string, baseline: string): boolean;
28
+ export declare function maybeAutoUpdate(): Promise<void>;
29
+ export declare function readFirstJson(paths: string[]): Promise<ResolveConfig | undefined>;
30
+ export declare const BANNED_COMMANDS: ReadonlyArray<RegExp>;
31
+ export declare const DANGEROUS_BASH_PATTERNS: ReadonlyArray<RegExp>;
32
+ export declare const ALWAYS_SAFE_COMMANDS: ReadonlyArray<string>;
33
+ export declare const SAFE_BASH_PREFIXES: ReadonlyArray<readonly [string, ReadonlyArray<string>]>;
package/dist/utils.js ADDED
@@ -0,0 +1,371 @@
1
+ import { spawn } from "node:child_process";
2
+ import { stat, readFile, access, readdir } from "node:fs/promises";
3
+ import { join, dirname, extname, relative } from "node:path";
4
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { fileURLToPath } from "node:url";
7
+ import { normalizeResolveConfig } from "./config.js";
8
+ export const PLUGIN_VERSION = readPluginVersion();
9
+ export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
10
+ export const UPDATE_CHECK_FILE = join(homedir(), ".cache", "opencode-resolve", "update-check.json");
11
+ export const PLUGIN_CACHE_DIR = join(homedir(), ".cache", "opencode", "packages", "opencode-resolve@latest");
12
+ export function runCommand(command, cwd, timeoutMs) {
13
+ return new Promise((resolve) => {
14
+ const proc = spawn("sh", ["-c", command], {
15
+ cwd,
16
+ env: { ...process.env, CI: "true", GIT_TERMINAL_PROMPT: "0" },
17
+ stdio: ["ignore", "pipe", "pipe"],
18
+ });
19
+ let stdout = "";
20
+ let stderr = "";
21
+ const timer = setTimeout(() => { proc.kill("SIGKILL"); }, timeoutMs);
22
+ proc.stdout.on("data", (d) => { stdout += d.toString(); });
23
+ proc.stderr.on("data", (d) => { stderr += d.toString(); });
24
+ proc.on("close", (code) => {
25
+ clearTimeout(timer);
26
+ resolve({ stdout, stderr, exitCode: code ?? 1 });
27
+ });
28
+ proc.on("error", (err) => {
29
+ clearTimeout(timer);
30
+ resolve({ stdout: "", stderr: err.message, exitCode: 1 });
31
+ });
32
+ });
33
+ }
34
+ export function truncateOutput(text, maxLen) {
35
+ if (text.length <= maxLen)
36
+ return text;
37
+ return text.slice(0, maxLen) + `\n... (${text.length - maxLen} more bytes truncated)`;
38
+ }
39
+ /** Sanitize a string for safe use as a shell argument. Strips dangerous metacharacters. */
40
+ export function sanitizeShellArg(input) {
41
+ return input
42
+ .replace(/[;&|`$(){}[\]!#~<>\\]/g, "") // strip shell metacharacters
43
+ .replace(/'/g, "'\\''") // escape single quotes for single-quoted context
44
+ .slice(0, 500);
45
+ }
46
+ export function isMissingFileError(error) {
47
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
48
+ }
49
+ export function formatError(error) {
50
+ return error instanceof Error ? error.message : String(error);
51
+ }
52
+ export function classifyBashCommand(pattern) {
53
+ const cmd = pattern.trim();
54
+ for (const re of BANNED_COMMANDS) {
55
+ if (re.test(cmd))
56
+ return "deny";
57
+ }
58
+ for (const re of DANGEROUS_BASH_PATTERNS) {
59
+ if (re.test(cmd))
60
+ return "deny";
61
+ }
62
+ const firstToken = cmd.split(/\s+/)[0];
63
+ if (ALWAYS_SAFE_COMMANDS.includes(firstToken))
64
+ return "allow";
65
+ for (const [prefix, subcommands] of SAFE_BASH_PREFIXES) {
66
+ if (firstToken !== prefix)
67
+ continue;
68
+ // If no subcommands listed, the prefix itself is safe (e.g. npx, node)
69
+ if (subcommands.length === 0)
70
+ return "allow";
71
+ const secondToken = cmd.split(/\s+/)[1];
72
+ if (secondToken && subcommands.includes(secondToken))
73
+ return "allow";
74
+ }
75
+ return "ask";
76
+ }
77
+ export async function existsFile(path) {
78
+ try {
79
+ const s = await stat(path);
80
+ return s.isFile();
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ }
86
+ export async function existsPath(path) {
87
+ try {
88
+ await stat(path);
89
+ return true;
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ }
95
+ export async function existsDirectory(path) {
96
+ try {
97
+ const s = await stat(path);
98
+ return s.isDirectory();
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ }
104
+ export async function detectProjectContext(directory) {
105
+ const ctx = {
106
+ knowledgeFiles: [],
107
+ contextFiles: [],
108
+ packageManager: undefined,
109
+ verifyCommands: [],
110
+ hasTypeScript: false,
111
+ hasHarness: false,
112
+ hasAgents: false,
113
+ };
114
+ const knowledgeFileCandidates = [
115
+ "HARNESS.md",
116
+ "AGENTS.md",
117
+ "CLAUDE.md",
118
+ "CONVENTIONS.md",
119
+ ];
120
+ for (const candidate of knowledgeFileCandidates) {
121
+ const fullPath = join(directory, candidate);
122
+ if (await existsFile(fullPath)) {
123
+ ctx.knowledgeFiles.push(candidate);
124
+ if (candidate === "HARNESS.md")
125
+ ctx.hasHarness = true;
126
+ if (candidate === "AGENTS.md")
127
+ ctx.hasAgents = true;
128
+ }
129
+ }
130
+ const knowledgeDirectoryCandidates = [
131
+ ".opencode/context",
132
+ ".claude/context",
133
+ "context",
134
+ "thoughts",
135
+ ];
136
+ for (const candidate of knowledgeDirectoryCandidates) {
137
+ const fullPath = join(directory, candidate);
138
+ if (await existsDirectory(fullPath)) {
139
+ ctx.knowledgeFiles.push(candidate);
140
+ ctx.contextFiles.push(...await collectContextFiles(directory, candidate));
141
+ }
142
+ }
143
+ if (await existsFile(join(directory, "pnpm-lock.yaml")))
144
+ ctx.packageManager = "pnpm";
145
+ else if (await existsFile(join(directory, "bun.lockb")) || await existsFile(join(directory, "bun.lock")))
146
+ ctx.packageManager = "bun";
147
+ else if (await existsFile(join(directory, "yarn.lock")))
148
+ ctx.packageManager = "yarn";
149
+ else if (await existsFile(join(directory, "package-lock.json")))
150
+ ctx.packageManager = "npm";
151
+ ctx.hasTypeScript = await existsFile(join(directory, "tsconfig.json"));
152
+ try {
153
+ const pkgRaw = await readFile(join(directory, "package.json"), "utf8");
154
+ const pkg = JSON.parse(pkgRaw);
155
+ const scripts = typeof pkg?.scripts === "object" && pkg.scripts !== null ? pkg.scripts : {};
156
+ if (typeof scripts["typecheck"] === "string" || typeof scripts["type-check"] === "string") {
157
+ const cmd = scripts["typecheck"] ?? scripts["type-check"];
158
+ ctx.verifyCommands.push(`npm run ${scripts["typecheck"] ? "typecheck" : "type-check"}`);
159
+ }
160
+ else if (ctx.hasTypeScript) {
161
+ ctx.verifyCommands.push("npx tsc --noEmit");
162
+ }
163
+ if (typeof scripts["lint"] === "string") {
164
+ ctx.verifyCommands.push("npm run lint");
165
+ }
166
+ if (typeof scripts["test"] === "string") {
167
+ ctx.verifyCommands.push("npm test");
168
+ }
169
+ }
170
+ catch {
171
+ // no package.json or unreadable — skip
172
+ }
173
+ return ctx;
174
+ }
175
+ export async function collectContextFiles(rootDirectory, relativeDirectory, maxFiles = 40) {
176
+ const allowedExtensions = new Set([".md", ".mdx", ".txt", ".json", ".jsonc", ".yaml", ".yml"]);
177
+ const results = [];
178
+ async function walk(current, depth) {
179
+ if (depth > 3 || results.length >= maxFiles)
180
+ return;
181
+ let entries;
182
+ try {
183
+ entries = await readdir(current, { withFileTypes: true });
184
+ }
185
+ catch {
186
+ return;
187
+ }
188
+ entries.sort((a, b) => a.name.localeCompare(b.name));
189
+ for (const entry of entries) {
190
+ if (results.length >= maxFiles)
191
+ break;
192
+ if (entry.name.startsWith("."))
193
+ continue;
194
+ if (entry.name === "archive")
195
+ continue;
196
+ const fullPath = join(current, entry.name);
197
+ if (entry.isDirectory()) {
198
+ await walk(fullPath, depth + 1);
199
+ continue;
200
+ }
201
+ if (!entry.isFile())
202
+ continue;
203
+ if (!allowedExtensions.has(extname(entry.name).toLowerCase()))
204
+ continue;
205
+ results.push(relative(rootDirectory, fullPath));
206
+ }
207
+ }
208
+ await walk(join(rootDirectory, relativeDirectory), 0);
209
+ return results;
210
+ }
211
+ export function readPluginVersion() {
212
+ try {
213
+ const pkgRoot = dirname(dirname(fileURLToPath(import.meta.url)));
214
+ const pkg = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8"));
215
+ return typeof pkg?.version === "string" ? pkg.version : "unknown";
216
+ }
217
+ catch {
218
+ return "unknown";
219
+ }
220
+ }
221
+ export function readUpdateCheckCache() {
222
+ try {
223
+ const raw = readFileSync(UPDATE_CHECK_FILE, "utf8");
224
+ const parsed = JSON.parse(raw);
225
+ if (typeof parsed?.checkedAt === "number" &&
226
+ typeof parsed?.latest === "string") {
227
+ return { checkedAt: parsed.checkedAt, latest: parsed.latest };
228
+ }
229
+ }
230
+ catch {
231
+ // file missing or unparseable
232
+ }
233
+ return undefined;
234
+ }
235
+ export function isNewerVersion(candidate, baseline) {
236
+ const a = candidate.split(".").map((n) => Number.parseInt(n, 10));
237
+ const b = baseline.split(".").map((n) => Number.parseInt(n, 10));
238
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
239
+ const av = Number.isFinite(a[i]) ? a[i] : 0;
240
+ const bv = Number.isFinite(b[i]) ? b[i] : 0;
241
+ if (av > bv)
242
+ return true;
243
+ if (av < bv)
244
+ return false;
245
+ }
246
+ return false;
247
+ }
248
+ export async function maybeAutoUpdate() {
249
+ try {
250
+ const previous = readUpdateCheckCache();
251
+ if (previous && Date.now() - previous.checkedAt < UPDATE_CHECK_INTERVAL_MS) {
252
+ return;
253
+ }
254
+ }
255
+ catch {
256
+ // ignore corrupt cache and re-check
257
+ }
258
+ let latest;
259
+ try {
260
+ const response = await fetch("https://registry.npmjs.org/opencode-resolve/latest", {
261
+ headers: { Accept: "application/json" },
262
+ signal: AbortSignal.timeout(5000),
263
+ });
264
+ if (!response.ok)
265
+ return;
266
+ const data = (await response.json());
267
+ if (typeof data?.version !== "string")
268
+ return;
269
+ latest = data.version;
270
+ }
271
+ catch {
272
+ return;
273
+ }
274
+ try {
275
+ mkdirSync(dirname(UPDATE_CHECK_FILE), { recursive: true });
276
+ writeFileSync(UPDATE_CHECK_FILE, JSON.stringify({ checkedAt: Date.now(), latest }));
277
+ }
278
+ catch {
279
+ // best-effort; don't block on cache write failure
280
+ }
281
+ if (!isNewerVersion(latest, PLUGIN_VERSION))
282
+ return;
283
+ console.log(`[opencode-resolve] new version v${latest} available (current: v${PLUGIN_VERSION}) — refreshing cache in background. Restart OpenCode to activate (current session stays on v${PLUGIN_VERSION}).`);
284
+ try {
285
+ spawn("sh", ["-c", `rm -rf "${PLUGIN_CACHE_DIR}" && opencode plugin opencode-resolve --global --force`], { detached: true, stdio: "ignore" }).unref();
286
+ }
287
+ catch {
288
+ // If spawn fails, the user already saw the notice and can run the command manually.
289
+ }
290
+ }
291
+ export async function readFirstJson(paths) {
292
+ for (const path of paths) {
293
+ try {
294
+ await access(path);
295
+ return normalizeResolveConfig(JSON.parse(await readFile(path, "utf8")), path);
296
+ }
297
+ catch (error) {
298
+ if (isMissingFileError(error))
299
+ continue;
300
+ throw new Error(`Failed to read OpenCode Resolve config at ${path}: ${formatError(error)}`);
301
+ }
302
+ }
303
+ return undefined;
304
+ }
305
+ export const BANNED_COMMANDS = [
306
+ /\b(vim?|nano|emacs|pico|ed)\b/, // interactive editors
307
+ /\b(less|more|most|pg)\b/, // pagers
308
+ /\bman\s/, // man pages
309
+ /\b(python|python3|ipython)\b(\s*$)/, // Python REPL
310
+ /\b(node|bun|deno)\b(\s*$)/, // JS REPL
311
+ /\b(irb|ghci|scala|jshell)\b(\s*$)/, // other REPLs
312
+ /\b(bash|zsh|fish|sh)\s+-i\b/, // interactive shells
313
+ /\bgit\s+add\s+-p\b/, // interactive git add
314
+ /\bgit\s+rebase\s+-i\b/, // interactive rebase
315
+ /\bgit\s+commit\b(?!\s+-m)/, // commit without -m
316
+ /\bscreen\b/, // screen multiplexer
317
+ /\btmux\b(?!.*[|&;])/, // tmux without subcommand pipe
318
+ /\bssh\b(?!\s.*-\w*[oN])/, // ssh without batch flags
319
+ /\bsftp\b/, // sftp interactive
320
+ /\btelnet\b/, // telnet interactive
321
+ /\bnc\b(\s*$)/, // netcat interactive
322
+ /\bsqlite3?\b(\s*$)/, // sqlite interactive
323
+ /\bpsql\b(\s*$)/, // psql interactive
324
+ /\bmysql\b(\s*$)/, // mysql interactive
325
+ // Ralph Loop: dangerous patterns that waste tokens or cause damage
326
+ /\bcurl\b.*\|\s*(ba)?sh\b/, // curl pipe to shell
327
+ /\bwget\b.*\|\s*(ba)?sh\b/, // wget pipe to shell
328
+ /\beval\s/, // eval is dangerous
329
+ /\bchmod\s+(-R\s+)?777\b/, // chmod 777
330
+ /\bchown\s+-R\s+/, // recursive chown
331
+ /\bsudo\s+(rm|chmod|chown|dd|mkfs)/, // sudo + destructive
332
+ /\bgit\s+push\s+--force/, // force push
333
+ /\bgit\s+reset\s+--hard/, // hard reset
334
+ /\brm\s+(-rf?|-fr?)\s+[^.]/, // rm -rf (not dotfiles)
335
+ /\bdd\s+if=/, // dd can destroy disks
336
+ /\b(mkfs|format)\b/, // filesystem format
337
+ ];
338
+ export const DANGEROUS_BASH_PATTERNS = [
339
+ /\brm\s+.*-[rR].*[fF].*\s+\//, // rm -rf /... (absolute path)
340
+ /\bgit\s+push\s+.*(--force|-f\b)/, // force push
341
+ /\bgit\s+reset\s+--hard/, // hard reset
342
+ /\bgit\s+clean\s+-fd/, // clean untracked files
343
+ /\bsudo\s+rm\b/, // sudo rm
344
+ /\bdd\s+.*of=\/dev\//, // dd to device
345
+ /\bchmod\s+-R\s+777\s+\//, // chmod everything
346
+ /\b(DROP|TRUNCATE)\s/i, // SQL destructive
347
+ ];
348
+ export const ALWAYS_SAFE_COMMANDS = [
349
+ "ls", "cat", "head", "tail", "wc", "which", "echo", "pwd", "env",
350
+ "printenv", "whoami", "uname", "date", "df", "du", "free", "top",
351
+ "ps", "grep", "find", "sort", "uniq", "diff", "file", "stat",
352
+ "touch", "mkdir", "cp", "mv", "sed", "awk", "tr", "cut", "xargs",
353
+ "curl", "wget", "dig", "nslookup", "ping",
354
+ ];
355
+ export const SAFE_BASH_PREFIXES = [
356
+ ["npm", ["test", "run", "start", "build", "lint", "typecheck", "check", "info", "list", "view", "outdated", "audit", "pack"]],
357
+ ["npx", []],
358
+ ["node", []],
359
+ ["bun", ["test", "run", "build", "install", "add", "remove"]],
360
+ ["yarn", ["test", "run", "build", "install", "add", "remove", "lint", "typecheck"]],
361
+ ["pnpm", ["test", "run", "build", "install", "add", "remove", "lint", "typecheck"]],
362
+ ["git", ["status", "log", "diff", "branch", "show", "remote", "stash", "tag", "describe"]],
363
+ ["tsc", []],
364
+ ["eslint", []],
365
+ ["prettier", []],
366
+ ["jest", []],
367
+ ["vitest", []],
368
+ ["pytest", []],
369
+ ["cargo", ["test", "check", "build", "clippy", "fmt"]],
370
+ ["make", ["test", "check", "build", "lint", "clean"]],
371
+ ];
@@ -1,5 +1,5 @@
1
1
  {
2
- "enabled": ["coder", "resolver", "explorer", "reviewer", "deep-reviewer", "planner"],
2
+ "profile": "mix",
3
3
  "preserveNative": true,
4
4
  "context7": true,
5
5
  "commands": false,
@@ -9,11 +9,14 @@
9
9
  "agents": {
10
10
  "coder": {
11
11
  "enabled": true,
12
- "mode": "all"
12
+ "mode": "subagent"
13
13
  },
14
14
  "resolver": {
15
15
  "enabled": true
16
16
  },
17
+ "codex": {
18
+ "enabled": false
19
+ },
17
20
  "explorer": {
18
21
  "enabled": true,
19
22
  "mode": "subagent"
@@ -41,6 +44,9 @@
41
44
  },
42
45
  "researcher": {
43
46
  "enabled": false
47
+ },
48
+ "glm": {
49
+ "enabled": false
44
50
  }
45
51
  }
46
52
  }