Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ea9b4a695 | ||
|
|
fb3ad11f3a |
@@ -22,19 +22,19 @@ Installation defaults to `~/.config/opencode` and respects `XDG_CONFIG_HOME`. Ov
|
||||
OPENCODE_CONFIG_DIR=/path/to/opencode-config bash install.sh
|
||||
```
|
||||
|
||||
The installer atomically replaces `plugins/stanp-opencode.js` for [V2 automatic plugin discovery](https://opencode.ai/v2/docs/plugins). It does not create symlinks, install package dependencies, edit `opencode.json` or `opencode.jsonc`, install OpenCode itself, or configure provider credentials.
|
||||
The installer atomically replaces the package directory at `plugins/stanp-opencode` and adds its default GPT configuration to `opencode.json`. Existing unrelated JSON settings and `opencode.jsonc` are preserved. It does not create symlinks, install package dependencies, install OpenCode itself, or configure provider credentials.
|
||||
|
||||
Restart `opencode2` after installation (or `opencode2 service restart` for an existing service). Connect your provider, then select `coordinate` in the agent picker. The default configuration uses OpenAI GPT models; agents are only enabled when the required models are available.
|
||||
|
||||
## Configure
|
||||
|
||||
See [GPT configuration](examples/gpt.opencode.jsonc) and [custom model assignments](examples/custom.opencode.jsonc). When using the installer, point the example's plugin package entry at the installed package directory rather than the unpublished npm package name:
|
||||
See [GPT configuration](examples/gpt.opencode.jsonc) and [custom model assignments](examples/custom.opencode.jsonc). When using the installer, point the example's plugin package entry at the installed package directory rather than the unpublished npm package name. Configured local plugin paths must be directories; pointing at `plugins/stanp-opencode.js` causes OpenCode to reject that configured entry and auto-load the file without its options:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"plugins": [
|
||||
{
|
||||
"package": "./plugins/stanp-opencode.js",
|
||||
"package": "./plugins/stanp-opencode",
|
||||
"options": {
|
||||
"mode": "gpt",
|
||||
"provider": "openai",
|
||||
@@ -49,6 +49,8 @@ This relative path assumes the configuration file is in the selected global conf
|
||||
|
||||
The plugin supplies session-local `todoread` and `todowrite` tools when no other plugin provides them. In this pinned V2 beta, custom tools cannot open permission prompts: an `ask` or `deny` rule blocks these todo tools rather than bypassing the rule. Explicitly allow them if you want to use them.
|
||||
|
||||
When an MCP server named `codegraph` is already configured, its tools are exposed directly to `coordinate` and retain the built-in `build` agent's matching allow, ask, and deny rules. The plugin does not configure or start a missing server, and no other MCP server is exposed this way.
|
||||
|
||||
## Uninstall
|
||||
|
||||
Remove `plugins/stanp-opencode.js` from the selected config directory and remove any explicit configuration entry for it, then restart OpenCode.
|
||||
Remove `plugins/stanp-opencode` from the selected config directory and remove any explicit configuration entry for it, then restart OpenCode.
|
||||
|
||||
+33
-5
@@ -45,11 +45,39 @@ bun -e '
|
||||
' "$install/package/dist/index.js"
|
||||
|
||||
tag=$(jq -r '.tag_name' "$install/release.json")
|
||||
if ! mv -f "$install/package/dist/index.js" "$config/plugins/stanp-opencode.js"; then
|
||||
config_file="$config/opencode.json"
|
||||
config_source="$config_file"
|
||||
if [[ ! -e "$config_source" ]]; then
|
||||
config_source="$install/empty-config.json"
|
||||
printf '{}\n' > "$config_source"
|
||||
fi
|
||||
jq -e '
|
||||
if type != "object" then error("opencode.json must contain an object") else . end
|
||||
| if (.plugins // []) | type != "array" then error("opencode.json plugins must be an array") else . end
|
||||
| .["$schema"] = (.["$schema"] // "https://opencode.ai/config.json")
|
||||
| .plugins = (.plugins // [])
|
||||
| if any(.plugins[]?; .package == "./plugins/stanp-opencode") then . else
|
||||
.plugins += [{
|
||||
package: "./plugins/stanp-opencode",
|
||||
options: { mode: "gpt", provider: "openai", lunaPriority: true }
|
||||
}]
|
||||
end
|
||||
' "$config_source" > "$install/opencode.json"
|
||||
target="$config/plugins/stanp-opencode"
|
||||
staged="$config/plugins/.stanp-opencode.new.$$"
|
||||
backup="$config/plugins/.stanp-opencode.old.$$"
|
||||
mkdir "$staged"
|
||||
mv "$install/package/dist/index.js" "$staged/index.js"
|
||||
cat > "$staged/package.json" <<'EOF'
|
||||
{"name":"stanp-opencode","type":"module","main":"./index.js","exports":"./index.js"}
|
||||
EOF
|
||||
if [[ -e "$target" || -L "$target" ]]; then mv "$target" "$backup"; fi
|
||||
if ! mv "$staged" "$target"; then
|
||||
if [[ -e "$backup" || -L "$backup" ]]; then mv "$backup" "$target"; fi
|
||||
printf 'Could not activate plugin; installation unchanged\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -L "$config/plugins/stanp-opencode" ]]; then
|
||||
rm -f "$config/plugins/stanp-opencode"
|
||||
fi
|
||||
printf 'Installed stanp-opencode %s into %s/plugins/stanp-opencode.js\nRestart opencode2 to load it.\n' "$tag" "$config"
|
||||
rm -rf "$backup"
|
||||
rm -f "$config/plugins/stanp-opencode.js"
|
||||
mv "$install/opencode.json" "$config_file"
|
||||
printf 'Installed stanp-opencode %s into %s/plugins/stanp-opencode and updated %s\nRestart opencode2 to load it.\n' "$tag" "$config" "$config_file"
|
||||
|
||||
@@ -46,6 +46,9 @@ export default Plugin.define({
|
||||
Agent.ID.make("build"),
|
||||
).permissions;
|
||||
let permissionKey = JSON.stringify(inherited);
|
||||
let hasCodegraph = false;
|
||||
let codegraphTools: string[] = [];
|
||||
let agentTransformReady = false;
|
||||
const controller = new AbortController();
|
||||
// Subscribe before the first read: configuration can publish its catalog while
|
||||
// asynchronous setup is running.
|
||||
@@ -80,6 +83,31 @@ export default Plugin.define({
|
||||
let workers: (typeof workerNames)[number][] = [];
|
||||
const active = new Set<string>();
|
||||
|
||||
await context.mcp.transform((editor) => {
|
||||
hasCodegraph = editor.get("codegraph") !== undefined;
|
||||
if (hasCodegraph)
|
||||
editor.update("codegraph", (server) => {
|
||||
server.codemode = false;
|
||||
});
|
||||
});
|
||||
await context.tool.transform((editor) => {
|
||||
const next = editor
|
||||
.list()
|
||||
.map((tool) => tool.id)
|
||||
.filter((id) => id.startsWith("codegraph_"))
|
||||
.sort();
|
||||
if (JSON.stringify(next) === JSON.stringify(codegraphTools)) return;
|
||||
codegraphTools = next;
|
||||
if (agentTransformReady)
|
||||
queueMicrotask(() => {
|
||||
void context.agent
|
||||
.reload()
|
||||
.catch((error) =>
|
||||
console.error("stanp-opencode agent reload failed", error),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
await context.agent.transform((editor) => {
|
||||
const deep = selected.mode === "custom" || models.has("gpt-6-astra");
|
||||
const candidates = workerNames.filter((name) => deep || !name.startsWith("deep-"));
|
||||
@@ -146,6 +174,13 @@ export default Plugin.define({
|
||||
.filter((rule) => matches(action, rule.action))
|
||||
.map((rule) => ({ ...rule, action })),
|
||||
),
|
||||
...(hasCodegraph
|
||||
? codegraphTools.flatMap((action) =>
|
||||
inherited
|
||||
.filter((rule) => matches(action, rule.action))
|
||||
.map((rule) => ({ ...rule, action })),
|
||||
)
|
||||
: []),
|
||||
...workers.map((resource) => ({
|
||||
action: "subagent",
|
||||
resource,
|
||||
@@ -154,6 +189,7 @@ export default Plugin.define({
|
||||
];
|
||||
});
|
||||
});
|
||||
agentTransformReady = true;
|
||||
async function assertTodo(agentID: Agent.ID, action: string) {
|
||||
const { data: agent } = await context.agent.get({ agentID });
|
||||
const effect =
|
||||
|
||||
+60
-4
@@ -18,6 +18,7 @@ import { fileURLToPath } from "node:url";
|
||||
const installer = fileURLToPath(new URL("../install.sh", import.meta.url));
|
||||
const directories: string[] = [];
|
||||
const configContents = '{\n // Keep user settings and comments.\n "theme": "dark"\n}\n';
|
||||
const jsonConfig = { permissions: { shell: "ask" }, plugins: [{ package: "existing-plugin" }] };
|
||||
const previousPlugin = 'export default { id: "previous", setup() { return "preserved"; } };\n';
|
||||
const previousTarget = "../stanp-opencode/install.previous";
|
||||
|
||||
@@ -43,7 +44,7 @@ async function run(command: string[], cwd: string, env: NodeJS.ProcessEnv) {
|
||||
return { exitCode, stdout, stderr };
|
||||
}
|
||||
|
||||
async function install(mode: "native" | "checksum" | "legacy") {
|
||||
async function install(mode: "native" | "checksum" | "legacy", configured = false) {
|
||||
const directory = await mkdtemp(join(tmpdir(), "stanp-install-"));
|
||||
directories.push(directory);
|
||||
const config =
|
||||
@@ -60,6 +61,27 @@ async function install(mode: "native" | "checksum" | "legacy") {
|
||||
]);
|
||||
await Promise.all([
|
||||
writeFile(join(config, "opencode.jsonc"), configContents),
|
||||
writeFile(
|
||||
join(config, "opencode.json"),
|
||||
JSON.stringify(
|
||||
configured
|
||||
? {
|
||||
...jsonConfig,
|
||||
plugins: [
|
||||
...jsonConfig.plugins,
|
||||
{
|
||||
package: "./plugins/stanp-opencode",
|
||||
options: {
|
||||
mode: "gpt",
|
||||
provider: "existing-provider",
|
||||
lunaPriority: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: jsonConfig,
|
||||
),
|
||||
),
|
||||
symlink(previousTarget, join(config, "plugins", "stanp-opencode")),
|
||||
writeFile(join(config, "stanp-opencode", "install.previous", "index.js"), previousPlugin),
|
||||
writeFile(
|
||||
@@ -165,7 +187,7 @@ exec sha256sum "$@"
|
||||
return { directory, config, env, result };
|
||||
}
|
||||
|
||||
test("installs an importable native plugin under XDG without changing user configuration or running scripts", async () => {
|
||||
test("installs an importable native plugin and default config under XDG without running scripts", async () => {
|
||||
const { directory, config, env, result } = await install("native");
|
||||
expect(result, result.stderr).toMatchObject({ exitCode: 0 });
|
||||
// The generated package path is only known at runtime; exercise its module-loading boundary.
|
||||
@@ -174,7 +196,7 @@ test("installs an importable native plugin under XDG without changing user confi
|
||||
process.execPath,
|
||||
"-e",
|
||||
"const { default: plugin } = await import(process.argv[1]); console.log(JSON.stringify([plugin.id, plugin.setup()]));",
|
||||
join(config, "plugins", "stanp-opencode.js"),
|
||||
join(config, "plugins", "stanp-opencode"),
|
||||
],
|
||||
directory,
|
||||
env,
|
||||
@@ -184,19 +206,53 @@ test("installs an importable native plugin under XDG without changing user confi
|
||||
stdout: '["stanp-opencode","native-ready"]\n',
|
||||
});
|
||||
expect(await readFile(join(config, "opencode.jsonc"), "utf8")).toBe(configContents);
|
||||
expect(await Bun.file(join(config, "plugins", "stanp-opencode")).exists()).toBe(false);
|
||||
expect(JSON.parse(await readFile(join(config, "opencode.json"), "utf8"))).toEqual({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
plugins: [
|
||||
{ package: "existing-plugin" },
|
||||
{
|
||||
package: "./plugins/stanp-opencode",
|
||||
options: { mode: "gpt", provider: "openai", lunaPriority: true },
|
||||
},
|
||||
],
|
||||
permissions: { shell: "ask" },
|
||||
});
|
||||
expect(await Bun.file(join(config, "plugins", "stanp-opencode", "package.json")).exists()).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
await readFile(join(config, "stanp-opencode", "install.previous", "index.js"), "utf8"),
|
||||
).toBe(previousPlugin);
|
||||
expect(await Bun.file(env.POSTINSTALL_MARKER).exists()).toBe(false);
|
||||
}, 60_000);
|
||||
|
||||
test("preserves an existing stanp-opencode configuration", async () => {
|
||||
const { config, result } = await install("native", true);
|
||||
expect(result, result.stderr).toMatchObject({ exitCode: 0 });
|
||||
const installed = JSON.parse(await readFile(join(config, "opencode.json"), "utf8"));
|
||||
expect(installed.plugins).toEqual([
|
||||
{ package: "existing-plugin" },
|
||||
{
|
||||
package: "./plugins/stanp-opencode",
|
||||
options: {
|
||||
mode: "gpt",
|
||||
provider: "existing-provider",
|
||||
lunaPriority: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(installed.permissions).toEqual(jsonConfig.permissions);
|
||||
}, 60_000);
|
||||
|
||||
for (const mode of ["checksum", "legacy"] as const) {
|
||||
test(`${mode} failure preserves the installed plugin in a custom path containing spaces`, async () => {
|
||||
const { directory, config, env, result } = await install(mode);
|
||||
expect(result.exitCode, result.stderr).not.toBe(0);
|
||||
expect(await readlink(join(config, "plugins", "stanp-opencode"))).toBe(previousTarget);
|
||||
expect(await readFile(join(config, "opencode.jsonc"), "utf8")).toBe(configContents);
|
||||
expect(JSON.parse(await readFile(join(config, "opencode.json"), "utf8"))).toEqual(
|
||||
jsonConfig,
|
||||
);
|
||||
expect(
|
||||
await readFile(join(config, "stanp-opencode", "install.previous", "index.js"), "utf8"),
|
||||
).toBe(previousPlugin);
|
||||
|
||||
+140
-18
@@ -5,6 +5,7 @@ import type { DeepMutable } from "@opencode/plugin/promise/types";
|
||||
import type { Info, ToolContext, ToolEditor } from "@opencode/plugin/promise/tool";
|
||||
import type { JsonValue, ModelInfo, SessionGetInput } from "@opencode/client";
|
||||
import type { SessionContext } from "@opencode/plugin/promise/session";
|
||||
import { z } from "zod";
|
||||
import { gptAgents } from "../src/config.js";
|
||||
import plugin from "../src/index.js";
|
||||
|
||||
@@ -26,6 +27,11 @@ type FixtureSetup = (
|
||||
async function host(
|
||||
models: ModelInfo[] = allModels,
|
||||
options: Plugin.Context["options"] = { provider: "test" },
|
||||
configuredMcp: Record<
|
||||
string,
|
||||
{ type: "local"; command: string[]; codemode?: boolean; disabled?: boolean }
|
||||
> = {},
|
||||
configuredMcpTools: string[] = [],
|
||||
) {
|
||||
const agents = new Map<string, DeepMutable<Agent.Info>>();
|
||||
const build: DeepMutable<Agent.Info> = Agent.Info.default(Agent.ID.make("build"));
|
||||
@@ -48,6 +54,50 @@ async function host(
|
||||
});
|
||||
const storage = new Map<string, JsonValue>();
|
||||
const tools = new Map<string, Info>();
|
||||
const mcpTools = new Set(configuredMcpTools);
|
||||
const mcpServers = new Map(Object.entries(structuredClone(configuredMcp)));
|
||||
let mcpTransform!: (editor: {
|
||||
get(name: string): (typeof configuredMcp)[string] | undefined;
|
||||
update(name: string, update: (server: (typeof configuredMcp)[string]) => void): void;
|
||||
}) => void;
|
||||
const toolTransforms: ((editor: ToolEditor) => void)[] = [];
|
||||
const editMcp = () => ({
|
||||
get: (name: string) => mcpServers.get(name),
|
||||
update(name: string, update: (server: (typeof configuredMcp)[string]) => void) {
|
||||
const server = mcpServers.get(name);
|
||||
if (server) update(server);
|
||||
},
|
||||
});
|
||||
const editTools = (): ToolEditor => ({
|
||||
get(id) {
|
||||
const tool = tools.get(id);
|
||||
return tool ? { ...tool, id } : undefined;
|
||||
},
|
||||
list: () => [
|
||||
...[...tools].map(([id, tool]) => ({ ...tool, id })),
|
||||
...[...mcpTools].map((id) => ({
|
||||
id,
|
||||
name: id,
|
||||
description: id,
|
||||
input: z.object({}),
|
||||
options: { codemode: false },
|
||||
async execute() {
|
||||
return { content: "" };
|
||||
},
|
||||
})),
|
||||
],
|
||||
namespace() {},
|
||||
add(tool) {
|
||||
tools.set(tool.name, tool);
|
||||
},
|
||||
update(id, update) {
|
||||
const tool = tools.get(id);
|
||||
if (tool) update(tool);
|
||||
},
|
||||
remove(id) {
|
||||
tools.delete(id);
|
||||
},
|
||||
});
|
||||
// SAFETY: This test host implements the native APIs used by setup; unrelated context
|
||||
// services are deliberately absent so unexpected new dependencies fail the test.
|
||||
const context = {
|
||||
@@ -73,6 +123,13 @@ async function host(
|
||||
storage.set(key, value);
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
async transform(callback: typeof mcpTransform) {
|
||||
mcpTransform = callback;
|
||||
callback(editMcp());
|
||||
return registration;
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
async list() {
|
||||
return { data: [...agents.values()] };
|
||||
@@ -119,24 +176,8 @@ async function host(
|
||||
},
|
||||
tool: {
|
||||
async transform(callback: (editor: ToolEditor) => void) {
|
||||
callback({
|
||||
get(id) {
|
||||
const tool = tools.get(id);
|
||||
return tool ? { ...tool, id } : undefined;
|
||||
},
|
||||
list: () => [...tools].map(([id, tool]) => ({ ...tool, id })),
|
||||
namespace() {},
|
||||
add(tool) {
|
||||
tools.set(tool.name, tool);
|
||||
},
|
||||
update(id, update) {
|
||||
const tool = tools.get(id);
|
||||
if (tool) update(tool);
|
||||
},
|
||||
remove(id) {
|
||||
tools.delete(id);
|
||||
},
|
||||
});
|
||||
toolTransforms.push(callback);
|
||||
callback(editTools());
|
||||
return registration;
|
||||
},
|
||||
async hook(_name: string, callback: typeof before) {
|
||||
@@ -153,6 +194,7 @@ async function host(
|
||||
return {
|
||||
agents,
|
||||
build,
|
||||
mcpServers,
|
||||
defaultAgent,
|
||||
originalBuild,
|
||||
async refresh(next: ModelInfo[]) {
|
||||
@@ -167,6 +209,21 @@ async function host(
|
||||
events.enqueue({ type: "agent.updated" });
|
||||
await reloaded.promise;
|
||||
},
|
||||
async changeCodegraph(
|
||||
server:
|
||||
| { type: "local"; command: string[]; codemode?: boolean; disabled?: boolean }
|
||||
| undefined,
|
||||
toolNames: string[],
|
||||
) {
|
||||
if (server) mcpServers.set("codegraph", server);
|
||||
else mcpServers.delete("codegraph");
|
||||
mcpTools.clear();
|
||||
for (const name of toolNames) mcpTools.add(name);
|
||||
reloaded = Promise.withResolvers<void>();
|
||||
mcpTransform(editMcp());
|
||||
for (const callback of toolTransforms) callback(editTools());
|
||||
await reloaded.promise;
|
||||
},
|
||||
async todo(name: string, sessionID: string, input: JsonValue = {}) {
|
||||
// SAFETY: Todo executors use only sessionID and agent from the native context.
|
||||
return tools
|
||||
@@ -257,6 +314,71 @@ test("worker inheritance and coordinator isolation preserve build and default",
|
||||
});
|
||||
});
|
||||
|
||||
test("coordinator inherits only codegraph MCP permissions when that server exists", async () => {
|
||||
const absent = await host();
|
||||
expect(
|
||||
absent.agents
|
||||
.get("coordinate")
|
||||
?.permissions.some((rule) => rule.action.startsWith("codegraph_")),
|
||||
).toBe(false);
|
||||
expect(absent.mcpServers.has("codegraph")).toBe(false);
|
||||
|
||||
const result = await host(
|
||||
allModels,
|
||||
{ provider: "test" },
|
||||
{
|
||||
codegraph: { type: "local", command: ["codegraph"], codemode: true },
|
||||
docs: { type: "local", command: ["docs"], codemode: true },
|
||||
},
|
||||
["codegraph_codegraph_search", "codegraph_future_query", "docs_search"],
|
||||
);
|
||||
expect(result.mcpServers.get("codegraph")?.codemode).toBe(false);
|
||||
expect(result.mcpServers.get("docs")?.codemode).toBe(true);
|
||||
let coordinate = result.agents.get("coordinate")!;
|
||||
expect(coordinate.permissions).toContainEqual({
|
||||
action: "codegraph_codegraph_search",
|
||||
resource: "*",
|
||||
effect: "allow",
|
||||
});
|
||||
expect(coordinate.permissions.some((rule) => rule.action.startsWith("docs_"))).toBe(false);
|
||||
expect(coordinate.permissions.some((rule) => rule.action === "shell")).toBe(false);
|
||||
expect(coordinate.permissions.some((rule) => rule.action === "execute")).toBe(false);
|
||||
|
||||
await result.changeBuildPermissions([
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
{ action: "code*", resource: "*", effect: "ask" },
|
||||
{ action: "*search", resource: "*", effect: "deny" },
|
||||
{ action: "docs_*", resource: "*", effect: "allow" },
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
]);
|
||||
coordinate = result.agents.get("coordinate")!;
|
||||
expect(coordinate.permissions).toContainEqual({
|
||||
action: "codegraph_future_query",
|
||||
resource: "*",
|
||||
effect: "ask",
|
||||
});
|
||||
expect(coordinate.permissions).toContainEqual({
|
||||
action: "codegraph_codegraph_search",
|
||||
resource: "*",
|
||||
effect: "deny",
|
||||
});
|
||||
expect(coordinate.permissions.some((rule) => rule.action === "docs_*")).toBe(false);
|
||||
expect(coordinate.permissions.some((rule) => rule.action === "shell")).toBe(false);
|
||||
|
||||
await result.changeCodegraph(undefined, []);
|
||||
coordinate = result.agents.get("coordinate")!;
|
||||
expect(coordinate.permissions.some((rule) => rule.action.startsWith("codegraph_"))).toBe(false);
|
||||
await result.changeCodegraph({ type: "local", command: ["codegraph"], codemode: true }, [
|
||||
"codegraph_added_later",
|
||||
]);
|
||||
expect(result.mcpServers.get("codegraph")?.codemode).toBe(false);
|
||||
expect(result.agents.get("coordinate")?.permissions).toContainEqual({
|
||||
action: "codegraph_added_later",
|
||||
resource: "*",
|
||||
effect: "ask",
|
||||
});
|
||||
});
|
||||
|
||||
test("manual calls and command-created child sessions cannot bypass delegation restrictions", async () => {
|
||||
const result = await host(allModels.slice(0, 2));
|
||||
for (const invoke of [result.delegate, result.command]) {
|
||||
|
||||
Reference in New Issue
Block a user