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.
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/index.ts +247 -0
- 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
|
+
}
|