opencode-ghost 0.3.1 → 0.5.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/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-09-30
11
+
12
+ ### Added
13
+
14
+ - Support `--all` and explicit directory targets in `install.sh` to install across both global OpenCode v1 (`~/.config/opencode`) and sandbox/v2 configurations.
15
+
16
+ ### Fixed
17
+
18
+ - Support OpenCode v1 and v2 plugin loading conventions (named and default `tui`/`id` exports, snake_case and camelCase slot props, object model specs).
19
+ ## [0.4.0] - 2026-09-30
20
+
21
+ ### Added
22
+
23
+ - Expanded direct stateless suggestion generation: resolve API keys directly from OpenCode provider state (`api.state.provider`), add default endpoints for well-known providers (OpenAI, DeepSeek, Groq, Mistral, OpenRouter, Together, Ollama, Perplexity, xAI, Fireworks, Cerebras), and support keyless local completions without creating temporary OpenCode sessions.
10
24
  ## [0.3.1] - 2026-09-28
11
25
 
12
26
  ### Fixed
@@ -132,7 +146,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
132
146
  - Options for `model`, `acceptKeys`, `maxChars`, `idleDelayMs`,
133
147
  `recentMessages` and `system`.
134
148
 
135
- [Unreleased]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.3.1...HEAD
149
+ [Unreleased]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.5.0...HEAD
150
+ [0.5.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.4.0...v0.5.0
151
+ [0.4.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.3.1...v0.4.0
136
152
  [0.3.1]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.3.0...v0.3.1
137
153
  [0.3.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.7...v0.3.0
138
154
  [0.2.7]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.6...v0.2.7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-ghost",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Claude Code-style next-prompt suggestions in the opencode TUI, shown as dimmed ghost text and accepted with Tab. An opencode plugin.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -63,6 +63,6 @@
63
63
  "@opentui/solid": "0.4.5",
64
64
  "@types/node": "24.12.2",
65
65
  "husky": "^9.1.7",
66
- "typescript": "5.8.2"
66
+ "typescript": "7.0.2"
67
67
  }
68
68
  }
package/src/stateless.ts CHANGED
@@ -3,6 +3,34 @@ import * as os from "node:os"
3
3
  import * as path from "node:path"
4
4
  import type { ResolvedOptions } from "./options"
5
5
 
6
+ const WELL_KNOWN_BASE_URLS: Record<string, string> = {
7
+ openai: "https://api.openai.com/v1",
8
+ deepseek: "https://api.deepseek.com",
9
+ groq: "https://api.groq.com/openai/v1",
10
+ mistral: "https://api.mistral.com/v1",
11
+ openrouter: "https://openrouter.ai/api/v1",
12
+ together: "https://api.together.xyz/v1",
13
+ ollama: "http://127.0.0.1:11434/v1",
14
+ perplexity: "https://api.perplexity.ai",
15
+ xai: "https://api.x.ai/v1",
16
+ fireworks: "https://api.fireworks.ai/inference/v1",
17
+ cerebras: "https://api.cerebras.ai/v1",
18
+ }
19
+
20
+ const WELL_KNOWN_ENV_KEYS: Record<string, string[]> = {
21
+ openai: ["OPENAI_API_KEY"],
22
+ deepseek: ["DEEPSEEK_API_KEY"],
23
+ groq: ["GROQ_API_KEY"],
24
+ mistral: ["MISTRAL_API_KEY"],
25
+ openrouter: ["OPENROUTER_API_KEY"],
26
+ together: ["TOGETHER_API_KEY"],
27
+ perplexity: ["PERPLEXITY_API_KEY"],
28
+ xai: ["XAI_API_KEY"],
29
+ fireworks: ["FIREWORKS_API_KEY"],
30
+ cerebras: ["CEREBRAS_API_KEY"],
31
+ anthropic: ["ANTHROPIC_API_KEY"],
32
+ }
33
+
6
34
  export function resolveApiKey(raw: string | undefined): string | undefined {
7
35
  if (!raw) return undefined
8
36
  const trimmed = raw.trim()
@@ -24,24 +52,70 @@ export function resolveApiKey(raw: string | undefined): string | undefined {
24
52
  return trimmed
25
53
  }
26
54
 
55
+ function resolveProviderApiKey(
56
+ opts: ResolvedOptions,
57
+ providerID: string | undefined,
58
+ providerConfig: any,
59
+ providerState: any,
60
+ ): string | undefined {
61
+ if (opts.apiKey) return resolveApiKey(opts.apiKey)
62
+
63
+ const configKey = resolveApiKey(providerConfig?.options?.apiKey)
64
+ if (configKey) return configKey
65
+
66
+ const stateOptionKey = resolveApiKey(providerState?.options?.apiKey)
67
+ if (stateOptionKey) return stateOptionKey
68
+
69
+ const stateKey = resolveApiKey(providerState?.key)
70
+ if (stateKey) return stateKey
71
+
72
+ if (Array.isArray(providerState?.env)) {
73
+ for (const envName of providerState.env) {
74
+ if (typeof envName === "string" && process.env[envName]) {
75
+ return process.env[envName]
76
+ }
77
+ }
78
+ }
79
+
80
+ if (providerID && WELL_KNOWN_ENV_KEYS[providerID]) {
81
+ for (const envName of WELL_KNOWN_ENV_KEYS[providerID]) {
82
+ if (process.env[envName]) {
83
+ return process.env[envName]
84
+ }
85
+ }
86
+ }
87
+
88
+ if (process.env.LITELLM_API_KEY) return process.env.LITELLM_API_KEY
89
+ if (!providerID || providerID === "openai" || providerID === "custom") {
90
+ if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY
91
+ }
92
+
93
+ return undefined
94
+ }
95
+
27
96
  export async function fetchStatelessSuggestion(
28
97
  transcript: string,
29
98
  opts: ResolvedOptions,
30
99
  modelRef: { providerID: string; modelID: string } | undefined,
31
100
  config: any,
101
+ providers?: readonly any[],
32
102
  ): Promise<string | undefined> {
33
- const provider = modelRef?.providerID ? config?.provider?.[modelRef.providerID] : undefined
103
+ const providerID = modelRef?.providerID
104
+ const providerConfig = providerID ? config?.provider?.[providerID] : undefined
105
+ const providerState = providerID && Array.isArray(providers)
106
+ ? providers.find((p) => p?.id === providerID)
107
+ : undefined
108
+
109
+ const apiKey = resolveProviderApiKey(opts, providerID, providerConfig, providerState)
110
+ const isAnthropic = providerID === "anthropic"
111
+
34
112
  const baseURL =
35
113
  opts.endpoint ||
36
- provider?.options?.baseURL ||
114
+ providerConfig?.options?.baseURL ||
115
+ providerState?.options?.baseURL ||
116
+ (!isAnthropic && providerID ? WELL_KNOWN_BASE_URLS[providerID] : undefined) ||
37
117
  process.env.LITELLM_BASE_URL ||
38
118
  process.env.OPENAI_BASE_URL
39
- const rawKey =
40
- opts.apiKey ||
41
- provider?.options?.apiKey ||
42
- process.env.LITELLM_API_KEY ||
43
- process.env.OPENAI_API_KEY
44
- const apiKey = resolveApiKey(rawKey)
45
119
 
46
120
  const controller = new AbortController()
47
121
  const timeoutMs = 4000
@@ -54,7 +128,8 @@ export async function fetchStatelessSuggestion(
54
128
  : `${baseURL.replace(/\/+$/, "")}/chat/completions`
55
129
 
56
130
  const rawModel = modelRef?.modelID || "default"
57
- const model = rawModel.includes("/")
131
+ const keepSlash = providerID === "openrouter" || providerID === "together"
132
+ const model = !keepSlash && rawModel.includes("/")
58
133
  ? rawModel.slice(rawModel.lastIndexOf("/") + 1)
59
134
  : rawModel
60
135
 
@@ -82,9 +157,15 @@ export async function fetchStatelessSuggestion(
82
157
  }
83
158
 
84
159
  if (modelRef?.providerID === "anthropic") {
85
- const antKey = resolveApiKey(provider?.options?.apiKey) || process.env.ANTHROPIC_API_KEY
160
+ const antKey = apiKey || process.env.ANTHROPIC_API_KEY
86
161
  if (!antKey) return undefined
87
- const res = await fetch("https://api.anthropic.com/v1/messages", {
162
+ const antBase = (
163
+ providerConfig?.options?.baseURL ||
164
+ providerState?.options?.baseURL ||
165
+ "https://api.anthropic.com"
166
+ ).replace(/\/+$/, "")
167
+ const url = antBase.endsWith("/v1/messages") ? antBase : `${antBase}/v1/messages`
168
+ const res = await fetch(url, {
88
169
  method: "POST",
89
170
  headers: {
90
171
  "Content-Type": "application/json",
package/src/text.ts CHANGED
@@ -1,7 +1,27 @@
1
1
  export function parseModel(
2
- spec: string | undefined,
2
+ spec: unknown,
3
3
  ): { providerID: string; modelID: string } | undefined {
4
4
  if (!spec) return undefined
5
+ if (typeof spec === "object" && spec !== null) {
6
+ const obj = spec as Record<string, unknown>
7
+ const providerID =
8
+ typeof obj.providerID === "string"
9
+ ? obj.providerID
10
+ : typeof obj.provider === "string"
11
+ ? obj.provider
12
+ : undefined
13
+ const modelID =
14
+ typeof obj.modelID === "string"
15
+ ? obj.modelID
16
+ : typeof obj.id === "string"
17
+ ? obj.id
18
+ : typeof obj.model === "string"
19
+ ? obj.model
20
+ : undefined
21
+ if (providerID && modelID) return { providerID, modelID }
22
+ return undefined
23
+ }
24
+ if (typeof spec !== "string") return undefined
5
25
  const index = spec.indexOf("/")
6
26
  if (index <= 0 || index === spec.length - 1) return undefined
7
27
  return { providerID: spec.slice(0, index), modelID: spec.slice(index + 1) }
package/src/tui.tsx CHANGED
@@ -562,10 +562,11 @@ const tui: TuiPlugin = async (api, rawOptions) => {
562
562
  } catch {
563
563
  // ignore
564
564
  }
565
+ const config = api.state.config as Record<string, unknown> | undefined
565
566
  return (
566
567
  parseModel(opts.model) ??
567
- parseModel(api.state.config.small_model) ??
568
- parseModel(api.state.config.model)
568
+ parseModel(config?.small_model ?? config?.smallModel) ??
569
+ parseModel(config?.model)
569
570
  )
570
571
  }
571
572
 
@@ -631,6 +632,7 @@ const tui: TuiPlugin = async (api, rawOptions) => {
631
632
  opts,
632
633
  model,
633
634
  api.state.config,
635
+ api.state.provider,
634
636
  )
635
637
  if (stateless) {
636
638
  const suggestion = normalize(stateless, opts.maxChars)
@@ -828,9 +830,10 @@ const tui: TuiPlugin = async (api, rawOptions) => {
828
830
  order: 60,
829
831
  slots: {
830
832
  session_prompt(_ctx, props) {
833
+ const sid = props.session_id ?? (props as any).sessionID
831
834
  const overlay = () => {
832
835
  const current = inBox()
833
- return current && current.sessionID === props.session_id ? current : undefined
836
+ return current && current.sessionID === sid ? current : undefined
834
837
  }
835
838
  return (
836
839
  <box
@@ -840,15 +843,16 @@ const tui: TuiPlugin = async (api, rawOptions) => {
840
843
  }}
841
844
  >
842
845
  <api.ui.Prompt
843
- sessionID={props.session_id}
846
+ sessionID={sid}
844
847
  visible={props.visible}
845
848
  disabled={props.disabled}
846
849
  showPlaceholder={false}
847
- right={<api.ui.Slot name="session_prompt_right" session_id={props.session_id} />}
850
+ right={<api.ui.Slot name="session_prompt_right" session_id={sid} />}
848
851
  onSubmit={() => {
849
852
  showGhost(undefined)
850
853
  setInBox(undefined)
851
854
  props.on_submit?.()
855
+ ;(props as any).onSubmit?.()
852
856
  }}
853
857
  ref={(ref) => {
854
858
  promptRef = ref
@@ -930,7 +934,6 @@ const tui: TuiPlugin = async (api, rawOptions) => {
930
934
  })
931
935
  }
932
936
 
933
- export default {
934
- id: "ghost",
935
- tui,
936
- }
937
+ export const id = "ghost"
938
+ export { tui }
939
+ export default Object.assign(tui, { id: "ghost", tui })