Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,35 @@ replaces a file alias with the same key, and inline `strict`/`debug` values win.
inline options apply when OpenCode reloads its configuration. The `.jsonc` file is read when
the plugin starts, so restart OpenCode after editing it.

## Editor support

`schema.json` ships with the package: a draft-07 JSON Schema for the plugin options. Point
the config file at it and editors autocomplete and validate the shape (JSONC comments and
trailing commas are fine):

```jsonc
// .opencode/opencode-model-aliases.jsonc — complete example
{
"$schema": "https://raw.githubusercontent.com/vmvarela/opencode-model-aliases/v0.4.0/schema.json",
"strict": true,
"debug": false,
"aliases": {
"openai/gpt-sol": { "match": "openai/gpt-*-sol" },
"openai/latest": {
"match": ["openai/gpt-*", "openai/o*"],
"exclude": ["openai/*-preview"],
"filter": { "status": ["active", "beta"], "minContext": 128000 }
}
}
}
```

`$schema` is file-only metadata: editors may require it, the plugin validates it as a
non-empty string and strips it before merging. Inline plugin options never accept it.
The schema checks shape only — glob compilation, the literal-provider rule and provider
equality between patterns and the alias key are still enforced by the plugin at runtime,
which remains authoritative.

## When things fail

- **Invalid configuration fails at startup**, including unknown keys and invalid globs.
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"index.js",
"tui.js",
"dist",
"schema.json",
"README.md",
"LICENSE"
],
Expand All @@ -48,6 +49,7 @@
"@opencode/plugin": "2.0.21",
"@types/node": "^26.6.3",
"@types/picomatch": "4.0.3",
"ajv": "8.20.0",
"conventional-changelog-conventionalcommits": "9.3.1",
"semantic-release": "25.0.9",
"typescript": "7.0.2",
Expand Down
34 changes: 34 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

