@visual-z/xeno 0.1.4 → 0.2.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 +19 -0
- package/README.md +143 -33
- package/docs/install.md +18 -6
- package/docs/providers.md +147 -0
- package/package.json +8 -2
- package/src/apps/cli.ts +42 -14
- package/src/apps/commands.ts +32 -4
- package/src/apps/completions.ts +9 -6
- package/src/apps/export-cli.ts +24 -0
- package/src/apps/extension-ui.ts +330 -0
- package/src/apps/input.ts +16 -1
- package/src/apps/model-catalog.ts +42 -0
- package/src/apps/project-trust.ts +49 -0
- package/src/apps/render.ts +1 -0
- package/src/apps/resource-browser.ts +75 -0
- package/src/apps/resources.ts +25 -0
- package/src/apps/session-browser.ts +113 -0
- package/src/apps/session-picker.ts +55 -0
- package/src/apps/shell.ts +1177 -378
- package/src/apps/startup.ts +400 -268
- package/src/core/action-defaults.ts +306 -0
- package/src/core/attachment-input.ts +17 -0
- package/src/core/catalog-merge.ts +19 -0
- package/src/core/event-validation.ts +19 -2
- package/src/core/events.ts +45 -2
- package/src/core/export.ts +6 -2
- package/src/core/extension-flags.ts +14 -0
- package/src/core/images.ts +46 -0
- package/src/core/json-event.ts +2 -0
- package/src/core/keymap.ts +43 -119
- package/src/core/model-replay.ts +22 -0
- package/src/core/model-scope.ts +53 -0
- package/src/core/packages.ts +47 -0
- package/src/core/project.ts +21 -3
- package/src/core/prompts.ts +33 -5
- package/src/core/queue.ts +18 -0
- package/src/core/resource-selection.ts +36 -0
- package/src/{runtime → core}/resume.ts +15 -22
- package/src/core/session-archive.ts +4 -0
- package/src/core/session-browser.ts +82 -0
- package/src/core/session-entry.ts +56 -0
- package/src/core/session-position.ts +26 -0
- package/src/core/settings.ts +169 -154
- package/src/core/theme.ts +15 -6
- package/src/core/thinking.ts +26 -0
- package/src/core/tokens.ts +2 -0
- package/src/core/transcript.ts +15 -5
- package/src/runtime/auth.ts +116 -11
- package/src/runtime/catalog.ts +54 -151
- package/src/runtime/clipboard.ts +114 -0
- package/src/runtime/extension-loader.ts +37 -0
- package/src/runtime/extension-ui.ts +55 -0
- package/src/runtime/extensions.ts +273 -0
- package/src/runtime/images.ts +76 -0
- package/src/runtime/loop.ts +213 -83
- package/src/runtime/model-config.ts +232 -0
- package/src/runtime/model-data.LICENSE +27 -0
- package/src/runtime/model-data.ts +21 -0
- package/src/runtime/model-replay.ts +10 -0
- package/src/runtime/model-scope.ts +20 -0
- package/src/runtime/model-sources.ts +151 -0
- package/src/runtime/model.ts +74 -11
- package/src/runtime/models-fetch.ts +6 -104
- package/src/runtime/oauth.ts +4 -3
- package/src/runtime/openai-responses.ts +12 -4
- package/src/runtime/openai.ts +13 -4
- package/src/runtime/package-manager.ts +125 -0
- package/src/runtime/pi-provider.ts +235 -0
- package/src/runtime/progress.ts +26 -0
- package/src/runtime/provider-access.ts +21 -0
- package/src/runtime/provider-protocols.ts +29 -0
- package/src/runtime/provider-request.ts +73 -0
- package/src/runtime/provider.ts +26 -6
- package/src/runtime/rich-text.ts +36 -0
- package/src/runtime/tools.ts +41 -5
- package/src/store/command.ts +7 -1
- package/src/store/context-files.ts +1 -1
- package/src/store/log.ts +65 -0
- package/src/store/models.ts +28 -9
- package/src/store/observations.ts +4 -2
- package/src/store/packages.ts +98 -0
- package/src/store/recovery.ts +8 -0
- package/src/store/resource-index.ts +170 -0
- package/src/store/resources.ts +75 -142
- package/src/store/session-archive.ts +162 -0
- package/src/store/session-browser.ts +68 -0
- package/src/store/session-position.ts +27 -0
- package/src/store/sessions.ts +87 -66
- package/src/store/trust.ts +13 -0
- package/src/tui/chat-viewport.ts +47 -0
- package/src/tui/editor.ts +62 -15
- package/src/tui/host.ts +50 -9
- package/src/tui/input.ts +8 -2
- package/src/tui/select.ts +24 -6
- package/src/tui/settings.ts +4 -0
- package/src/tui/system-theme.ts +700 -0
- package/src/tui/transcript.ts +63 -11
- package/src/tui/tree.ts +161 -95
- package/src/vendor/pi-rich/LICENSE.upstream +21 -0
- package/src/vendor/pi-rich/UPSTREAM.json +17 -0
- package/src/vendor/pi-rich/clipboard-command.ts +44 -0
- package/src/vendor/pi-rich/exif-orientation.ts +182 -0
- package/src/vendor/pi-rich/html.ts +51 -0
- package/src/vendor/pi-rich/image-convert.ts +49 -0
- package/src/vendor/pi-rich/image-resize-core.ts +164 -0
- package/src/vendor/pi-rich/image-resize-worker.ts +42 -0
- package/src/vendor/pi-rich/image-resize.ts +123 -0
- package/src/vendor/pi-rich/mime.ts +116 -0
- package/src/vendor/pi-rich/photon.ts +139 -0
- package/src/vendor/pi-rich/syntax-highlight.ts +214 -0
- package/src/vendor/pi-tui/UPSTREAM.json +50 -0
- package/src/vendor/pi-tui/VENDOR.md +7 -5
- package/src/vendor/pi-tui/autocomplete.ts +35 -10
- package/src/vendor/pi-tui/colors.ts +367 -0
- package/src/vendor/pi-tui/components/box.ts +8 -9
- package/src/vendor/pi-tui/components/editor.ts +4 -2
- package/src/vendor/pi-tui/components/markdown.ts +8 -2
- package/src/vendor/pi-tui/index.ts +31 -1
- package/src/vendor/pi-tui/native/UPSTREAM.json +23 -0
- package/src/vendor/pi-tui/native/clipboard.h +208 -0
- package/src/vendor/pi-tui/native/darwin/README.md +22 -0
- package/src/vendor/pi-tui/native/darwin/build.sh +69 -0
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +132 -0
- package/src/vendor/pi-tui/native/linux/README.md +23 -0
- package/src/vendor/pi-tui/native/linux/build.sh +65 -0
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +70 -0
- package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +274 -0
- package/src/vendor/pi-tui/native/napi.h +91 -0
- package/src/vendor/pi-tui/native/win32/README.md +31 -0
- package/src/vendor/pi-tui/native/win32/build.mjs +232 -0
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/src/win32-platform.c +241 -0
- package/src/vendor/pi-tui/native-platform.ts +2 -0
- package/src/vendor/pi-tui/oklab.ts +233 -0
- package/src/vendor/pi-tui/terminal-colors.ts +26 -8
- package/src/vendor/pi-tui/terminal-image.ts +43 -8
- package/src/vendor/pi-tui/terminal.ts +16 -9
- package/src/vendor/pi-tui/tui-alt-screen.ts +26 -18
- package/src/vendor/pi-tui/tui.ts +112 -75
- package/src/vendor/pi-tui/utils.ts +89 -49
- package/src/vendor/pi-tui/wheel-scroll.ts +82 -0
package/src/core/transcript.ts
CHANGED
|
@@ -1,25 +1,35 @@
|
|
|
1
1
|
/** Human history is a separate pure log projection from the model's bounded context (D1, D8). */
|
|
2
2
|
import type { LogEvent } from "./events.ts";
|
|
3
3
|
import { assertNever } from "./exhaustive.ts";
|
|
4
|
+
import type { ImageRef } from "./images.ts";
|
|
4
5
|
|
|
5
6
|
type Identity = { id: string; revision: string };
|
|
6
7
|
export type TranscriptItem = Identity & (
|
|
7
|
-
| { kind: "user"; text: string; interrupted: boolean }
|
|
8
|
+
| { kind: "user"; text: string; interrupted: boolean; images?: readonly ImageRef[] }
|
|
8
9
|
| { kind: "assistant" | "thinking" | "notice"; text: string }
|
|
9
|
-
| { kind: "
|
|
10
|
+
| { kind: "custom"; text: string; customType: string; data?: unknown; entry: boolean; timestamp: string }
|
|
11
|
+
| { kind: "tool"; callId: string; name: string; input: unknown; result: { text: string; ok: boolean; data?: unknown; images?: readonly ImageRef[] } | null }
|
|
10
12
|
);
|
|
11
13
|
|
|
12
|
-
export function projectTranscript(events: readonly LogEvent[]): TranscriptItem[] {
|
|
14
|
+
export function projectTranscript(events: readonly LogEvent[], entryTypes: ReadonlySet<string> = new Set()): TranscriptItem[] {
|
|
13
15
|
const items: TranscriptItem[] = [];
|
|
14
16
|
const calls = new Map<string, number>();
|
|
15
17
|
const users = new Map<string, number>();
|
|
16
18
|
for (const event of events) {
|
|
17
19
|
const identity = { id: event.id, revision: event.id };
|
|
18
20
|
switch (event.t) {
|
|
21
|
+
case "extension":
|
|
22
|
+
if (event.display || !event.inContext && entryTypes.has(event.customType)) items.push({ ...identity, kind: "custom", text: event.content, customType: event.customType,
|
|
23
|
+
entry: !event.inContext, timestamp: event.at, ...(event.data === undefined ? {} : { data: event.data }) });
|
|
24
|
+
break;
|
|
19
25
|
case "user":
|
|
20
26
|
users.set(event.id, items.length);
|
|
21
|
-
items.push({ ...identity, kind: "user", text: event.text, interrupted: false });
|
|
27
|
+
items.push({ ...identity, kind: "user", text: event.text, interrupted: false, ...(event.images?.length ? { images: event.images } : {}) });
|
|
22
28
|
break;
|
|
29
|
+
case "user_bash": items.push({ ...identity, kind: "notice",
|
|
30
|
+
text: `${event.excludeFromContext ? "!!" : "!"}${event.command}\n${event.content}` }); break;
|
|
31
|
+
case "outcome": items.push({ ...identity, kind: "notice",
|
|
32
|
+
text: `${event.outcome.status} · ${event.outcome.reason} · ${event.reason}` }); break;
|
|
23
33
|
case "assistant":
|
|
24
34
|
event.blocks.forEach((block, i) => {
|
|
25
35
|
const id = `${event.id}:${i}`;
|
|
@@ -33,7 +43,7 @@ export function projectTranscript(events: readonly LogEvent[]): TranscriptItem[]
|
|
|
33
43
|
const index = calls.get(event.call_id);
|
|
34
44
|
const call = index === undefined ? undefined : items[index];
|
|
35
45
|
if (call?.kind === "tool" && index !== undefined) {
|
|
36
|
-
items[index] = { ...call, revision: event.id, result: { text: event.content, ok: event.ok } };
|
|
46
|
+
items[index] = { ...call, revision: event.id, result: { text: event.content, ok: event.ok, ...(event.data === undefined ? {} : { data: event.data }), ...(event.images?.length ? { images: event.images } : {}) } };
|
|
37
47
|
} else items.push({ ...identity, kind: "notice", text: `Unpaired tool result ${event.call_id}: ${event.content}` });
|
|
38
48
|
break;
|
|
39
49
|
}
|
package/src/runtime/auth.ts
CHANGED
|
@@ -13,13 +13,13 @@
|
|
|
13
13
|
* still winning. pi states this rule outright in its resolver; it is worth
|
|
14
14
|
* copying verbatim.
|
|
15
15
|
*
|
|
16
|
-
* The order is
|
|
17
|
-
* A fourth position belongs to custom providers declared in `models.json`,
|
|
18
|
-
* which this build does not have yet.
|
|
16
|
+
* The order is command line, auth.json, models.json, then the environment.
|
|
19
17
|
*/
|
|
20
18
|
|
|
21
19
|
import { assertNever } from "../core/exhaustive.ts";
|
|
22
20
|
import type { ModelSpec } from "./model.ts";
|
|
21
|
+
import { providerAccess } from "./provider-access.ts";
|
|
22
|
+
import { SOURCE_PROVIDERS } from "./model-data.ts";
|
|
23
23
|
import {
|
|
24
24
|
refreshOAuthCredential,
|
|
25
25
|
type OAuthCredential,
|
|
@@ -40,12 +40,18 @@ export interface AuthFile {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export function storedIsOAuth(stored: AuthFile, provider: string): boolean {
|
|
43
|
-
const entry = stored
|
|
43
|
+
const entry = storedCredential(stored, provider);
|
|
44
44
|
return entry !== undefined && entry.type === "oauth";
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
function storedCredential(stored: AuthFile, provider: string): StoredCredential | undefined {
|
|
48
|
+
// Earlier xeno stored Codex subscriptions under openai. Only OAuth gets this read-compatible alias;
|
|
49
|
+
// API keys and explicit native records retain their own provider and billing account.
|
|
50
|
+
return stored[provider] ?? (provider === "openai-codex" && stored["openai"]?.type === "oauth" ? stored["openai"] : undefined);
|
|
51
|
+
}
|
|
52
|
+
|
|
47
53
|
export type Resolved =
|
|
48
|
-
| { ok: true; key: string; source: "flag" | "auth.json" | "environment" }
|
|
54
|
+
| { ok: true; key: string; source: "flag" | "auth.json" | "models.json" | "environment" | "ambient" }
|
|
49
55
|
| { ok: false; reason: string };
|
|
50
56
|
|
|
51
57
|
/** The two effects resolution needs, injected so the rules stay testable. */
|
|
@@ -127,21 +133,78 @@ export function resolveKeyValue(
|
|
|
127
133
|
}
|
|
128
134
|
}
|
|
129
135
|
|
|
136
|
+
/** Per-provider variables resolve lazily, so order cannot change their meaning or mutate process.env. */
|
|
137
|
+
export function providerEnvironment(spec: ModelSpec, fx: ResolveEffects): { env: Record<string, string>; effects: ResolveEffects } {
|
|
138
|
+
const configured = providerAccess(spec).env ?? {};
|
|
139
|
+
const env: Record<string, string> = {};
|
|
140
|
+
const resolving = new Set<string>();
|
|
141
|
+
const lookup = (name: string): string | undefined => {
|
|
142
|
+
if (Object.hasOwn(env, name)) return env[name];
|
|
143
|
+
const raw = configured[name];
|
|
144
|
+
if (raw === undefined) return fx.env(name);
|
|
145
|
+
if (resolving.has(name)) throw new Error(`${spec.provider} env.${name}: cyclic variable reference`);
|
|
146
|
+
resolving.add(name);
|
|
147
|
+
try {
|
|
148
|
+
const got = resolveKeyValue(raw, { ...fx, env: lookup });
|
|
149
|
+
if (!got.ok) throw new Error(`${spec.provider} env.${name}: could not resolve configured value`);
|
|
150
|
+
env[name] = got.key;
|
|
151
|
+
return got.key;
|
|
152
|
+
} finally { resolving.delete(name); }
|
|
153
|
+
};
|
|
154
|
+
for (const name of Object.keys(configured)) lookup(name);
|
|
155
|
+
const aliases: Record<string, readonly string[]> = {
|
|
156
|
+
AZURE_OPENAI_API_KEY: ["AZURE_API_KEY"], AZURE_OPENAI_RESOURCE_NAME: ["AZURE_RESOURCE_NAME"], AZURE_OPENAI_BASE_URL: ["AZURE_BASE_URL"],
|
|
157
|
+
GOOGLE_CLOUD_PROJECT: ["GOOGLE_VERTEX_PROJECT", "GCLOUD_PROJECT"], GOOGLE_CLOUD_LOCATION: ["GOOGLE_VERTEX_LOCATION"],
|
|
158
|
+
};
|
|
159
|
+
for (const [target, sources] of Object.entries(aliases)) {
|
|
160
|
+
const value = lookup(target) ?? sources.map(lookup).find(Boolean);
|
|
161
|
+
if (value) env[target] = value;
|
|
162
|
+
}
|
|
163
|
+
return { env, effects: { ...fx, env: lookup } };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function configuredEffects(spec: ModelSpec, fx: ResolveEffects): { ok: true; effects: ResolveEffects } | { ok: false; reason: string } {
|
|
167
|
+
if (providerAccess(spec).invalid) return { ok: false, reason: providerAccess(spec).invalid! };
|
|
168
|
+
try {
|
|
169
|
+
const scoped = providerEnvironment(spec, fx).effects;
|
|
170
|
+
const access = providerAccess(spec);
|
|
171
|
+
if (["google-generative-ai", "google-vertex"].includes(spec.api) && (access.auth === false || access.authHeader === false || typeof access.authHeader === "string")) {
|
|
172
|
+
return { ok: false, reason: `${spec.api} owns API-key authentication; use apiKey or an OpenAI-compatible local endpoint` };
|
|
173
|
+
}
|
|
174
|
+
if (!spec.baseUrl && !["anthropic-messages", "google-generative-ai", "google-vertex", "bedrock-converse-stream", "azure-openai-responses"].includes(spec.api)) {
|
|
175
|
+
return { ok: false, reason: `${spec.provider}: configure baseUrl for ${spec.api}` };
|
|
176
|
+
}
|
|
177
|
+
if (spec.baseUrl?.includes("$")) {
|
|
178
|
+
const endpoint = resolveKeyValue(spec.baseUrl, scoped);
|
|
179
|
+
if (!endpoint.ok) return { ok: false, reason: `${spec.provider} baseUrl: ${endpoint.reason}` };
|
|
180
|
+
}
|
|
181
|
+
if (spec.api === "azure-openai-responses" && !spec.baseUrl && !scoped.env("AZURE_OPENAI_BASE_URL") && !scoped.env("AZURE_OPENAI_RESOURCE_NAME")) {
|
|
182
|
+
return { ok: false, reason: `${spec.provider}: configure baseUrl or AZURE_OPENAI_RESOURCE_NAME` };
|
|
183
|
+
}
|
|
184
|
+
return { ok: true, effects: scoped };
|
|
185
|
+
} catch (err) { return { ok: false, reason: err instanceof Error ? err.message : "could not resolve provider environment" }; }
|
|
186
|
+
}
|
|
187
|
+
|
|
130
188
|
export function resolveCredential(
|
|
131
189
|
spec: ModelSpec,
|
|
132
190
|
stored: AuthFile,
|
|
133
191
|
flagKey: string | undefined,
|
|
134
192
|
fx: ResolveEffects,
|
|
135
193
|
): Resolved {
|
|
194
|
+
const access = providerAccess(spec);
|
|
195
|
+
const configured = configuredEffects(spec, fx);
|
|
196
|
+
if (!configured.ok) return configured;
|
|
197
|
+
fx = configured.effects;
|
|
136
198
|
if (flagKey !== undefined && flagKey.length > 0) {
|
|
137
199
|
const got = resolveKeyValue(flagKey, fx);
|
|
138
200
|
return got.ok ? { ok: true, key: got.key, source: "flag" } : got;
|
|
139
201
|
}
|
|
140
202
|
|
|
141
|
-
const entry = stored
|
|
203
|
+
const entry = storedCredential(stored, spec.provider);
|
|
142
204
|
if (entry !== undefined) {
|
|
143
205
|
// Deliberately no fallback to the environment on failure. See the header.
|
|
144
206
|
if (entry.type === "oauth") {
|
|
207
|
+
if (spec.provider === "openai" && spec.api !== "openai-codex-responses") return { ok: false, reason: "ChatGPT OAuth belongs to openai-codex; choose an openai-codex model or configure an OpenAI API key" };
|
|
145
208
|
return { ok: true, key: entry.access, source: "auth.json" };
|
|
146
209
|
}
|
|
147
210
|
if (entry.type === "api_key") {
|
|
@@ -153,13 +216,33 @@ export function resolveCredential(
|
|
|
153
216
|
return assertNever(entry, "stored credential");
|
|
154
217
|
}
|
|
155
218
|
|
|
156
|
-
|
|
219
|
+
if (access.apiKey !== undefined) {
|
|
220
|
+
const got = resolveKeyValue(access.apiKey, fx);
|
|
221
|
+
return got.ok ? { ok: true, key: got.key, source: "models.json" }
|
|
222
|
+
: { ok: false, reason: `${spec.provider} in models.json: could not resolve apiKey` };
|
|
223
|
+
}
|
|
224
|
+
if (access.auth === false || access.authHeader === false) return { ok: true, key: "", source: "models.json" };
|
|
225
|
+
if (access.auth === "ambient") {
|
|
226
|
+
if (spec.api !== "bedrock-converse-stream" && spec.api !== "google-vertex") return { ok: false, reason: `${spec.api} does not support ambient credentials` };
|
|
227
|
+
if (spec.api === "google-vertex" && (!fx.env("GOOGLE_CLOUD_PROJECT") || !fx.env("GOOGLE_CLOUD_LOCATION"))) return { ok: false, reason: `${spec.provider}: ambient Vertex authentication requires GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION` };
|
|
228
|
+
return { ok: true, key: "", source: "ambient" };
|
|
229
|
+
}
|
|
230
|
+
for (const variable of envVarsOf(spec.provider, spec.credentialEnv)) {
|
|
157
231
|
const value = fx.env(variable);
|
|
158
232
|
if (value !== undefined && value.length > 0) {
|
|
159
233
|
return { ok: true, key: value, source: "environment" };
|
|
160
234
|
}
|
|
161
235
|
}
|
|
162
236
|
|
|
237
|
+
if (spec.api === "bedrock-converse-stream" && (fx.env("AWS_PROFILE")
|
|
238
|
+
|| (fx.env("AWS_ACCESS_KEY_ID") && fx.env("AWS_SECRET_ACCESS_KEY"))
|
|
239
|
+
|| fx.env("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") || fx.env("AWS_CONTAINER_CREDENTIALS_FULL_URI") || fx.env("AWS_WEB_IDENTITY_TOKEN_FILE"))) {
|
|
240
|
+
return { ok: true, key: "", source: "ambient" };
|
|
241
|
+
}
|
|
242
|
+
if (spec.api === "google-vertex" && fx.env("GOOGLE_APPLICATION_CREDENTIALS")
|
|
243
|
+
&& (fx.env("GOOGLE_CLOUD_PROJECT") || fx.env("GOOGLE_VERTEX_PROJECT") || fx.env("GCLOUD_PROJECT"))
|
|
244
|
+
&& (fx.env("GOOGLE_CLOUD_LOCATION") || fx.env("GOOGLE_VERTEX_LOCATION"))) return { ok: true, key: "", source: "ambient" };
|
|
245
|
+
|
|
163
246
|
return { ok: false, reason: describeMissingCredential(spec.provider) };
|
|
164
247
|
}
|
|
165
248
|
|
|
@@ -180,14 +263,32 @@ export const ENV_VAR: Readonly<Record<string, string | readonly string[]>> = {
|
|
|
180
263
|
zai: "ZHIPU_API_KEY",
|
|
181
264
|
moonshotai: "MOONSHOT_API_KEY",
|
|
182
265
|
together: "TOGETHER_API_KEY",
|
|
266
|
+
"amazon-bedrock": "AWS_BEARER_TOKEN_BEDROCK",
|
|
267
|
+
"azure-openai-responses": ["AZURE_OPENAI_API_KEY", "AZURE_API_KEY"],
|
|
268
|
+
"google-vertex": "GOOGLE_CLOUD_API_KEY",
|
|
269
|
+
"kimi-coding": ["KIMI_CODING_API_KEY", "KIMI_API_KEY"],
|
|
270
|
+
"openai-codex": "OPENAI_CODEX_ACCESS_TOKEN",
|
|
271
|
+
"ant-ling": "ANT_LING_API_KEY",
|
|
272
|
+
"qwen-token-plan": "QWEN_TOKEN_PLAN_API_KEY",
|
|
273
|
+
"qwen-token-plan-cn": "QWEN_TOKEN_PLAN_CN_API_KEY",
|
|
274
|
+
"qwen-token-plan-individual": "QWEN_TOKEN_PLAN_API_KEY",
|
|
275
|
+
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
|
|
276
|
+
"zai-coding-cn": "ZAI_CODING_CN_API_KEY",
|
|
277
|
+
"fireworks": "FIREWORKS_API_KEY",
|
|
278
|
+
"typesafe": "TYPESAFE_API_KEY",
|
|
279
|
+
"radius": "RADIUS_API_KEY",
|
|
280
|
+
"opencode-go": "OPENCODE_API_KEY",
|
|
183
281
|
};
|
|
184
282
|
|
|
185
|
-
export function envVarsOf(provider: string): readonly string[] {
|
|
283
|
+
export function envVarsOf(provider: string, additional: readonly string[] = []): readonly string[] {
|
|
186
284
|
const v = ENV_VAR[provider];
|
|
187
285
|
// A provider name is free text in a user catalog; an environment variable
|
|
188
286
|
// is not. `kimi-coding` must become KIMI_CODING, or no shell can set it.
|
|
189
|
-
|
|
190
|
-
|
|
287
|
+
const declared = [...additional, ...(SOURCE_PROVIDERS[provider]?.env ?? [])].filter(name => /(?:API_KEY|TOKEN|_KEY)$/.test(name)
|
|
288
|
+
&& !/(?:SECRET_ACCESS_KEY|ACCESS_KEY_ID|RESOURCE|PROJECT|LOCATION)/.test(name));
|
|
289
|
+
const known = typeof v === "string" ? [v] : v ?? [];
|
|
290
|
+
const names = [...new Set([...known, ...declared])];
|
|
291
|
+
return names.length ? names : [`${provider.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`];
|
|
191
292
|
}
|
|
192
293
|
|
|
193
294
|
export function envLabel(provider: string): string {
|
|
@@ -227,11 +328,15 @@ export async function ensureFreshCredential(
|
|
|
227
328
|
fx: ResolveEffects,
|
|
228
329
|
oauth: FreshEffects,
|
|
229
330
|
): Promise<Resolved> {
|
|
331
|
+
const configured = configuredEffects(spec, fx);
|
|
332
|
+
if (!configured.ok) return configured;
|
|
230
333
|
if (flagKey !== undefined && flagKey.length > 0) {
|
|
231
334
|
return resolveCredential(spec, stored, flagKey, fx);
|
|
232
335
|
}
|
|
233
|
-
const entry = stored
|
|
336
|
+
const entry = storedCredential(stored, spec.provider);
|
|
234
337
|
if (entry !== undefined && entry.type === "oauth") {
|
|
338
|
+
const resolved = resolveCredential(spec, stored, flagKey, fx);
|
|
339
|
+
if (!resolved.ok) return resolved;
|
|
235
340
|
if (oauth.now() < entry.expires) {
|
|
236
341
|
return { ok: true, key: entry.access, source: "auth.json" };
|
|
237
342
|
}
|
package/src/runtime/catalog.ts
CHANGED
|
@@ -15,11 +15,17 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { assertNever } from "../core/exhaustive.ts";
|
|
18
|
+
import { modelKey } from "../core/session-position.ts";
|
|
19
|
+
import { mergeCatalogs } from "../core/catalog-merge.ts";
|
|
20
|
+
import { parseModelConfiguration } from "./model-config.ts";
|
|
18
21
|
import type { Settings } from "../core/settings.ts";
|
|
19
|
-
import { CATALOG,
|
|
22
|
+
import { CATALOG, CURATED_CATALOG, type ModelSpec } from "./model.ts";
|
|
20
23
|
import { AnthropicProvider, type Provider, type ProviderConfig } from "./provider.ts";
|
|
21
24
|
import { OpenAICompletionsProvider } from "./openai.ts";
|
|
22
25
|
import { OpenAIResponsesProvider } from "./openai-responses.ts";
|
|
26
|
+
import { PiProvider } from "./pi-provider.ts";
|
|
27
|
+
import { copyProviderAccess, hasProviderAccess } from "./provider-access.ts";
|
|
28
|
+
import type { ResolveEffects } from "./auth.ts";
|
|
23
29
|
|
|
24
30
|
export type { Settings };
|
|
25
31
|
|
|
@@ -36,149 +42,39 @@ export type CatalogFetch = (input: RequestInfo | URL, init?: RequestInit) => Pro
|
|
|
36
42
|
* startup-path test can inject one and assert it was never called — a default
|
|
37
43
|
* `refresh: false` flag would not prove that.
|
|
38
44
|
*/
|
|
39
|
-
export function loadCatalog(opts: {
|
|
40
|
-
userText: string | null;
|
|
41
|
-
fetch?: CatalogFetch;
|
|
42
|
-
}): LoadedCatalog {
|
|
45
|
+
export function loadCatalog(opts: { userText: string | null; cacheText?: string | null; readWarnings?: readonly string[]; fetch?: CatalogFetch }): LoadedCatalog {
|
|
43
46
|
void opts.fetch;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const byId = new Map<string, ModelSpec>();
|
|
66
|
-
for (const spec of builtin) byId.set(spec.id, spec);
|
|
67
|
-
const extra: ModelSpec[] = [];
|
|
68
|
-
for (const spec of user) {
|
|
69
|
-
if (byId.has(spec.id)) byId.set(spec.id, spec);
|
|
70
|
-
else extra.push(spec);
|
|
71
|
-
}
|
|
72
|
-
return [...byId.values(), ...extra];
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function parseUserCatalog(json: unknown): { models: ModelSpec[]; errors: string[] } {
|
|
76
|
-
const rows = catalogRows(json);
|
|
77
|
-
if (rows.ok === false) return { models: [], errors: [rows.error] };
|
|
78
|
-
const models: ModelSpec[] = [];
|
|
79
|
-
const errors: string[] = [];
|
|
80
|
-
for (const [i, row] of rows.rows.entries()) {
|
|
81
|
-
const parsed = parseUserEntry(row, i);
|
|
82
|
-
if (parsed.ok) models.push(parsed.spec);
|
|
83
|
-
else errors.push(parsed.error);
|
|
84
|
-
}
|
|
85
|
-
return { models, errors };
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function catalogRows(json: unknown): { ok: true; rows: unknown[] } | { ok: false; error: string } {
|
|
89
|
-
if (Array.isArray(json)) return { ok: true, rows: json };
|
|
90
|
-
if (typeof json === "object" && json !== null && "models" in json) {
|
|
91
|
-
const models = (json as { models: unknown }).models;
|
|
92
|
-
if (Array.isArray(models)) return { ok: true, rows: models };
|
|
93
|
-
return { ok: false, error: "user catalog .models is not an array" };
|
|
94
|
-
}
|
|
95
|
-
return { ok: false, error: "user catalog must be an array or { models: [] }" };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function parseUserEntry(
|
|
99
|
-
row: unknown,
|
|
100
|
-
index: number,
|
|
101
|
-
): { ok: true; spec: ModelSpec } | { ok: false; error: string } {
|
|
102
|
-
const where = `entry ${index + 1}`;
|
|
103
|
-
if (typeof row !== "object" || row === null || Array.isArray(row)) {
|
|
104
|
-
return { ok: false, error: `${where} is not an object` };
|
|
105
|
-
}
|
|
106
|
-
const raw = row as Record<string, unknown>;
|
|
107
|
-
const id = typeof raw["id"] === "string" && raw["id"].length > 0 ? raw["id"] : null;
|
|
108
|
-
const label = id ?? where;
|
|
109
|
-
if (id === null) return { ok: false, error: `${where} has no id` };
|
|
110
|
-
if (!isApi(raw["api"])) {
|
|
111
|
-
return { ok: false, error: `${label} has an unknown or missing api` };
|
|
112
|
-
}
|
|
113
|
-
if (typeof raw["provider"] !== "string" || raw["provider"].length === 0) {
|
|
114
|
-
return { ok: false, error: `${label} has no provider` };
|
|
47
|
+
const warnings: string[] = [...(opts.readWarnings ?? [])];
|
|
48
|
+
let models = [...CATALOG];
|
|
49
|
+
if (opts.cacheText?.trim()) {
|
|
50
|
+
try {
|
|
51
|
+
const parsed = parseModelConfiguration(JSON.parse(opts.cacheText));
|
|
52
|
+
const curated = new Map(CURATED_CATALOG.map(spec => [modelKey(spec), spec]));
|
|
53
|
+
const cached = parsed.models.map(spec => {
|
|
54
|
+
const known = curated.get(modelKey(spec));
|
|
55
|
+
if (!known) return spec;
|
|
56
|
+
// Refresh metadata without replacing already verified wire dialects for existing sessions.
|
|
57
|
+
const next = { ...spec, api: known.api, reasoning: known.reasoning };
|
|
58
|
+
delete next.driver; delete next.compat; delete next.nativeCompat; delete next.thinkingLevelMap;
|
|
59
|
+
if (known.compat) next.compat = known.compat;
|
|
60
|
+
if (known.thinkingLevelMap) next.thinkingLevelMap = known.thinkingLevelMap;
|
|
61
|
+
if (known.baseUrl !== undefined) next.baseUrl = known.baseUrl;
|
|
62
|
+
copyProviderAccess(spec, next);
|
|
63
|
+
return next;
|
|
64
|
+
});
|
|
65
|
+
models = mergeCatalogs(models, cached);
|
|
66
|
+
warnings.push(...parsed.errors.map(message => "catalog cache: " + message));
|
|
67
|
+
} catch { warnings.push("catalog cache is not valid JSON; using bundled models"); }
|
|
115
68
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return { ok: false, error: `${label} has an invalid thinkingLevelMap (keys low|medium|high|xhigh|max, values string or null)` };
|
|
69
|
+
if (opts.userText?.trim()) {
|
|
70
|
+
try { const parsed = parseModelConfiguration(JSON.parse(opts.userText), models); models = parsed.models; warnings.push(...parsed.errors); }
|
|
71
|
+
catch { warnings.push("user catalog is not valid JSON; using bundled/cache models"); }
|
|
120
72
|
}
|
|
121
|
-
|
|
122
|
-
id,
|
|
123
|
-
name: typeof raw["name"] === "string" && raw["name"].length > 0 ? raw["name"] : id,
|
|
124
|
-
api: raw["api"],
|
|
125
|
-
provider: raw["provider"],
|
|
126
|
-
reasoning: raw["reasoning"] === true,
|
|
127
|
-
...(typeof raw["baseUrl"] === "string" ? { baseUrl: raw["baseUrl"] } : {}),
|
|
128
|
-
...(isCacheKind(raw["cache"]) ? { cache: raw["cache"] } : {}),
|
|
129
|
-
...(isCacheRates(raw["rates"]) ? { rates: raw["rates"] } : {}),
|
|
130
|
-
...(isPrice(raw["price"]) ? { price: raw["price"] } : {}),
|
|
131
|
-
...(typeof raw["contextWindow"] === "number" ? { contextWindow: raw["contextWindow"] } : {}),
|
|
132
|
-
...(typeof raw["maxTokens"] === "number" ? { maxTokens: raw["maxTokens"] } : {}),
|
|
133
|
-
...(isCompat(raw["compat"]) ? { compat: raw["compat"] } : {}),
|
|
134
|
-
...(isEffortMap(raw["thinkingLevelMap"]) ? { thinkingLevelMap: raw["thinkingLevelMap"] } : {}),
|
|
135
|
-
};
|
|
136
|
-
return { ok: true, spec };
|
|
73
|
+
return { models, warnings };
|
|
137
74
|
}
|
|
138
75
|
|
|
139
|
-
|
|
140
|
-
function
|
|
141
|
-
if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
|
|
142
|
-
return Object.entries(v).every(([key, value]) => EFFORTS.includes(key) && (value === null || typeof value === "string"));
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function isApi(v: unknown): v is Api {
|
|
146
|
-
return v === "anthropic-messages" || v === "openai-completions" || v === "openai-responses";
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function isCacheKind(v: unknown): v is CacheKind {
|
|
150
|
-
return v === "explicit" || v === "automatic";
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function isCacheRates(v: unknown): v is CacheRates {
|
|
154
|
-
if (typeof v !== "object" || v === null) return false;
|
|
155
|
-
const o = v as Record<string, unknown>;
|
|
156
|
-
return typeof o["read"] === "number" && typeof o["write"] === "number";
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function isPrice(v: unknown): v is Price {
|
|
160
|
-
if (typeof v !== "object" || v === null) return false;
|
|
161
|
-
const o = v as Record<string, unknown>;
|
|
162
|
-
if (typeof o["input"] !== "number" || typeof o["output"] !== "number") return false;
|
|
163
|
-
// Cache slots may be absent. A present non-number is a bad entry, not
|
|
164
|
-
// "unknown cache" — that would let `{cacheRead: "free"}` through.
|
|
165
|
-
if ("cacheRead" in o && typeof o["cacheRead"] !== "number") return false;
|
|
166
|
-
if ("cacheWrite" in o && typeof o["cacheWrite"] !== "number") return false;
|
|
167
|
-
return true;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function isCompat(v: unknown): v is Compat {
|
|
171
|
-
if (typeof v !== "object" || v === null) return false;
|
|
172
|
-
const o = v as Record<string, unknown>;
|
|
173
|
-
return (
|
|
174
|
-
typeof o["supportsDeveloperRole"] === "boolean" &&
|
|
175
|
-
(o["maxTokensField"] === "max_tokens" || o["maxTokensField"] === "max_completion_tokens") &&
|
|
176
|
-
typeof o["supportsReasoningEffort"] === "boolean" &&
|
|
177
|
-
typeof o["supportsUsageInStreaming"] === "boolean" &&
|
|
178
|
-
typeof o["requiresReasoningContentOnAssistantMessages"] === "boolean" &&
|
|
179
|
-
(o["thinkingFormat"] === "openai" || o["thinkingFormat"] === "deepseek")
|
|
180
|
-
);
|
|
181
|
-
}
|
|
76
|
+
export function overlayCatalog(builtin: readonly ModelSpec[], user: readonly ModelSpec[]): ModelSpec[] { return mergeCatalogs(builtin, user); }
|
|
77
|
+
export function parseUserCatalog(json: unknown): { models: ModelSpec[]; errors: string[] } { return parseModelConfiguration(json); }
|
|
182
78
|
|
|
183
79
|
export function findModel(id: string, catalog: readonly ModelSpec[] = CATALOG): ModelSpec | undefined {
|
|
184
80
|
return catalog.find((m) => m.id === id);
|
|
@@ -187,7 +83,7 @@ export function findModel(id: string, catalog: readonly ModelSpec[] = CATALOG):
|
|
|
187
83
|
/**
|
|
188
84
|
* Resolve a typed pattern to one catalog entry.
|
|
189
85
|
*
|
|
190
|
-
* Order is
|
|
86
|
+
* Order is `provider/id`, then exact id, then substring, then subsequence
|
|
191
87
|
* ("fuzzy"). Each tier is tried only if the one above it matched nothing,
|
|
192
88
|
* so a unique exact id cannot be displaced by a looser match. Several
|
|
193
89
|
* hits at the winning tier is an error that lists the candidates --
|
|
@@ -208,12 +104,12 @@ export function findModelByPattern(
|
|
|
208
104
|
return { ok: false, reason: "empty model pattern", candidates: [] };
|
|
209
105
|
}
|
|
210
106
|
|
|
211
|
-
const exactId = catalog.filter((m) => m.id.toLowerCase() === needle);
|
|
212
|
-
if (exactId.length > 0) return reportPattern(pattern, exactId);
|
|
213
|
-
|
|
214
107
|
const byRef = catalog.filter((m) => `${m.provider}/${m.id}`.toLowerCase() === needle);
|
|
215
108
|
if (byRef.length > 0) return reportPattern(pattern, byRef);
|
|
216
109
|
|
|
110
|
+
const exactId = catalog.filter((m) => m.id.toLowerCase() === needle);
|
|
111
|
+
if (exactId.length > 0) return reportPattern(pattern, exactId);
|
|
112
|
+
|
|
217
113
|
const substring = catalog.filter((m) => haystacks(m).some((h) => h.includes(needle)));
|
|
218
114
|
if (substring.length > 0) return reportPattern(pattern, substring);
|
|
219
115
|
|
|
@@ -245,7 +141,7 @@ function reportPattern(pattern: string, matches: readonly ModelSpec[]): ModelPat
|
|
|
245
141
|
if (matches.length === 0) {
|
|
246
142
|
return { ok: false, reason: `unknown model: ${pattern}. Try --list-models`, candidates: [] };
|
|
247
143
|
}
|
|
248
|
-
const ids = matches.map(
|
|
144
|
+
const ids = matches.map(modelKey);
|
|
249
145
|
return {
|
|
250
146
|
ok: false,
|
|
251
147
|
reason:
|
|
@@ -302,11 +198,9 @@ export function selectModel(
|
|
|
302
198
|
catalog: readonly ModelSpec[] = CATALOG,
|
|
303
199
|
): Selection {
|
|
304
200
|
if (flags.model !== undefined && flags.model.length > 0) {
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
309
|
-
return { ok: true, spec };
|
|
201
|
+
const candidates = flags.provider ? modelsOfProvider(flags.provider, catalog) : catalog;
|
|
202
|
+
const match = findModelByPattern(flags.model, candidates);
|
|
203
|
+
return match.ok ? { ok: true, spec: match.spec } : { ok: false, reason: `${match.reason}. Try --list-models` };
|
|
310
204
|
}
|
|
311
205
|
|
|
312
206
|
if (flags.provider !== undefined && flags.provider.length > 0) {
|
|
@@ -318,7 +212,9 @@ export function selectModel(
|
|
|
318
212
|
}
|
|
319
213
|
|
|
320
214
|
if (settings.defaultModel !== undefined) {
|
|
321
|
-
const
|
|
215
|
+
const candidates = settings.defaultProvider ? catalog.filter(model => model.provider === settings.defaultProvider) : catalog;
|
|
216
|
+
const found = findModelByPattern(settings.defaultModel, candidates);
|
|
217
|
+
const spec = found.ok ? found.spec : undefined;
|
|
322
218
|
if (spec !== undefined) return { ok: true, spec };
|
|
323
219
|
// Not fatal: a settings file naming a model this build dropped should
|
|
324
220
|
// degrade to the normal search, not refuse to start.
|
|
@@ -346,9 +242,13 @@ export function createProvider(
|
|
|
346
242
|
config: ProviderConfig,
|
|
347
243
|
apiKey: string,
|
|
348
244
|
onRetry?: (attempt: number, delayMs: number, error: unknown) => void,
|
|
349
|
-
opts?: { oauth?: boolean },
|
|
245
|
+
opts?: { oauth?: boolean; fetch?: typeof fetch; effects?: ResolveEffects },
|
|
350
246
|
): Provider {
|
|
351
247
|
const oauth = opts?.oauth === true;
|
|
248
|
+
if (config.spec.driver === "pi" || hasProviderAccess(config.spec)) return new PiProvider(config, apiKey, {
|
|
249
|
+
oauth,
|
|
250
|
+
...(onRetry ? { onRetry } : {}), ...(opts?.fetch ? { fetch: opts.fetch } : {}), ...(opts?.effects ? { effects: opts.effects } : {}),
|
|
251
|
+
});
|
|
352
252
|
switch (config.spec.api) {
|
|
353
253
|
case "anthropic-messages":
|
|
354
254
|
return new AnthropicProvider(config, apiKey.length > 0 ? apiKey : undefined, {
|
|
@@ -359,6 +259,9 @@ export function createProvider(
|
|
|
359
259
|
return new OpenAICompletionsProvider(config, apiKey, { ...(onRetry ? { onRetry } : {}) });
|
|
360
260
|
case "openai-responses":
|
|
361
261
|
return new OpenAIResponsesProvider(config, apiKey, { ...(onRetry ? { onRetry } : {}) });
|
|
262
|
+
case "azure-openai-responses": case "openai-codex-responses": case "google-generative-ai": case "google-vertex":
|
|
263
|
+
case "bedrock-converse-stream": case "mistral-conversations": case "pi-messages":
|
|
264
|
+
return new PiProvider(config, apiKey, { ...(onRetry ? { onRetry } : {}), ...(opts?.fetch ? { fetch: opts.fetch } : {}), ...(opts?.effects ? { effects: opts.effects } : {}) });
|
|
362
265
|
default:
|
|
363
266
|
return assertNever(config.spec.api, "createProvider");
|
|
364
267
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** Pi's files → image → text precedence, with native-helper and OS-command fallbacks (MIT, 02eed88). */
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { getNativeClipboard, type NativeClipboard } from "../vendor/pi-tui/native-platform.ts";
|
|
4
|
+
import { runClipboardCommand } from "../vendor/pi-rich/clipboard-command.ts";
|
|
5
|
+
|
|
6
|
+
export type ClipboardContent = { files: string[] } | { image: Uint8Array } | { text: string } | null;
|
|
7
|
+
type Run = (command: string, args: readonly string[], options?: { input?: string; timeoutMs?: number }) => Promise<Buffer | undefined>;
|
|
8
|
+
export interface ClipboardOptions {
|
|
9
|
+
platform?: NodeJS.Platform;
|
|
10
|
+
env?: NodeJS.ProcessEnv;
|
|
11
|
+
native?: NativeClipboard | null;
|
|
12
|
+
run?: Run;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Text is transported as data. Clipboard contents must never become PowerShell or shell source. */
|
|
16
|
+
export async function writeClipboard(text: string, options: ClipboardOptions = {}): Promise<boolean> {
|
|
17
|
+
const platform = options.platform ?? process.platform, env = options.env ?? process.env;
|
|
18
|
+
const native = options.native === undefined ? getNativeClipboard() : options.native, run = options.run ?? runClipboardCommand;
|
|
19
|
+
if (native?.setText) {
|
|
20
|
+
try { await native.setText(text); return true; } catch { /* Continue to the same platform's command transport. */ }
|
|
21
|
+
}
|
|
22
|
+
const windows = platform === "win32" || !!(env["WSL_DISTRO_NAME"] || env["WSL_INTEROP"]);
|
|
23
|
+
const commands: readonly (readonly [string, readonly string[]])[] = windows
|
|
24
|
+
? [["powershell.exe", ["-NoProfile", "-STA", "-Command", "Add-Type -AssemblyName System.Windows.Forms; [Console]::InputEncoding = [System.Text.Encoding]::UTF8; $t = [Console]::In.ReadToEnd(); if ($t.Length) { [System.Windows.Forms.Clipboard]::SetText($t) } else { [System.Windows.Forms.Clipboard]::Clear() }"]]]
|
|
25
|
+
: platform === "darwin" ? [["pbcopy", []]]
|
|
26
|
+
: env["TERMUX_VERSION"] ? [["termux-clipboard-set", []]]
|
|
27
|
+
: platform === "linux" ? [
|
|
28
|
+
...(env["WAYLAND_DISPLAY"] || env["XDG_SESSION_TYPE"] === "wayland" ? [["wl-copy", ["--type", "text/plain;charset=utf-8"]] as const] : []),
|
|
29
|
+
...(env["DISPLAY"] ? [["xclip", ["-selection", "clipboard"]] as const, ["xsel", ["--clipboard", "--input"]] as const] : []),
|
|
30
|
+
] : [];
|
|
31
|
+
for (const [command, args] of commands) {
|
|
32
|
+
try { if (await run(command, args, { input: text, timeoutMs: 5000 }) !== undefined) return true; } catch { /* Try a same-platform backend. */ }
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Fixed programs only. Clipboard content is data, never interpolated into a shell or script.
|
|
38
|
+
const MAC_READ = `ObjC.import('AppKit');
|
|
39
|
+
const p = $.NSPasteboard.generalPasteboard;
|
|
40
|
+
const urls = p.readObjectsForClassesOptions([$.NSURL], $({NSPasteboardURLReadingFileURLsOnlyKey: true}));
|
|
41
|
+
const files = [];
|
|
42
|
+
if (urls) for (let i = 0; i < urls.count; i++) files.push(ObjC.unwrap(urls.objectAtIndex(i).path));
|
|
43
|
+
if (files.length) JSON.stringify({files});
|
|
44
|
+
else {
|
|
45
|
+
const png = p.dataForType('public.png');
|
|
46
|
+
const tiff = png ? null : p.dataForType('public.tiff');
|
|
47
|
+
const data = png || (tiff ? $.NSBitmapImageRep.imageRepWithData(tiff).representationUsingTypeProperties($.NSPNGFileType, $({})) : null);
|
|
48
|
+
if (data) JSON.stringify({image: ObjC.unwrap(data.base64EncodedStringWithOptions(0))});
|
|
49
|
+
else JSON.stringify({text: ObjC.unwrap(p.stringForType($.NSPasteboardTypeString)) || ''});
|
|
50
|
+
}`;
|
|
51
|
+
const WINDOWS_READ = `Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing;
|
|
52
|
+
$c = [System.Windows.Forms.Clipboard];
|
|
53
|
+
if ($c::ContainsFileDropList()) { @{files = @($c::GetFileDropList())} | ConvertTo-Json -Compress }
|
|
54
|
+
elseif ($c::ContainsImage()) { $i = $c::GetImage(); $s = New-Object System.IO.MemoryStream; try { $i.Save($s, [System.Drawing.Imaging.ImageFormat]::Png); @{image = [Convert]::ToBase64String($s.ToArray())} | ConvertTo-Json -Compress } finally { $i.Dispose(); $s.Dispose() } }
|
|
55
|
+
else { @{text = $c::GetText()} | ConvertTo-Json -Compress }`;
|
|
56
|
+
|
|
57
|
+
function decode(bytes: Buffer | undefined): ClipboardContent | undefined {
|
|
58
|
+
if (bytes === undefined) return undefined;
|
|
59
|
+
const value = JSON.parse(bytes.toString("utf8")) as { files?: unknown; image?: unknown; text?: unknown };
|
|
60
|
+
if (Array.isArray(value.files) && value.files.every(p => typeof p === "string") && value.files.length) return { files: value.files };
|
|
61
|
+
if (typeof value.image === "string" && value.image) return { image: Buffer.from(value.image, "base64") };
|
|
62
|
+
return typeof value.text === "string" && value.text ? { text: value.text } : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function readClipboard(options: ClipboardOptions = {}): Promise<ClipboardContent> {
|
|
66
|
+
const platform = options.platform ?? process.platform, env = options.env ?? process.env;
|
|
67
|
+
const run = options.run ?? runClipboardCommand;
|
|
68
|
+
const native = options.native === undefined ? getNativeClipboard() : options.native;
|
|
69
|
+
const files = await native?.getFilePaths?.();
|
|
70
|
+
if (files?.length) return { files };
|
|
71
|
+
if (platform === "darwin" && !native) {
|
|
72
|
+
const result = decode(await run("osascript", ["-l", "JavaScript", "-e", MAC_READ]));
|
|
73
|
+
if (result !== undefined) return result;
|
|
74
|
+
}
|
|
75
|
+
const wsl = !!(env["WSL_DISTRO_NAME"] || env["WSL_INTEROP"]);
|
|
76
|
+
if (platform === "win32" || wsl) {
|
|
77
|
+
const result = decode(await run("powershell.exe", ["-NoProfile", "-STA", "-Command", WINDOWS_READ], { timeoutMs: 5000 }));
|
|
78
|
+
if (result !== undefined) return result;
|
|
79
|
+
}
|
|
80
|
+
if (platform === "linux" && !env["TERMUX_VERSION"]) {
|
|
81
|
+
const wayland = !!env["WAYLAND_DISPLAY"] || env["XDG_SESSION_TYPE"] === "wayland";
|
|
82
|
+
const backends = [...(wayland ? ["wayland"] : []), ...(env["DISPLAY"] ? ["x11"] : [])];
|
|
83
|
+
for (const backend of backends) {
|
|
84
|
+
const read = (type: string) => backend === "wayland" ? run("wl-paste", ["--type", type, "--no-newline"]) : run("xclip", ["-selection", "clipboard", "-t", type, "-o"]);
|
|
85
|
+
const available = backend === "wayland" ? await run("wl-paste", ["--list-types"], { timeoutMs: 1000 }) : await read("TARGETS");
|
|
86
|
+
if (available === undefined) continue;
|
|
87
|
+
const types = available.toString("utf8").trim().split(/\r?\n/);
|
|
88
|
+
if (types.includes("text/uri-list")) {
|
|
89
|
+
const bytes = await read("text/uri-list");
|
|
90
|
+
if (bytes === undefined) throw new Error("Clipboard file transfer failed");
|
|
91
|
+
const paths = bytes.toString("utf8").split(/\r?\n/).filter(p => p.startsWith("file://")).map(p => fileURLToPath(p));
|
|
92
|
+
if (paths.length) return { files: paths };
|
|
93
|
+
}
|
|
94
|
+
const type = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/bmp"].find(t => types.includes(t));
|
|
95
|
+
if (type) {
|
|
96
|
+
const bytes = await read(type);
|
|
97
|
+
if (bytes === undefined) throw new Error("Clipboard image transfer failed");
|
|
98
|
+
return bytes.length ? { image: bytes } : null;
|
|
99
|
+
}
|
|
100
|
+
const textType = ["text/plain;charset=utf-8", "UTF8_STRING", "text/plain", "text"].find(t => types.includes(t));
|
|
101
|
+
if (!textType) return null; // An empty Wayland selection must not reveal stale X11 contents.
|
|
102
|
+
const bytes = await read(textType);
|
|
103
|
+
if (bytes === undefined) throw new Error("Clipboard text transfer failed");
|
|
104
|
+
return bytes.length ? { text: bytes.toString("utf8") } : null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const image = await native?.getImage();
|
|
108
|
+
if (image?.length) return { image };
|
|
109
|
+
const text = await native?.getText();
|
|
110
|
+
if (text !== undefined) return text ? { text } : null;
|
|
111
|
+
const fallback = env["TERMUX_VERSION"] ? await run("termux-clipboard-get", []) : platform === "darwin" ? await run("pbpaste", []) : undefined;
|
|
112
|
+
if (fallback === undefined) throw new Error("Clipboard unavailable for this terminal session");
|
|
113
|
+
return fallback.length ? { text: fallback.toString("utf8") } : null;
|
|
114
|
+
}
|