feat(opencode): Windows PowerShell installer and Desktop key file

CLI and Desktop share %USERPROFILE%\.config\opencode. Persist the API
key in .arka-api-key so GUI apps do not need a login-shell export.
This commit is contained in:
ixoblakp committed 2026-08-27 18:12:48 +03:00
1 parent 3e440b9365
commit cb17ce8d2a
6 files changed
+531 -12

No files matched your search

+327
View File
@@ -0,0 +1,327 @@
# install-arka-provider.ps1 — OpenCode Arka-AI provider for Windows
# (CLI and Desktop). Idempotent. Canonical files also live next to this script.
#
# One-liner (PowerShell):
# irm https://git.arka-ai.ru/arka/public/raw/branch/main/opencode/install-arka-provider.ps1 | iex
#
# With key + default model:
# iwr -useb URL -OutFile $env:TEMP\install-arka.ps1
# powershell -ExecutionPolicy Bypass -File $env:TEMP\install-arka.ps1 -Key $env:ARKA_API_KEY -SetDefault DeepSeek-V4-Pro
#
# cmd.exe:
# powershell -NoProfile -ExecutionPolicy Bypass -Command "irm URL | iex"
[CmdletBinding()]
param(
[string]$Key,
[string]$SetDefault
)
$ErrorActionPreference = 'Stop'
if (-not $Key) { $Key = $env:ARKA_API_KEY }
function Get-ConfigDir {
if ($env:XDG_CONFIG_HOME) { return (Join-Path $env:XDG_CONFIG_HOME 'opencode') }
return (Join-Path $HOME '.config\opencode')
}
function Get-InstallDirs {
$dirs = New-Object System.Collections.Generic.List[string]
$canonical = Get-ConfigDir
[void]$dirs.Add($canonical)
if ($env:APPDATA) {
$legacy = Join-Path $env:APPDATA 'opencode'
if ((Test-Path $legacy) -and ($legacy -ne $canonical)) { [void]$dirs.Add($legacy) }
}
return $dirs
}
if (-not $Key) {
$entered = Read-Host 'ARKA_API_KEY (leave blank to skip)'
if ($entered) { $Key = $entered }
}
$plugin = @'
import { homedir } from "node:os"
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { join } from "node:path"
const PROVIDER_ID = "Arka-AI"
const API_URL = "https://api.arka-ai.ru/v1"
const CATALOG_URL =
"https://git.arka-ai.ru/arka/public/raw/branch/main/opencode/catalog.json"
const TTL_MS = 24 * 60 * 60 * 1000
function configDir(): string {
const xdg = process.env.XDG_CONFIG_HOME
if (xdg) return join(xdg, "opencode")
return join(homedir(), ".config", "opencode")
}
function cacheDir(): string {
const xdg = process.env.XDG_CACHE_HOME
if (xdg) return join(xdg, "opencode")
return join(homedir(), ".cache", "opencode")
}
const CACHE_FILE = join(cacheDir(), "arka-ai-models.json")
const KEY_FILE = join(configDir(), ".arka-api-key")
function readApiKey(): string | undefined {
const fromEnv = process.env.ARKA_API_KEY?.trim()
if (fromEnv) return fromEnv
try {
if (existsSync(KEY_FILE)) {
const fromFile = readFileSync(KEY_FILE, "utf8").trim()
if (fromFile) return fromFile
}
} catch {}
return undefined
}
// Ids that must always be exposed, even if the filter would drop them.
const FORCED_MODELS = [
"DeepSeek-V4-Pro",
"deepseek-v4-flash-0731",
"qwen3.8-max",
"kimi-k3",
"MiniMax-M3",
"GLM-5.3",
]
// Heuristic: drop non-chat models (TTS/ASR/image/video/OCR/embeddings/rerankers…)
// from the catalog so they don't clutter the /models picker.
// Keep in sync with scripts/sync-catalog.py
const NON_CHAT = new RegExp(
[
"tts", "asr", "audio", "speech", "voice", "whisper", "sensevoice",
"image", "(^|[.-])img", "wan", "vidu", "video", "sora", "flux",
"diffus", "kolors", "cogview", "ocr", "embedding", "(^|[.-])embed",
"rerank", "clip", "moderation", "(^|[.-])filter", "detect", "classif",
"(^|[.-])location", "(^|[.-])search", "comfyui", "meme", "hello",
"(^|[.-])sam", "florence", "esrgan", "rmbg", "background", "poster",
"(^|[.-])face", "(^|[.-])lip", "translate", "(^|[.-])mt([2-]|$)",
"(^|[.-])bge", "(^|[.-])e5", "(^|[.-])jina", "(^|[.-])mpnet",
"spark-tts", "cosyvoice", "chattts", "indextts", "step-audio",
"fish-speech", "cosy", "mate-gguf", "tokenizer",
].join("|"),
"i",
)
type ModelMap = Record<string, { name: string }>
type Cache = { ts: number; models: ModelMap }
function loadCache(): Cache | null {
try {
if (existsSync(CACHE_FILE)) return JSON.parse(readFileSync(CACHE_FILE, "utf8"))
} catch {}
return null
}
function saveCache(models: ModelMap): void {
try {
mkdirSync(cacheDir(), { recursive: true })
writeFileSync(CACHE_FILE, JSON.stringify({ ts: Date.now(), models }))
} catch {}
}
async function fetchJson(
url: string,
headers: Record<string, string>,
timeoutMs: number,
): Promise<unknown> {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
try {
const res = await fetch(url, { headers, signal: ctrl.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return await res.json()
} finally {
clearTimeout(timer)
}
}
function idsFromPayload(json: unknown): string[] {
const data = (json as { data?: Array<{ id: string }> }).data
return (data ?? []).map((m) => m.id).filter(Boolean)
}
function toModelMap(ids: string[], filter: boolean): ModelMap {
const models: ModelMap = {}
for (const id of ids) {
if (!filter || FORCED_MODELS.includes(id) || !NON_CHAT.test(id)) {
models[id] = { name: id }
}
}
return models
}
async function resolveModels(): Promise<ModelMap | null> {
const cache = loadCache()
if (cache && Date.now() - cache.ts < TTL_MS) return cache.models
try {
const models = toModelMap(idsFromPayload(await fetchJson(CATALOG_URL, {}, 5000)), false)
if (Object.keys(models).length) {
saveCache(models)
return models
}
} catch {}
try {
const key = readApiKey()
const json = await fetchJson(
`${API_URL}/models`,
key ? { Authorization: `Bearer ${key}` } : {},
5000,
)
const models = toModelMap(idsFromPayload(json), true)
saveCache(models)
return models
} catch {
return cache?.models ?? null
}
}
export default async function ArkaAIProviderPlugin() {
const models = await resolveModels()
const apiKey = readApiKey()
return {
config: (cfg: any) => {
cfg.provider ??= {}
const prev = cfg.provider[PROVIDER_ID] ?? {}
// Static models from opencode.json act as a fallback; the fetched
// catalog is layered on top so the configured default model never
// disappears even if the catalog drops it.
// apiKey from env or ~/.config/opencode/.arka-api-key so Desktop /
// Windows GUI works without a login-shell export.
cfg.provider[PROVIDER_ID] = {
name: "Arka-AI",
api: API_URL,
env: ["ARKA_API_KEY"],
...prev,
models: { ...(prev.models ?? {}), ...(models ?? {}) },
options: {
...(prev.options ?? {}),
...(apiKey ? { apiKey } : {}),
},
}
},
}
}
'@
$skill = @'
---
name: arka-provider
description: Manage the custom Arka-AI (arka-ai.ru) LLM provider in opencode config. Use when the user needs to add, refresh, or fix the arka-ai.ru provider, sync its model catalog, change the Arka-AI default model, or when arka models are missing from /models.
---
# Arka-AI provider
arka-ai.ru is **not** in models.dev, so opencode ships no built-in config for it.
It is wired up locally with a plugin + a static `provider` block in
`~/.config/opencode/opencode.json`.
## How it works
- Plugin: `~/.config/opencode/plugins/arka-ai.ts`
- On startup it loads the published chat catalog
`https://git.arka-ai.ru/arka/public/raw/branch/main/opencode/catalog.json`
(no API key; refreshed daily at 05:00 Europe/Moscow).
- If that fetch fails, it falls back to `https://api.arka-ai.ru/v1/models`
and filters out non-chat models (TTS/ASR/image/video/OCR/embedding/reranker/…)
via a heuristic regex so the `/models` picker stays clean.
- Caches the result at `~/.cache/opencode/arka-ai-models.json` (TTL 24h).
- Injects the provider into config via the `config` hook, layered over the
static block in `opencode.json`.
- Static block (`opencode.json` → `provider["Arka-AI"]`) holds
`name`, `api`, `env`, and a small fallback `models` map that is used when the
catalog is unreachable.
- Inference uses `ARKA_API_KEY` (env) or `~/.config/opencode/.arka-api-key`
(so OpenCode Desktop / Windows GUI works without a login shell).
## Refresh the model list
Catalog ~216 raw models / ~116 chat; the public snapshot updates at 05:00
Europe/Moscow. Changes land in opencode only after cache expiry + restart
(config is not hot-reloaded).
1. `rm -f ~/.cache/opencode/arka-ai-models.json`
2. Restart opencode (or run `opencode` again) — the plugin re-fetches on the
next cold start.
Optionally inspect the live API (no key required for listing):
```bash
curl -sS https://api.arka-ai.ru/v1/models | python3 -m json.tool
curl -sS https://git.arka-ai.ru/arka/public/raw/branch/main/opencode/catalog.json | python3 -m json.tool
```
## Change default model
Edit `opencode.json` → `"model": "Arka-AI/<model-id>"`. Use the IDs exactly as
returned by the catalog (e.g. `Arka-AI/DeepSeek-V4-Pro`,
`Arka-AI/qwen3.8-max`, `Arka-AI/kimi-k3`).
## Adjust filtering
Edit `NON_CHAT` (regex) or `FORCED_MODELS` in the plugin file **and** the same
lists in `scripts/sync-catalog.py` in `arka/public`, then re-run the installer
and restart. `FORCED_MODELS` keeps must-have chat models even if the heuristic
would drop them.
'@
$dirs = Get-InstallDirs
foreach ($conf in $dirs) {
$pluginDir = Join-Path $conf 'plugins'
$skillDir = Join-Path $conf 'skills\arka-provider'
New-Item -ItemType Directory -Force -Path $pluginDir | Out-Null
New-Item -ItemType Directory -Force -Path $skillDir | Out-Null
$pluginPath = Join-Path $pluginDir 'arka-ai.ts'
$skillPath = Join-Path $skillDir 'SKILL.md'
[System.IO.File]::WriteAllText($pluginPath, $plugin.TrimStart() + "`n")
[System.IO.File]::WriteAllText($skillPath, $skill.TrimStart() + "`n")
Write-Host "==> plugin $pluginPath"
Write-Host "==> skill $skillPath"
if ($Key) {
$keyFile = Join-Path $conf '.arka-api-key'
[System.IO.File]::WriteAllText($keyFile, $Key.Trim() + "`n")
Write-Host "==> key $keyFile"
}
if ($SetDefault) {
$confFile = Join-Path $conf 'opencode.json'
Write-Host "==> default Arka-AI/$SetDefault in $confFile"
$data = $null
if (Test-Path $confFile) {
$data = Get-Content -Raw -Encoding UTF8 $confFile | ConvertFrom-Json
}
if (-not $data) { $data = New-Object psobject }
$data | Add-Member -NotePropertyName '$schema' -NotePropertyValue 'https://opencode.ai/config.json' -Force
$data | Add-Member -NotePropertyName model -NotePropertyValue ("Arka-AI/" + $SetDefault) -Force
($data | ConvertTo-Json -Depth 40) + "`n" | Out-File -FilePath $confFile -Encoding utf8
}
}
if ($Key) {
$env:ARKA_API_KEY = $Key
try {
[Environment]::SetEnvironmentVariable('ARKA_API_KEY', $Key, 'User')
Write-Host '==> User environment ARKA_API_KEY (Desktop / GUI pick this up after a full restart)'
} catch {
Write-Warning "could not set User environment variable: $_"
}
} else {
Write-Warning 'no ARKA_API_KEY; Desktop will not be able to call models until you set it'
}
Write-Host ''
Write-Host 'Done.'
Write-Host 'Next:'
Write-Host ' 1. Fully quit OpenCode CLI *and* OpenCode Desktop (tray icon too), then start again.'
Write-Host ' 2. /models — provider Arka-AI should appear (~116 chat models).'
Write-Host ' 3. Refresh later: delete %USERPROFILE%\.cache\opencode\arka-ai-models.json and restart.'