opencode-muse-code 0.1.0

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/index.ts +247 -0
  4. package/package.json +35 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shane Walker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # opencode-muse-code
2
+
3
+ Use your Muse Code (Meta) subscription in OpenCode.
4
+
5
+ The plugin reads the login the [`muse`](https://api.meta.ai/muse-launcher.sh) CLI
6
+ already stored on your machine, registers a `muse-code` provider that talks to
7
+ `https://api.meta.ai/v1`, and lists the Muse Spark models with their real limits
8
+ and reasoning-effort variants. Model requests are billed against your Muse Code
9
+ subscription by Meta.
10
+
11
+ ## Requirements
12
+
13
+ - OpenCode V2
14
+ - The `muse` CLI installed and signed in (`muse`). The plugin reads the login it
15
+ saved in the macOS keychain (or `~/.config/muse/auth.json` on other systems).
16
+
17
+ ## Install
18
+
19
+ Add the package to the `plugins` list in `opencode.jsonc`:
20
+
21
+ ```jsonc
22
+ {
23
+ "plugins": ["opencode-muse-code"]
24
+ }
25
+ ```
26
+
27
+ Or copy this folder to `~/.config/opencode/plugins/muse-code/`; plugin
28
+ directories there are loaded automatically.
29
+
30
+ Or clone the repository and point OpenCode at the checkout:
31
+
32
+ ```sh
33
+ git clone https://github.com/swalker326/opencode-muse-code.git
34
+ ```
35
+
36
+ ```jsonc
37
+ {
38
+ "plugins": ["/absolute/path/to/opencode-muse-code"]
39
+ }
40
+ ```
41
+
42
+ Restart the OpenCode service (`opencode service restart`) or reopen OpenCode.
43
+
44
+ ## Use
45
+
46
+ Select a model like `muse-code/muse-spark-1.3` with `/models`, or run:
47
+
48
+ ```sh
49
+ opencode run --model muse-code/muse-spark-1.3 "hello"
50
+ ```
51
+
52
+ Variant suffixes pick a reasoning effort, for example
53
+ `muse-code/muse-spark-1.3#max` (`minimal`, `low`, `medium`, `high`, `xhigh`,
54
+ `max`). The default comes from the model metadata.
55
+
56
+ ## How it works
57
+
58
+ 1. **Find the login.** Credentials are resolved in order: the plugin `key`
59
+ option, then `META_API_KEY` / `MUSE_API_KEY`, then the macOS keychain item
60
+ `ai.meta.dev.credentials` (account `meta`), then `~/.config/muse/auth.json`.
61
+ 2. **Get an API key.** It uses the stored API key, or mints one from the OAuth
62
+ token by calling `POST https://api.meta.ai/muse-code/key`. Minting is
63
+ idempotent: the same account always gets the same key back.
64
+ 3. **Discover models.** It reads Meta's catalog from
65
+ `GET https://api.meta.ai/muse-code/models`, keeping tool-capable text models
66
+ and their real limits, input types, and reasoning-effort variants.
67
+ 4. **Register the provider.** OpenCode receives a provider definition with ID
68
+ `muse-code`, display name "Muse Code Sub Plugin", the OpenAI-compatible
69
+ runtime, endpoint `https://api.meta.ai/v1`, the minted key, and one model
70
+ entry per Muse Spark model. Variants map to the `reasoning_effort` request
71
+ field.
72
+ 5. **Stay fresh.** Every 30 minutes it re-reads the login, re-fetches the
73
+ catalog, and reloads the provider. On unload it stops the timer and removes
74
+ its registration.
75
+
76
+ Requests go from OpenCode to `https://api.meta.ai/v1` with the minted key; Meta
77
+ meters them against your Muse Code subscription.
78
+
79
+ ## Options
80
+
81
+ Pass a key directly instead of reading the login:
82
+
83
+ ```jsonc
84
+ {
85
+ "plugins": [{ "package": "opencode-muse-code", "options": { "key": "..." } }]
86
+ }
87
+ ```
88
+
89
+ The `META_API_KEY` and `MUSE_API_KEY` environment variables are also honored.
90
+
91
+ ## Troubleshooting
92
+
93
+ - **Unauthorized**: run `muse` and sign in again, then restart the service. If
94
+ you also stored an API key for this provider in OpenCode, update or remove
95
+ that stored account so it cannot shadow the keychain login.
96
+ - **No models listed**: the first refresh failed and the fallback catalog was
97
+ used; check the network and restart the service.
98
+ - **Linux**: Muse CLI credential storage differs by platform. If the keychain
99
+ is not readable, set `META_API_KEY` or the plugin `key` option.
package/index.ts ADDED
@@ -0,0 +1,247 @@
1
+ import { execFile } from "node:child_process"
2
+ import { readFile } from "node:fs/promises"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+
6
+ import { Model, Plugin, Provider } from "@opencode-ai/plugin"
7
+
8
+ const PROVIDER_ID = "muse-code"
9
+ const PROVIDER_NAME = "Muse Code Sub Plugin"
10
+ const PROVIDER_PACKAGE = "@opencode/ai/providers/openai-compatible"
11
+ const API_ORIGIN = "https://api.meta.ai"
12
+ const API_BASE = `${API_ORIGIN}/v1`
13
+ const KEYCHAIN_SERVICE = "ai.meta.dev.credentials"
14
+ const KEYCHAIN_ACCOUNT = "meta"
15
+ const REFRESH_MS = 30 * 60 * 1_000
16
+ const REQUEST_TIMEOUT_MS = 10_000
17
+
18
+ type Credential = { api_key?: string; access_token?: string }
19
+
20
+ type MuseVariant = { reasoningEffort?: string }
21
+
22
+ type MuseModel = {
23
+ readonly id: string
24
+ readonly created?: number
25
+ readonly metadata?: {
26
+ readonly "muse-code"?: {
27
+ readonly name?: string
28
+ readonly is_hidden?: boolean
29
+ readonly tool_call?: boolean
30
+ readonly modalities?: { readonly input?: readonly string[]; readonly output?: readonly string[] }
31
+ readonly limit?: { readonly context?: number; readonly output?: number }
32
+ readonly options?: { readonly reasoningEffort?: string }
33
+ readonly variants?: Readonly<Record<string, MuseVariant>>
34
+ }
35
+ }
36
+ }
37
+
38
+ // The plugin host context is newer than the installed @opencode-ai/plugin
39
+ // types, so the provider domain is described locally.
40
+ type ProviderEditor = {
41
+ get(providerID: string): unknown
42
+ add(input: { info: Provider.Info; models: readonly Model.Info[] }): void
43
+ update(providerID: string, update: (provider: Record<string, unknown>) => void): void
44
+ models: { set(providerID: string, models: readonly Model.Info[]): void }
45
+ }
46
+
47
+ type ProviderDomain = {
48
+ transform(callback: (editor: ProviderEditor) => void): Promise<{ dispose: () => Promise<void> }>
49
+ reload(): Promise<void>
50
+ }
51
+
52
+ const FALLBACK_CATALOG: readonly MuseModel[] = [
53
+ {
54
+ id: "muse-spark-1.3",
55
+ metadata: {
56
+ "muse-code": {
57
+ name: "Muse Spark 1.3",
58
+ tool_call: true,
59
+ modalities: { input: ["text", "image"], output: ["text"] },
60
+ limit: { context: 1_007_997, output: 128_000 },
61
+ options: { reasoningEffort: "high" },
62
+ variants: {
63
+ minimal: { reasoningEffort: "minimal" },
64
+ low: { reasoningEffort: "low" },
65
+ medium: { reasoningEffort: "medium" },
66
+ high: { reasoningEffort: "high" },
67
+ xhigh: { reasoningEffort: "xhigh" },
68
+ max: { reasoningEffort: "max" },
69
+ },
70
+ },
71
+ },
72
+ },
73
+ ]
74
+
75
+ function run(command: string, args: readonly string[]): Promise<string> {
76
+ return new Promise((resolve, reject) => {
77
+ execFile(command, [...args], { encoding: "utf8", timeout: REQUEST_TIMEOUT_MS }, (error, stdout) => {
78
+ if (error) reject(error)
79
+ else resolve(stdout)
80
+ })
81
+ })
82
+ }
83
+
84
+ function parseJson<T>(value: string | undefined): T | undefined {
85
+ if (!value) return undefined
86
+ try {
87
+ return JSON.parse(value) as T
88
+ } catch {
89
+ return undefined
90
+ }
91
+ }
92
+
93
+ // The muse CLI keeps its login in the operating system credential store. On
94
+ // macOS that is the login keychain; on other platforms fall back to the files
95
+ // the CLI may write, and to the environment variables it understands.
96
+ async function readCredential(override: string | undefined): Promise<Credential | undefined> {
97
+ if (override) return { api_key: override }
98
+
99
+ const fromEnv = process.env.META_API_KEY ?? process.env.MUSE_API_KEY
100
+ if (fromEnv) return { api_key: fromEnv }
101
+
102
+ if (process.platform === "darwin") {
103
+ const raw = await run("security", [
104
+ "find-generic-password",
105
+ "-s",
106
+ KEYCHAIN_SERVICE,
107
+ "-a",
108
+ KEYCHAIN_ACCOUNT,
109
+ "-w",
110
+ ]).catch(() => undefined)
111
+ const parsed = parseJson<Credential>(raw)
112
+ if (parsed?.api_key || parsed?.access_token) return parsed
113
+ }
114
+
115
+ for (const file of ["auth.json", "secrets.json"]) {
116
+ const filePath = path.join(os.homedir(), ".config", "muse", file)
117
+ const parsed = parseJson<Credential>(await readFile(filePath, "utf8").catch(() => undefined))
118
+ if (parsed?.api_key || parsed?.access_token) return parsed
119
+ }
120
+
121
+ return undefined
122
+ }
123
+
124
+ // Exchanges the OAuth login for the long-lived API key. Minting is idempotent:
125
+ // the same account always gets the same key back.
126
+ async function mintApiKey(accessToken: string): Promise<string | undefined> {
127
+ const response = await fetch(`${API_ORIGIN}/muse-code/key`, {
128
+ method: "POST",
129
+ headers: {
130
+ authorization: `Bearer ${accessToken}`,
131
+ "content-type": "application/json",
132
+ },
133
+ body: "{}",
134
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
135
+ }).catch(() => undefined)
136
+ if (!response?.ok) return undefined
137
+ const body = (await response.json().catch(() => undefined)) as { api_key?: string } | undefined
138
+ return body?.api_key
139
+ }
140
+
141
+ async function fetchCatalog(apiKey: string): Promise<readonly MuseModel[] | undefined> {
142
+ const response = await fetch(`${API_ORIGIN}/muse-code/models`, {
143
+ headers: { authorization: `Bearer ${apiKey}` },
144
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
145
+ }).catch(() => undefined)
146
+ if (!response?.ok) return undefined
147
+ const body = (await response.json().catch(() => undefined)) as { data?: readonly MuseModel[] } | undefined
148
+ return body?.data
149
+ }
150
+
151
+ function toModelInfo(providerID: Provider.ID, model: MuseModel): Model.Info {
152
+ const meta = model.metadata?.["muse-code"] ?? {}
153
+ const defaultEffort = meta.options?.reasoningEffort
154
+ return {
155
+ ...Model.Info.default(providerID, Model.ID.make(model.id)),
156
+ name: meta.name ?? model.id,
157
+ capabilities: {
158
+ tools: meta.tool_call !== false,
159
+ input: [...(meta.modalities?.input ?? ["text"])],
160
+ output: ["text"],
161
+ },
162
+ limit: {
163
+ context: meta.limit?.context ?? 200_000,
164
+ output: meta.limit?.output ?? 32_000,
165
+ },
166
+ time: { released: (model.created ?? 0) * 1_000 },
167
+ variants: Object.entries(meta.variants ?? {}).map(([id, variant]) => ({
168
+ id: Model.VariantID.make(id),
169
+ body: { reasoning_effort: variant.reasoningEffort ?? id },
170
+ })),
171
+ ...(defaultEffort ? { body: { reasoning_effort: defaultEffort } } : {}),
172
+ }
173
+ }
174
+
175
+ export default Plugin.define({
176
+ id: "muse-code",
177
+ async setup(ctx) {
178
+ const provider = (ctx as unknown as { provider: ProviderDomain }).provider
179
+ const providerID = Provider.ID.make(PROVIDER_ID)
180
+ const optionKey = typeof ctx.options?.key === "string" ? ctx.options.key : undefined
181
+
182
+ // Mutable state the synchronous transform reads. It is refreshed before
183
+ // every provider reload, so reads during a replay see current data.
184
+ const state: { apiKey?: string; catalog: readonly MuseModel[] } = { catalog: FALLBACK_CATALOG }
185
+
186
+ const refresh = async (): Promise<boolean> => {
187
+ const credential = await readCredential(optionKey)
188
+ const apiKey = credential?.api_key ?? (credential?.access_token && (await mintApiKey(credential.access_token)))
189
+ if (!apiKey) return false
190
+
191
+ state.apiKey = apiKey
192
+ const catalog = await fetchCatalog(apiKey)
193
+ const usable = catalog?.filter((model) => {
194
+ const meta = model.metadata?.["muse-code"]
195
+ return meta && !meta.is_hidden && meta.tool_call !== false && (meta.modalities?.output ?? ["text"]).includes("text")
196
+ })
197
+ if (usable?.length) state.catalog = usable
198
+ return true
199
+ }
200
+
201
+ const connected = await refresh().catch(() => false)
202
+ if (!connected) {
203
+ console.warn(
204
+ "[muse-code] No Muse Code login found. Sign in with the `muse` CLI or set META_API_KEY. " +
205
+ "Models are listed but requests fail until then.",
206
+ )
207
+ }
208
+
209
+ const registration = await provider.transform((editor) => {
210
+ const settings = state.apiKey ? { baseURL: API_BASE, apiKey: state.apiKey } : { baseURL: API_BASE }
211
+ const models = state.catalog.map((model) => toModelInfo(providerID, model))
212
+
213
+ if (editor.get(PROVIDER_ID)) {
214
+ editor.update(PROVIDER_ID, (draft) => {
215
+ draft.name = PROVIDER_NAME
216
+ draft.activation = "enabled"
217
+ draft.package = PROVIDER_PACKAGE
218
+ draft.settings = settings
219
+ })
220
+ editor.models.set(PROVIDER_ID, models)
221
+ return
222
+ }
223
+
224
+ editor.add({
225
+ info: {
226
+ ...Provider.Info.empty(providerID),
227
+ name: PROVIDER_NAME,
228
+ activation: "enabled",
229
+ package: PROVIDER_PACKAGE,
230
+ settings,
231
+ },
232
+ models,
233
+ })
234
+ })
235
+
236
+ const timer = setInterval(() => {
237
+ void refresh()
238
+ .then((ready) => (ready ? provider.reload() : undefined))
239
+ .catch((error) => console.error("[muse-code] refresh failed", error))
240
+ }, REFRESH_MS)
241
+
242
+ return () => {
243
+ clearInterval(timer)
244
+ void registration.dispose()
245
+ }
246
+ },
247
+ })
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package.json",
3
+ "name": "opencode-muse-code",
4
+ "version": "0.1.0",
5
+ "description": "Use a Muse Code (Meta) subscription in OpenCode",
6
+ "author": "Shane Walker",
7
+ "type": "module",
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/swalker326/opencode-muse-code.git"
12
+ },
13
+ "homepage": "https://github.com/swalker326/opencode-muse-code#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/swalker326/opencode-muse-code/issues"
16
+ },
17
+ "exports": {
18
+ ".": "./index.ts"
19
+ },
20
+ "files": [
21
+ "index.ts",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "keywords": [
26
+ "opencode",
27
+ "opencode-plugin",
28
+ "muse",
29
+ "muse-code",
30
+ "meta"
31
+ ],
32
+ "peerDependencies": {
33
+ "@opencode-ai/plugin": "*"
34
+ }
35
+ }