diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json
new file mode 100644
index 00000000..b6f196a7
--- /dev/null
+++ b/.devcontainer/devcontainer-lock.json
@@ -0,0 +1,9 @@
+{
+ "features": {
+ "ghcr.io/devcontainers/features/common-utils:2": {
+ "version": "2.5.9",
+ "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a",
+ "integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a"
+ }
+ }
+}
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 00000000..7fa8e0d4
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,8 @@
+{
+ "name": "Yasa",
+ "image": "oven/bun:1.3",
+ "mounts": [
+ "source=opencode-node-modules,target=${containerWorkspaceFolder}/node_modules,type=volume"
+ ],
+ "postCreateCommand": "bun install"
+}
\ No newline at end of file
diff --git a/bun.lock b/bun.lock
index 1e3a6425..7c08efb3 100644
--- a/bun.lock
+++ b/bun.lock
@@ -10,6 +10,7 @@
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"heap-snapshot-toolkit": "1.1.3",
+ "openai": "^6.16.0",
"typescript": "catalog:",
},
"devDependencies": {
@@ -4614,7 +4615,7 @@
"open": ["open@10.1.2", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="],
- "openai": ["openai@5.11.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-+AuTc5pVjlnTuA9zvn8rA/k+1RluPIx9AD4eDcnutv6JNwHHZxIhkFy+tmMKCvmMFDQzfA/r1ujvPWB19DQkYg=="],
+ "openai": ["openai@6.39.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-z3dO9fEWOXBzlXynVb/xZ/tujzUjFWQWn3C0n0mw6Vo0zJTbEkaN4b2cLWjhJ6haJQx8LlREoafHRl+Gu/Hl+A=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
@@ -6030,6 +6031,8 @@
"@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="],
+ "@opencode-ai/console-function/openai": ["openai@5.11.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-+AuTc5pVjlnTuA9zvn8rA/k+1RluPIx9AD4eDcnutv6JNwHHZxIhkFy+tmMKCvmMFDQzfA/r1ujvPWB19DQkYg=="],
+
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
@@ -6334,8 +6337,6 @@
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
- "gitlab-ai-provider/openai": ["openai@6.39.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-z3dO9fEWOXBzlXynVb/xZ/tujzUjFWQWn3C0n0mw6Vo0zJTbEkaN4b2cLWjhJ6haJQx8LlREoafHRl+Gu/Hl+A=="],
-
"gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
diff --git a/package.json b/package.json
index 58712547..dc29a90c 100644
--- a/package.json
+++ b/package.json
@@ -115,6 +115,7 @@
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"heap-snapshot-toolkit": "1.1.3",
+ "openai": "^6.16.0",
"typescript": "catalog:"
},
"repository": {
diff --git a/packages/core/package.json b/packages/core/package.json
index 0aa257a8..e2942e0f 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -85,22 +85,22 @@
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
- "@lydell/node-pty": "catalog:",
"@ff-labs/fff-bun": "0.9.4",
+ "@lydell/node-pty": "catalog:",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*",
- "@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
+ "@opencode-ai/schema": "workspace:*",
+ "@openrouter/ai-sdk-provider": "2.9.0",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
- "@openrouter/ai-sdk-provider": "2.9.0",
"ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8",
"cross-spawn": "catalog:",
@@ -113,8 +113,8 @@
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
"htmlparser2": "8.0.2",
- "immer": "11.1.4",
"ignore": "7.0.5",
+ "immer": "11.1.4",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.2.5",
diff --git a/packages/opencode/src/util/repository.ts b/packages/opencode/src/util/repository.ts
index dfeee432..8405b86a 100644
--- a/packages/opencode/src/util/repository.ts
+++ b/packages/opencode/src/util/repository.ts
@@ -136,29 +136,38 @@ function buildFileReference(input: { url: URL; remote: string }) {
} satisfies FileReference
}
-export function parseRepositoryReference(input: string) {
- const cleaned = normalizeRepositoryInput(input)
- if (!cleaned) return null
+function tryGithubPrefixedReference(cleaned: string): Reference | null | undefined {
const githubPrefixed = cleaned.match(/^github:([^/\s]+)\/([^/\s]+)$/)
- if (githubPrefixed) {
- return buildRemoteReference({ host: "github.com", segments: [githubPrefixed[1], githubPrefixed[2]] })
- }
-
- if (!cleaned.includes("://")) {
- const scp = cleaned.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/)
- if (scp) return buildRemoteReference({ host: scp[1], segments: parts(scp[2]), remote: cleaned })
+ if (!githubPrefixed) return undefined
+ return buildRemoteReference({ host: "github.com", segments: [githubPrefixed[1], githubPrefixed[2]] })
+}
- const direct = parts(cleaned)
- if (direct.length >= 2 && hostLike(direct[0])) {
- return buildRemoteReference({ host: direct[0], segments: direct.slice(1) })
- }
+function tryScpReference(cleaned: string): Reference | null | undefined {
+ const scp = cleaned.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/)
+ if (!scp) return undefined
+ return buildRemoteReference({ host: scp[1], segments: parts(scp[2]), remote: cleaned })
+}
- if (direct.length === 2) {
- return buildRemoteReference({ host: "github.com", segments: direct })
- }
+function tryDirectReference(cleaned: string): Reference | null | undefined {
+ const direct = parts(cleaned)
+ if (direct.length >= 2 && hostLike(direct[0])) {
+ return buildRemoteReference({ host: direct[0], segments: direct.slice(1) })
}
+ if (direct.length === 2) {
+ return buildRemoteReference({ host: "github.com", segments: direct })
+ }
+ return undefined
+}
+function tryShorthandReference(cleaned: string): Reference | null | undefined {
+ if (cleaned.includes("://")) return undefined
+ const scpResult = tryScpReference(cleaned)
+ if (scpResult !== undefined) return scpResult
+ return tryDirectReference(cleaned)
+}
+
+function tryUrlReference(cleaned: string): Reference | null {
try {
const url = new URL(cleaned)
if (url.protocol === "file:") return buildFileReference({ url, remote: cleaned })
@@ -174,7 +183,18 @@ export function parseRepositoryReference(input: string) {
return null
}
}
+export function parseRepositoryReference(input: string): Reference | null {
+ const cleaned = normalizeRepositoryInput(input)
+ if (!cleaned) return null
+ const githubPrefixed = tryGithubPrefixedReference(cleaned)
+ if (githubPrefixed !== undefined) return githubPrefixed
+
+ const shorthand = tryShorthandReference(cleaned)
+ if (shorthand !== undefined) return shorthand
+
+ return tryUrlReference(cleaned)
+}
export function isFileRepositoryReference(reference: Reference): reference is FileReference {
return reference.protocol === "file:"
}
diff --git a/packages/opencode/test/util/repository.test.ts b/packages/opencode/test/util/repository.test.ts
index 5c619f2a..a5d8bc33 100644
--- a/packages/opencode/test/util/repository.test.ts
+++ b/packages/opencode/test/util/repository.test.ts
@@ -8,6 +8,7 @@ import {
UnsupportedLocalRepositoryError,
isFileRepositoryReference,
isRemoteRepositoryReference,
+ parseGitHubRemote,
parseRemoteRepositoryReference,
parseRepositoryReference,
repositoryCacheIdentity,
@@ -90,4 +91,37 @@ describe("util.repository", () => {
expect(() => validateRepositoryBranch("bad branch")).toThrow("Branch must contain only alphanumeric characters")
expect(() => validateRepositoryBranch("bad branch")).toThrow(InvalidRepositoryBranchError)
})
-})
+
+ test("parses github: prefixed shorthand", () => {
+ const reference = parseRemoteRepositoryReference("github:owner/repo")
+
+ expect(reference).toMatchObject({
+ host: "github.com",
+ owner: "owner",
+ repo: "repo",
+ label: "owner/repo",
+ })
+ })
+
+ test("parses github remote shorthand into owner and repo", () => {
+ expect(parseGitHubRemote("git@github.com:owner/repo.git")).toEqual({ owner: "owner", repo: "repo" })
+ expect(parseGitHubRemote("https://github.com/owner/repo.git")).toEqual({ owner: "owner", repo: "repo" })
+
+ expect(parseGitHubRemote("owner/repo")).toBeNull()
+ expect(parseGitHubRemote("https://gitlab.com/owner/repo.git")).toBeNull()
+ expect(parseGitHubRemote("not a valid remote")).toBeNull()
+ })
+
+ test("uses custom github base url override when set", () => {
+ const original = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
+ process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = "https://github.internal.example.com"
+
+ try {
+ const reference = parseRemoteRepositoryReference("owner/repo")
+ expect(reference.remote).toBe("https://github.internal.example.com/owner/repo.git")
+ } finally {
+ if (original === undefined) delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
+ else process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = original
+ }
+ })
+})
\ No newline at end of file
diff --git a/tmp/opencode-provider-dynamic-o82f8c/index.html b/tmp/opencode-provider-dynamic-o82f8c/index.html
new file mode 100644
index 00000000..57206a92
--- /dev/null
+++ b/tmp/opencode-provider-dynamic-o82f8c/index.html
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+ LCOV - lcov.info - ../../../../tmp/opencode-provider-dynamic-o82f8c
+
+
+
+
+
+
+ | LCOV - code coverage report |
+  |
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+  |
+
+ |
+
+
+  |
+
+
+
+
+
+
+
|
+ |
+ |
+ |
+ |
+
+
+
+ File  |
+ Line Coverage  |
+
+
+ | Rate |
+ Total |
+ Hit |
+
+
+ | provider.mjs |
+
+
+ |
+ 100.0 % |
+ 1 |
+ 1 |
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tmp/opencode-provider-dynamic-o82f8c/provider.mjs.gcov.html b/tmp/opencode-provider-dynamic-o82f8c/provider.mjs.gcov.html
new file mode 100644
index 00000000..755bbe05
--- /dev/null
+++ b/tmp/opencode-provider-dynamic-o82f8c/provider.mjs.gcov.html
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+ LCOV - lcov.info - ../../../../tmp/opencode-provider-dynamic-o82f8c/provider.mjs
+
+
+
+
+
+
+ | LCOV - code coverage report |
+  |
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+  |
+
+ |
+
+
+  |
+
+
+
+
+
|
+
+
+
+ Line data Source code
+
+ 1 39 : /* ../../../../tmp/opencode-provider-dynamic-o82f8c/provider.mjs empty or unreadable */
+
+ |
+
+
+
+
+
+
+
+
+