90 changes: 90 additions & 0 deletions schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://raw.githubusercontent.com/vmvarela/opencode-model-aliases/v0.4.0/schema.json",
"title": "opencode-model-aliases plugin options",
"description": "Schema for the plugin configuration file `.opencode/opencode-model-aliases.jsonc` and for the inline plugin `options` in `opencode.json`; file and inline options are merged before validation, so `aliases` may be omitted in the file when it is provided inline. The schema describes the JSON shape only: glob compilation, provider equality between match patterns and the alias key, and model-catalog collisions are enforced by the plugin at runtime. `$schema` is file-only metadata: it is accepted here and stripped before merging; inline options never accept it.",
"type": "object",
"additionalProperties": false,
"properties": {
"$schema": { "type": "string", "minLength": 1, "description": "File-only metadata; removed before merging and never accepted in inline options." },
"aliases": {
"type": "object",
"propertyNames": { "$ref": "#/definitions/selector" },
"additionalProperties": { "$ref": "#/definitions/alias" }
},
"strict": { "type": "boolean" },
"debug": { "type": "boolean" }
},
"definitions": {
"selector": {
"type": "string",
"minLength": 1,
"pattern": "^[^#/*?\\[\\]{}()!+@|\\\\]+/[^#]+$"
},
"alias": {
"type": "object",
"additionalProperties": false,
"required": ["match"],
"properties": {
"match": {
"oneOf": [
{ "$ref": "#/definitions/selector" },
{
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/selector" }
}
]
},
"exclude": {
"oneOf": [
{ "$ref": "#/definitions/selector" },
{
"type": "array",
"items": { "$ref": "#/definitions/selector" }
}
]
},
"filter": { "$ref": "#/definitions/filter" },
"select": { "$ref": "#/definitions/select" },
"name": { "type": "string", "minLength": 1 }
}
},
"filter": {
"type": "object",
"additionalProperties": false,
"properties": {
"status": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "enum": ["active", "alpha", "beta"] }
},
"capabilities": { "$ref": "#/definitions/capabilities" },
"minContext": { "type": "integer", "minimum": 1 }
}
},
"capabilities": {
"type": "object",
"additionalProperties": false,
"properties": {
"tools": { "type": "boolean" },
"input": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "minLength": 1 }
},
"output": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "minLength": 1 }
}
}
},
"select": {
"type": "object",
"additionalProperties": false,
"required": ["strategy"],
"properties": { "strategy": { "type": "string", "enum": ["latest"] } }
}
}
}
11 changes: 10 additions & 1 deletion scripts/check-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,15 @@ assert.ok(Array.isArray(packOutput) && packOutput.length === 1, "pack inesperado
assert.equal(packOutput[0].name, "opencode-model-aliases");
const packedFiles = packOutput[0].files.map((entry) => entry.path);
// package.json always goes into the npm tarball in addition to the files list.
const allowedRoots = ["index.js", "tui.js", "dist", "README.md", "LICENSE", "package.json"];
const allowedRoots = [
"index.js",
"tui.js",
"dist",
"schema.json",
"README.md",
"LICENSE",
"package.json",
];
const leaked = packedFiles.filter((file) => {
return !allowedRoots.some((root) => file === root || file.startsWith(`${root}/`));
});
Expand All @@ -290,6 +298,7 @@ for (const required of [
"tui.js",
"dist/index.js",
"dist/tui.js",
"schema.json",
"README.md",
"LICENSE",
]) {
Expand Down
39 changes: 29 additions & 10 deletions src/config-file.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
import { getNodeValue, type ParseError, parseTree, printParseErrorCode } from "jsonc-parser";
import { isPlainObject } from "./config.js";

/** Directory + file name of the plugin configuration file. */
Expand All @@ -9,7 +9,9 @@ const FILE_NAME = "opencode-model-aliases.jsonc";

/** Raw options read from the file; values are not validated yet.
* Unrecognized root keys are preserved as-is: normalizeOptions
* must reject them as parse-error (no silent whitelisting). */
* must reject them as parse-error (no silent whitelisting).
* The single exception is `$schema`: file-only metadata, validated and
* stripped before it can reach the merge (see loadConfigFile). */
export interface RawFileOptions {
readonly aliases?: Record<string, unknown>;
readonly strict?: unknown;
Expand Down Expand Up @@ -57,9 +59,12 @@ export async function loadConfigFile(startDirectory: string): Promise<LoadConfig
}

// The parser is failure-tolerant: without inspecting `errors` it would
// accept partial data as if it were valid.
// accept partial data as if it were valid. parseTree + getNodeValue
// build null-prototype objects, so a hostile "__proto__" root key stays
// an own enumerable key and normalizeOptions rejects it; `parse` would
// silently turn it into a prototype mutation and hide the key.
const errors: ParseError[] = [];
const data = parse(text, errors, { allowTrailingComma: true });
const tree = parseTree(text, errors, { allowTrailingComma: true });
if (errors.length > 0) {
const first = errors[0];
if (!first) return { ok: false, reason: `config file "${candidate}" has malformed JSONC` };
Expand All @@ -68,6 +73,7 @@ export async function loadConfigFile(startDirectory: string): Promise<LoadConfig
reason: `config file "${candidate}" has malformed JSONC: ${printParseErrorCode(first.error)} at offset ${first.offset}`,
};
}
const data = tree === undefined ? undefined : getNodeValue(tree);
if (!isPlainObject(data)) {
return {
ok: false,
Expand All @@ -81,12 +87,25 @@ export async function loadConfigFile(startDirectory: string): Promise<LoadConfig
};
}

// All own keys of the file are preserved, including unknown ones:
// whitelisting would hide root typos before validation. The spread
// copies as an own data property, safe against hostile "__proto__"
// keys. The aliases container was already validated above, hence the
// cast.
const options = { ...data } as RawFileOptions;
// `$schema` is the only root key with file-level semantics: it names
// the JSON Schema for editor support and is not part of the plugin
// options. Accepted here as a non-empty string, never forwarded to
// the merge: inline options have no `$schema` channel, so
// normalizeOptions keeps rejecting it as an unknown root key.
const { $schema: metadata, ...rest } = data;
if (metadata !== undefined && (typeof metadata !== "string" || metadata.length === 0)) {
return {
ok: false,
reason: `config file "${candidate}": $schema must be a non-empty string`,
};
}

// All other own keys of the file are preserved, including unknown
// ones: whitelisting would hide root typos before validation. The
// rest spread copies each key as an own data property, safe against
// hostile "__proto__" keys. The aliases container was already
// validated above, hence the cast.
const options = rest as RawFileOptions;
return { ok: true, file: { path: candidate, options } };
}
}
Loading
Loading