@valni/cli-darwin-x64 0.0.0-stage → 0.1.2

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 (236) hide show
  1. package/LICENSE +53 -0
  2. package/package.json +18 -5
  3. package/runtime/LICENSE +53 -0
  4. package/runtime/README.md +674 -0
  5. package/runtime/assets/clankolas.png +0 -0
  6. package/runtime/docs/extensions.md +2910 -0
  7. package/runtime/docs/json.md +82 -0
  8. package/runtime/docs/models.md +540 -0
  9. package/runtime/docs/packages.md +228 -0
  10. package/runtime/docs/rpc.md +1480 -0
  11. package/runtime/docs/sdk.md +1183 -0
  12. package/runtime/docs/session-format.md +422 -0
  13. package/runtime/docs/settings.md +315 -0
  14. package/runtime/docs/shell-aliases.md +13 -0
  15. package/runtime/docs/terminal-setup.md +142 -0
  16. package/runtime/docs/termux.md +127 -0
  17. package/runtime/docs/tmux.md +63 -0
  18. package/runtime/docs/tui.md +927 -0
  19. package/runtime/docs/windows.md +17 -0
  20. package/runtime/examples/README.md +25 -0
  21. package/runtime/examples/extensions/README.md +212 -0
  22. package/runtime/examples/extensions/auto-commit-on-exit.ts +49 -0
  23. package/runtime/examples/extensions/bash-spawn-hook.ts +30 -0
  24. package/runtime/examples/extensions/bookmark.ts +50 -0
  25. package/runtime/examples/extensions/border-status-editor.ts +145 -0
  26. package/runtime/examples/extensions/built-in-tool-renderer.ts +249 -0
  27. package/runtime/examples/extensions/claude-rules.ts +86 -0
  28. package/runtime/examples/extensions/commands.ts +72 -0
  29. package/runtime/examples/extensions/confirm-destructive.ts +59 -0
  30. package/runtime/examples/extensions/custom-footer.ts +64 -0
  31. package/runtime/examples/extensions/custom-header.ts +73 -0
  32. package/runtime/examples/extensions/custom-provider-anthropic/index.ts +604 -0
  33. package/runtime/examples/extensions/custom-provider-anthropic/package-lock.json +24 -0
  34. package/runtime/examples/extensions/custom-provider-anthropic/package.json +19 -0
  35. package/runtime/examples/extensions/dirty-repo-guard.ts +56 -0
  36. package/runtime/examples/extensions/doom-overlay/README.md +46 -0
  37. package/runtime/examples/extensions/doom-overlay/doom/build/doom.js +21 -0
  38. package/runtime/examples/extensions/doom-overlay/doom/build/doom.wasm +0 -0
  39. package/runtime/examples/extensions/doom-overlay/doom/build.sh +152 -0
  40. package/runtime/examples/extensions/doom-overlay/doom/doomgeneric_pi.c +72 -0
  41. package/runtime/examples/extensions/doom-overlay/doom-component.ts +132 -0
  42. package/runtime/examples/extensions/doom-overlay/doom-engine.ts +173 -0
  43. package/runtime/examples/extensions/doom-overlay/doom-keys.ts +104 -0
  44. package/runtime/examples/extensions/doom-overlay/index.ts +74 -0
  45. package/runtime/examples/extensions/doom-overlay/wad-finder.ts +51 -0
  46. package/runtime/examples/extensions/dynamic-resources/SKILL.md +8 -0
  47. package/runtime/examples/extensions/dynamic-resources/dynamic.json +79 -0
  48. package/runtime/examples/extensions/dynamic-resources/dynamic.md +5 -0
  49. package/runtime/examples/extensions/dynamic-resources/index.ts +15 -0
  50. package/runtime/examples/extensions/dynamic-tools.ts +74 -0
  51. package/runtime/examples/extensions/entry-renderer.ts +41 -0
  52. package/runtime/examples/extensions/event-bus.ts +43 -0
  53. package/runtime/examples/extensions/file-trigger.ts +41 -0
  54. package/runtime/examples/extensions/git-checkpoint.ts +53 -0
  55. package/runtime/examples/extensions/git-merge-and-resolve.ts +115 -0
  56. package/runtime/examples/extensions/github-issue-autocomplete.ts +185 -0
  57. package/runtime/examples/extensions/gondolin/index.ts +531 -0
  58. package/runtime/examples/extensions/gondolin/package-lock.json +185 -0
  59. package/runtime/examples/extensions/gondolin/package.json +19 -0
  60. package/runtime/examples/extensions/hello.ts +26 -0
  61. package/runtime/examples/extensions/hidden-thinking-label.ts +53 -0
  62. package/runtime/examples/extensions/inline-bash.ts +94 -0
  63. package/runtime/examples/extensions/input-transform-streaming.ts +39 -0
  64. package/runtime/examples/extensions/input-transform.ts +43 -0
  65. package/runtime/examples/extensions/interactive-shell.ts +196 -0
  66. package/runtime/examples/extensions/mac-system-theme.ts +47 -0
  67. package/runtime/examples/extensions/message-renderer.ts +59 -0
  68. package/runtime/examples/extensions/minimal-mode.ts +426 -0
  69. package/runtime/examples/extensions/modal-editor.ts +85 -0
  70. package/runtime/examples/extensions/model-status.ts +31 -0
  71. package/runtime/examples/extensions/notify.ts +55 -0
  72. package/runtime/examples/extensions/overlay-qa-tests.ts +1450 -0
  73. package/runtime/examples/extensions/overlay-test.ts +153 -0
  74. package/runtime/examples/extensions/permission-gate.ts +34 -0
  75. package/runtime/examples/extensions/pirate.ts +47 -0
  76. package/runtime/examples/extensions/plan-mode/README.md +66 -0
  77. package/runtime/examples/extensions/plan-mode/index.ts +390 -0
  78. package/runtime/examples/extensions/plan-mode/utils.ts +168 -0
  79. package/runtime/examples/extensions/preset.ts +436 -0
  80. package/runtime/examples/extensions/project-trust.ts +64 -0
  81. package/runtime/examples/extensions/prompt-customizer.ts +97 -0
  82. package/runtime/examples/extensions/protected-paths.ts +30 -0
  83. package/runtime/examples/extensions/provider-payload.ts +18 -0
  84. package/runtime/examples/extensions/question.ts +278 -0
  85. package/runtime/examples/extensions/questionnaire.ts +440 -0
  86. package/runtime/examples/extensions/rainbow-editor.ts +88 -0
  87. package/runtime/examples/extensions/reload-runtime.ts +37 -0
  88. package/runtime/examples/extensions/rpc-demo.ts +118 -0
  89. package/runtime/examples/extensions/sandbox/index.ts +321 -0
  90. package/runtime/examples/extensions/sandbox/package-lock.json +92 -0
  91. package/runtime/examples/extensions/sandbox/package.json +19 -0
  92. package/runtime/examples/extensions/send-user-message.ts +97 -0
  93. package/runtime/examples/extensions/session-name.ts +27 -0
  94. package/runtime/examples/extensions/shutdown-command.ts +63 -0
  95. package/runtime/examples/extensions/snake.ts +343 -0
  96. package/runtime/examples/extensions/space-invaders.ts +560 -0
  97. package/runtime/examples/extensions/ssh.ts +220 -0
  98. package/runtime/examples/extensions/status-line.ts +32 -0
  99. package/runtime/examples/extensions/structured-output.ts +65 -0
  100. package/runtime/examples/extensions/subagent/README.md +175 -0
  101. package/runtime/examples/extensions/subagent/agents/planner.md +37 -0
  102. package/runtime/examples/extensions/subagent/agents/reviewer.md +35 -0
  103. package/runtime/examples/extensions/subagent/agents/scout.md +50 -0
  104. package/runtime/examples/extensions/subagent/agents/worker.md +24 -0
  105. package/runtime/examples/extensions/subagent/agents.ts +126 -0
  106. package/runtime/examples/extensions/subagent/index.ts +1009 -0
  107. package/runtime/examples/extensions/subagent/prompts/implement-and-review.md +10 -0
  108. package/runtime/examples/extensions/subagent/prompts/implement.md +10 -0
  109. package/runtime/examples/extensions/subagent/prompts/scout-and-plan.md +9 -0
  110. package/runtime/examples/extensions/system-prompt-header.ts +17 -0
  111. package/runtime/examples/extensions/tic-tac-toe.ts +1008 -0
  112. package/runtime/examples/extensions/timed-confirm.ts +70 -0
  113. package/runtime/examples/extensions/titlebar-spinner.ts +58 -0
  114. package/runtime/examples/extensions/todo.ts +297 -0
  115. package/runtime/examples/extensions/tool-override.ts +144 -0
  116. package/runtime/examples/extensions/tools.ts +146 -0
  117. package/runtime/examples/extensions/trigger-compact.ts +50 -0
  118. package/runtime/examples/extensions/truncated-tool.ts +195 -0
  119. package/runtime/examples/extensions/widget-placement.ts +9 -0
  120. package/runtime/examples/extensions/with-deps/index.ts +32 -0
  121. package/runtime/examples/extensions/with-deps/package-lock.json +31 -0
  122. package/runtime/examples/extensions/with-deps/package.json +22 -0
  123. package/runtime/examples/extensions/working-indicator.ts +123 -0
  124. package/runtime/examples/extensions/working-message-test.ts +25 -0
  125. package/runtime/examples/rpc-extension-ui.ts +632 -0
  126. package/runtime/examples/sdk/01-minimal.ts +26 -0
  127. package/runtime/examples/sdk/02-custom-model.ts +43 -0
  128. package/runtime/examples/sdk/03-custom-prompt.ts +70 -0
  129. package/runtime/examples/sdk/04-skills.ts +55 -0
  130. package/runtime/examples/sdk/05-tools.ts +48 -0
  131. package/runtime/examples/sdk/06-extensions.ts +94 -0
  132. package/runtime/examples/sdk/07-context-files.ts +42 -0
  133. package/runtime/examples/sdk/08-prompt-templates.ts +51 -0
  134. package/runtime/examples/sdk/09-api-keys-and-oauth.ts +34 -0
  135. package/runtime/examples/sdk/10-settings.ts +53 -0
  136. package/runtime/examples/sdk/11-sessions.ts +52 -0
  137. package/runtime/examples/sdk/12-full-control.ts +71 -0
  138. package/runtime/examples/sdk/13-session-runtime.ts +67 -0
  139. package/runtime/examples/sdk/README.md +140 -0
  140. package/runtime/export-html/ansi-to-html.js +249 -0
  141. package/runtime/export-html/index.js +226 -0
  142. package/runtime/export-html/template.css +1066 -0
  143. package/runtime/export-html/template.html +55 -0
  144. package/runtime/export-html/template.js +1864 -0
  145. package/runtime/export-html/tool-renderer.js +108 -0
  146. package/runtime/export-html/vendor/highlight.min.js +1213 -0
  147. package/runtime/export-html/vendor/marked.min.js +78 -0
  148. package/runtime/extensions/pi-switchboard/.github/workflows/ci.yml +51 -0
  149. package/runtime/extensions/pi-switchboard/LICENSE +22 -0
  150. package/runtime/extensions/pi-switchboard/README.md +85 -0
  151. package/runtime/extensions/pi-switchboard/extensions/switchboard/catalog.ts +183 -0
  152. package/runtime/extensions/pi-switchboard/extensions/switchboard/config.ts +97 -0
  153. package/runtime/extensions/pi-switchboard/extensions/switchboard/connections.ts +386 -0
  154. package/runtime/extensions/pi-switchboard/extensions/switchboard/constants.ts +12 -0
  155. package/runtime/extensions/pi-switchboard/extensions/switchboard/device-auth.ts +127 -0
  156. package/runtime/extensions/pi-switchboard/extensions/switchboard/envelope.ts +199 -0
  157. package/runtime/extensions/pi-switchboard/extensions/switchboard/errors.ts +180 -0
  158. package/runtime/extensions/pi-switchboard/extensions/switchboard/index.ts +125 -0
  159. package/runtime/extensions/pi-switchboard/extensions/switchboard/participant.ts +17 -0
  160. package/runtime/extensions/pi-switchboard/extensions/switchboard/sessionStream.ts +131 -0
  161. package/runtime/extensions/pi-switchboard/extensions/switchboard/toolProxy.ts +136 -0
  162. package/runtime/extensions/pi-switchboard/extensions/switchboard/tools.ts +16 -0
  163. package/runtime/extensions/pi-switchboard/extensions/switchboard/types.ts +37 -0
  164. package/runtime/extensions/pi-switchboard/package.json +30 -0
  165. package/runtime/extensions/pi-switchboard/tsconfig.json +13 -0
  166. package/runtime/extensions/switchboard-local/.github/workflows/ci.yml +80 -0
  167. package/runtime/extensions/switchboard-local/.github/workflows/release.yml +46 -0
  168. package/runtime/extensions/switchboard-local/README.md +98 -0
  169. package/runtime/extensions/switchboard-local/extensions/local/engine.ts +28 -0
  170. package/runtime/extensions/switchboard-local/extensions/local/engines/gguf.ts +324 -0
  171. package/runtime/extensions/switchboard-local/extensions/local/engines/llamaBuild.ts +62 -0
  172. package/runtime/extensions/switchboard-local/extensions/local/engines/mlx.ts +245 -0
  173. package/runtime/extensions/switchboard-local/extensions/local/engines/select.ts +38 -0
  174. package/runtime/extensions/switchboard-local/extensions/local/engines/support.ts +151 -0
  175. package/runtime/extensions/switchboard-local/extensions/local/hf.ts +106 -0
  176. package/runtime/extensions/switchboard-local/extensions/local/index.ts +67 -0
  177. package/runtime/extensions/switchboard-local/extensions/local/nativeStream.ts +602 -0
  178. package/runtime/extensions/switchboard-local/extensions/local/paths.ts +44 -0
  179. package/runtime/extensions/switchboard-local/extensions/local/provider.ts +57 -0
  180. package/runtime/extensions/switchboard-local/extensions/local/store.ts +82 -0
  181. package/runtime/extensions/switchboard-local/extensions/local/ui.ts +60 -0
  182. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard/dist/apiTypes.js +74 -0
  183. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard/dist/client.js +292 -0
  184. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard/dist/deviceAuth.js +155 -0
  185. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard/dist/errors.js +95 -0
  186. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard/dist/index.js +8 -0
  187. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard/dist/messages.js +253 -0
  188. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard/dist/models.js +19 -0
  189. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard/dist/native.js +886 -0
  190. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard/dist/wire.js +85 -0
  191. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard/package.json +39 -0
  192. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/package.json +29 -0
  193. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/anthropic.ts +127 -0
  194. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/google.ts +111 -0
  195. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/index.ts +8 -0
  196. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/modelRecord.test.ts +25 -0
  197. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/modelRecord.ts +57 -0
  198. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/openaiGeneric.ts +103 -0
  199. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/openaiPro.ts +89 -0
  200. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/profile.ts +120 -0
  201. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/routing.test.ts +82 -0
  202. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/routing.ts +74 -0
  203. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/validateConfig.google.test.ts +137 -0
  204. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/validateConfig.openai.test.ts +241 -0
  205. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/validateConfig.test.ts +174 -0
  206. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/validateConfig.ts +438 -0
  207. package/runtime/extensions/switchboard-local/package.json +34 -0
  208. package/runtime/extensions/switchboard-local/tsconfig.json +13 -0
  209. package/runtime/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
  210. package/runtime/node_modules/@mariozechner/clipboard/.yarnrc.yml +1 -0
  211. package/runtime/node_modules/@mariozechner/clipboard/Cargo.toml +45 -0
  212. package/runtime/node_modules/@mariozechner/clipboard/README.md +60 -0
  213. package/runtime/node_modules/@mariozechner/clipboard/build.rs +5 -0
  214. package/runtime/node_modules/@mariozechner/clipboard/clipboard.darwin-x64.node +0 -0
  215. package/runtime/node_modules/@mariozechner/clipboard/exp.ts +12 -0
  216. package/runtime/node_modules/@mariozechner/clipboard/index.js +606 -0
  217. package/runtime/node_modules/@mariozechner/clipboard/package.json +56 -0
  218. package/runtime/node_modules/@mariozechner/clipboard/src/clipboard_rs/common.rs +307 -0
  219. package/runtime/node_modules/@mariozechner/clipboard/src/clipboard_rs/mod.rs +93 -0
  220. package/runtime/node_modules/@mariozechner/clipboard/src/clipboard_rs/platform/ios.rs +313 -0
  221. package/runtime/node_modules/@mariozechner/clipboard/src/clipboard_rs/platform/macos.rs +471 -0
  222. package/runtime/node_modules/@mariozechner/clipboard/src/clipboard_rs/platform/mod.rs +252 -0
  223. package/runtime/node_modules/@mariozechner/clipboard/src/clipboard_rs/platform/wayland.rs +431 -0
  224. package/runtime/node_modules/@mariozechner/clipboard/src/clipboard_rs/platform/win.rs +712 -0
  225. package/runtime/node_modules/@mariozechner/clipboard/src/clipboard_rs/platform/x11.rs +916 -0
  226. package/runtime/node_modules/@mariozechner/clipboard/src/lib.rs +194 -0
  227. package/runtime/node_modules/@mariozechner/clipboard-darwin-x64/README.md +3 -0
  228. package/runtime/node_modules/@mariozechner/clipboard-darwin-x64/clipboard.darwin-x64.node +0 -0
  229. package/runtime/node_modules/@mariozechner/clipboard-darwin-x64/package.json +22 -0
  230. package/runtime/package.json +104 -0
  231. package/runtime/photon_rs_bg.wasm +0 -0
  232. package/runtime/theme/dark.json +87 -0
  233. package/runtime/theme/light.json +86 -0
  234. package/runtime/theme/theme-schema.json +340 -0
  235. package/runtime/valni +4 -0
  236. package/README.md +0 -3
@@ -0,0 +1,386 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import type { AddressInfo } from "node:net";
4
+ import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
5
+ import { MILLISECONDS_PER_SECOND } from "./constants.ts";
6
+ import { recallSessionCredentials, resolveBaseUrl } from "./config.ts";
7
+ import { deviceLogin, openInBrowser, sleep } from "./device-auth.ts";
8
+ import { describeFailure } from "./errors.ts";
9
+
10
+ const MENU_SIGN_IN = "switchboard-sign-in";
11
+ const MENU_SUBSCRIPTIONS = "subscriptions";
12
+ const MENU_ADD_OAUTH = "add-oauth";
13
+ const NEW_APP = "register-new-app";
14
+ const SUBSCRIPTION_PREFIX = "sub:";
15
+ const CALLBACK_PATH = "/callback";
16
+ const LOOPBACK_TIMEOUT_MS = 300_000;
17
+ const DEFAULT_POLL_INTERVAL_SECONDS = 5;
18
+ const DEFAULT_CONNECT_WINDOW_SECONDS = 600;
19
+
20
+ interface SubscriptionProvider {
21
+ id: string;
22
+ label: string;
23
+ }
24
+
25
+ interface OauthApp {
26
+ id: string;
27
+ name: string;
28
+ providerSlug: string;
29
+ authorizationEndpoint: string;
30
+ tokenEndpoint: string;
31
+ clientId: string;
32
+ scope: string;
33
+ }
34
+
35
+ async function apiFetch(access: string, path: string, init: { method?: string; body?: unknown } = {}): Promise<Response> {
36
+ return fetch(`${resolveBaseUrl()}${path}`, {
37
+ method: init.method ?? "GET",
38
+ headers: {
39
+ Authorization: `Bearer ${access}`,
40
+ ...(init.body === undefined ? {} : { "Content-Type": "application/json" }),
41
+ },
42
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
43
+ });
44
+ }
45
+
46
+ async function listSubscriptionProviders(access: string): Promise<SubscriptionProvider[]> {
47
+ const response = await apiFetch(access, "/v1/connections/subscription-providers");
48
+ if (!response.ok) throw new Error(await describeFailure(response));
49
+ const body = (await response.json()) as { providers?: SubscriptionProvider[] };
50
+ return Array.isArray(body.providers) ? body.providers : [];
51
+ }
52
+
53
+ async function listOauthApps(access: string): Promise<OauthApp[]> {
54
+ const response = await apiFetch(access, "/v1/connections/oauth-apps");
55
+ if (!response.ok) throw new Error(await describeFailure(response));
56
+ const body = (await response.json()) as { oauthApps?: OauthApp[] };
57
+ return Array.isArray(body.oauthApps) ? body.oauthApps : [];
58
+ }
59
+
60
+ function requirePrompt(value: string, field: string): string {
61
+ const trimmed = value.trim();
62
+ if (!trimmed) throw new Error(`${field} is required.`);
63
+ return trimmed;
64
+ }
65
+
66
+ function requireEndpointUrl(value: string, field: string): string {
67
+ const trimmed = requirePrompt(value, field);
68
+ let parsed: URL;
69
+ try {
70
+ parsed = new URL(trimmed);
71
+ } catch {
72
+ throw new Error(`The ${field} must be a valid URL, got: ${trimmed}`);
73
+ }
74
+ const loopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
75
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopback)) {
76
+ throw new Error(`The ${field} must use https (http is allowed only for localhost). The authorization code and client secret would otherwise travel in cleartext.`);
77
+ }
78
+ return trimmed;
79
+ }
80
+
81
+ function defaultSlug(name: string): string {
82
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
83
+ }
84
+
85
+ async function registerOauthApp(
86
+ access: string,
87
+ callbacks: OAuthLoginCallbacks,
88
+ ): Promise<{ app: OauthApp; clientSecret: string }> {
89
+ const name = requirePrompt(await callbacks.onPrompt({ message: "Connection name (e.g. Acme Inference)" }), "name");
90
+ const slugInput = await callbacks.onPrompt({
91
+ message: `Provider slug (lowercase, unique for your company; empty = "${defaultSlug(name)}")`,
92
+ allowEmpty: true,
93
+ });
94
+ const authorizationEndpoint = requireEndpointUrl(
95
+ await callbacks.onPrompt({ message: "Authorization endpoint (https URL)" }),
96
+ "authorization endpoint",
97
+ );
98
+ const tokenEndpoint = requireEndpointUrl(
99
+ await callbacks.onPrompt({ message: "Token endpoint (https URL)" }),
100
+ "token endpoint",
101
+ );
102
+ const clientId = requirePrompt(await callbacks.onPrompt({ message: "Client id" }), "client id");
103
+ const clientSecret = await callbacks.onPrompt({
104
+ message: "Client secret (stored encrypted by Switchboard; empty for a public client)",
105
+ allowEmpty: true,
106
+ });
107
+ const scope = await callbacks.onPrompt({ message: "Scope (empty for none)", allowEmpty: true });
108
+
109
+ const response = await apiFetch(access, "/v1/connections/oauth-apps", {
110
+ method: "POST",
111
+ body: {
112
+ name,
113
+ provider_slug: slugInput.trim() || defaultSlug(name),
114
+ authorization_endpoint: authorizationEndpoint,
115
+ token_endpoint: tokenEndpoint,
116
+ client_id: clientId,
117
+ client_secret: clientSecret,
118
+ scope: scope.trim(),
119
+ },
120
+ });
121
+ if (!response.ok) throw new Error(await describeFailure(response));
122
+ const app = (await response.json()) as OauthApp;
123
+ return { app, clientSecret };
124
+ }
125
+
126
+ async function chooseOauthApp(
127
+ access: string,
128
+ callbacks: OAuthLoginCallbacks,
129
+ ): Promise<{ app: OauthApp; clientSecret: string }> {
130
+ const apps = await listOauthApps(access);
131
+ if (apps.length === 0) return registerOauthApp(access, callbacks);
132
+
133
+ const choice = await callbacks.onSelect({
134
+ message: "Which OAuth connection?",
135
+ options: [
136
+ ...apps.map((app) => ({ id: app.id, label: `${app.name} (${app.providerSlug})` })),
137
+ { id: NEW_APP, label: "+ Register a new OAuth app" },
138
+ ],
139
+ });
140
+ if (choice === undefined) throw new Error("Connection cancelled");
141
+ if (choice === NEW_APP) return registerOauthApp(access, callbacks);
142
+
143
+ const app = apps.find((candidate) => candidate.id === choice);
144
+ if (!app) throw new Error("Connection cancelled");
145
+ const clientSecret = await callbacks.onPrompt({
146
+ message: "Client secret for the token exchange (empty for a public client; Switchboard never returns stored secrets)",
147
+ allowEmpty: true,
148
+ });
149
+ return { app, clientSecret };
150
+ }
151
+
152
+ interface LoopbackResult {
153
+ code: string;
154
+ redirectUri: string;
155
+ verifier: string;
156
+ }
157
+
158
+ function loopbackAuthorize(app: OauthApp, callbacks: OAuthLoginCallbacks): Promise<LoopbackResult> {
159
+ return new Promise<LoopbackResult>((resolve, reject) => {
160
+ const state = randomBytes(16).toString("base64url");
161
+ const verifier = randomBytes(32).toString("base64url");
162
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
163
+ const server = createServer();
164
+ let settled = false;
165
+
166
+ const fail = (error: Error) => {
167
+ if (settled) return;
168
+ settled = true;
169
+ clearTimeout(timer);
170
+ server.close();
171
+ reject(error);
172
+ };
173
+ const succeed = (code: string, redirectUri: string) => {
174
+ if (settled) return;
175
+ settled = true;
176
+ clearTimeout(timer);
177
+ server.close();
178
+ resolve({ code, redirectUri, verifier });
179
+ };
180
+
181
+ const timer = setTimeout(() => {
182
+ fail(new Error("Timed out waiting for the browser authorization. Run /login to try again."));
183
+ }, LOOPBACK_TIMEOUT_MS);
184
+ timer.unref?.();
185
+
186
+ callbacks.signal?.addEventListener("abort", () => {
187
+ fail(new Error("Connection cancelled"));
188
+ }, { once: true });
189
+
190
+ server.on("error", (error: Error & { code?: string }) => {
191
+ const detail = error.code === "EADDRINUSE" ? "the local callback port is in use" : String(error);
192
+ fail(new Error(`Could not open a local callback listener: ${detail}`));
193
+ });
194
+
195
+ server.listen(0, "127.0.0.1", () => {
196
+ const port = (server.address() as AddressInfo).port;
197
+ const redirectUri = `http://127.0.0.1:${port}${CALLBACK_PATH}`;
198
+
199
+ server.on("request", (request, response) => {
200
+ const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`);
201
+ if (url.pathname !== CALLBACK_PATH) {
202
+ response.writeHead(404).end();
203
+ return;
204
+ }
205
+ const deniedError = url.searchParams.get("error");
206
+ if (deniedError !== null) {
207
+ response.writeHead(200, { "Content-Type": "text/plain" }).end("Authorization was declined. You can close this tab.");
208
+ fail(new Error(deniedError === "access_denied" ? "Authorization was declined in the browser." : `Authorization failed: ${deniedError}`));
209
+ return;
210
+ }
211
+ if (url.searchParams.get("state") !== state) {
212
+ response.writeHead(400, { "Content-Type": "text/plain" }).end("State mismatch. This response was rejected.");
213
+ fail(new Error("Authorization response state did not match; rejected."));
214
+ return;
215
+ }
216
+ const code = url.searchParams.get("code");
217
+ if (code === null || code === "") {
218
+ response.writeHead(400, { "Content-Type": "text/plain" }).end("Missing authorization code.");
219
+ fail(new Error("The authorization response carried no code."));
220
+ return;
221
+ }
222
+ response.writeHead(200, { "Content-Type": "text/plain" }).end("Connected. You can close this tab and return to pi.");
223
+ succeed(code, redirectUri);
224
+ });
225
+
226
+ const authorizeUrl = new URL(app.authorizationEndpoint);
227
+ authorizeUrl.searchParams.set("response_type", "code");
228
+ authorizeUrl.searchParams.set("client_id", app.clientId);
229
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri);
230
+ if (app.scope) authorizeUrl.searchParams.set("scope", app.scope);
231
+ authorizeUrl.searchParams.set("state", state);
232
+ authorizeUrl.searchParams.set("code_challenge", challenge);
233
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
234
+
235
+ callbacks.onAuth({ url: authorizeUrl.toString(), instructions: `Authorize ${app.name} in your browser` });
236
+ openInBrowser(authorizeUrl.toString());
237
+ });
238
+ });
239
+ }
240
+
241
+ async function connectCustomOauth(access: string, callbacks: OAuthLoginCallbacks): Promise<void> {
242
+ const { app, clientSecret } = await chooseOauthApp(access, callbacks);
243
+ requireEndpointUrl(app.authorizationEndpoint, "authorization endpoint");
244
+ requireEndpointUrl(app.tokenEndpoint, "token endpoint");
245
+ const authorization = await loopbackAuthorize(app, callbacks);
246
+
247
+ const exchangeResponse = await fetch(app.tokenEndpoint, {
248
+ method: "POST",
249
+ headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
250
+ body: new URLSearchParams({
251
+ grant_type: "authorization_code",
252
+ code: authorization.code,
253
+ redirect_uri: authorization.redirectUri,
254
+ client_id: app.clientId,
255
+ code_verifier: authorization.verifier,
256
+ ...(clientSecret ? { client_secret: clientSecret } : {}),
257
+ }),
258
+ });
259
+ if (!exchangeResponse.ok) {
260
+ throw new Error(`${app.name} rejected the code exchange (HTTP ${exchangeResponse.status}). Check the token endpoint, client id, and client secret.`);
261
+ }
262
+ const tokens = (await exchangeResponse.json()) as {
263
+ access_token?: unknown;
264
+ refresh_token?: unknown;
265
+ expires_in?: unknown;
266
+ };
267
+ if (typeof tokens.access_token !== "string" || tokens.access_token.length === 0) {
268
+ throw new Error(`${app.name} returned no access token from the code exchange.`);
269
+ }
270
+
271
+ const storeResponse = await apiFetch(access, "/v1/connections/credentials", {
272
+ method: "POST",
273
+ body: {
274
+ oauth_app_id: app.id,
275
+ access_token: tokens.access_token,
276
+ ...(typeof tokens.refresh_token === "string" && tokens.refresh_token.length > 0
277
+ ? { refresh_token: tokens.refresh_token }
278
+ : {}),
279
+ ...(typeof tokens.expires_in === "number" && tokens.expires_in > 0 ? { expires_in: tokens.expires_in } : {}),
280
+ },
281
+ });
282
+ if (!storeResponse.ok) throw new Error(await describeFailure(storeResponse));
283
+ callbacks.onProgress?.(`Connected ${app.name}`);
284
+ }
285
+
286
+ async function connectSubscription(access: string, providerId: string, label: string, callbacks: OAuthLoginCallbacks): Promise<void> {
287
+ const startResponse = await apiFetch(access, `/v1/connections/subscription/${encodeURIComponent(providerId)}/start`, { method: "POST" });
288
+ if (!startResponse.ok) throw new Error(await describeFailure(startResponse));
289
+ const start = (await startResponse.json()) as {
290
+ connectId: string;
291
+ userCode: string;
292
+ verificationUri: string;
293
+ expiresIn: number;
294
+ interval: number;
295
+ };
296
+
297
+ callbacks.onDeviceCode({
298
+ userCode: start.userCode,
299
+ verificationUri: start.verificationUri,
300
+ intervalSeconds: start.interval,
301
+ expiresInSeconds: start.expiresIn,
302
+ });
303
+ openInBrowser(start.verificationUri);
304
+
305
+ const intervalSeconds = start.interval > 0 ? start.interval : DEFAULT_POLL_INTERVAL_SECONDS;
306
+ const windowSeconds = start.expiresIn > 0 ? start.expiresIn : DEFAULT_CONNECT_WINDOW_SECONDS;
307
+ const deadline = Date.now() + windowSeconds * MILLISECONDS_PER_SECOND;
308
+ while (Date.now() < deadline) {
309
+ await sleep(intervalSeconds * MILLISECONDS_PER_SECOND, callbacks.signal);
310
+ const pollResponse = await apiFetch(
311
+ access,
312
+ `/v1/connections/subscription/${encodeURIComponent(providerId)}/connect/${encodeURIComponent(start.connectId)}`,
313
+ { method: "POST" },
314
+ );
315
+ if (!pollResponse.ok) throw new Error(await describeFailure(pollResponse));
316
+ const poll = (await pollResponse.json()) as { status?: string };
317
+ if (poll.status === "active") {
318
+ callbacks.onProgress?.(`Connected ${label}`);
319
+ return;
320
+ }
321
+ if (poll.status === "denied") throw new Error("Authorization was declined in the browser.");
322
+ if (poll.status === "expired") throw new Error("The connect code expired. Run /login to try again.");
323
+ if (poll.status === "failed") throw new Error("The provider rejected the connection. Run /login to try again.");
324
+ }
325
+ throw new Error("The connect code expired. Run /login to try again.");
326
+ }
327
+
328
+ async function ensureSession(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
329
+ const remembered = recallSessionCredentials();
330
+ if (remembered !== null) return remembered;
331
+ callbacks.onProgress?.("Sign in to Switchboard first");
332
+ return deviceLogin(callbacks);
333
+ }
334
+
335
+ export async function switchboardLogin(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
336
+ const remembered = recallSessionCredentials();
337
+ let subscriptionProviders: SubscriptionProvider[] = [];
338
+ if (remembered !== null) {
339
+ try {
340
+ subscriptionProviders = await listSubscriptionProviders(remembered.access);
341
+ } catch {
342
+ subscriptionProviders = [];
343
+ }
344
+ }
345
+
346
+ const options = [
347
+ { id: MENU_SIGN_IN, label: remembered === null ? "Sign in to Switchboard" : "Sign in again (switch account)" },
348
+ ...(subscriptionProviders.length > 0
349
+ ? subscriptionProviders.map((provider) => ({ id: `${SUBSCRIPTION_PREFIX}${provider.id}`, label: `Connect ${provider.label}` }))
350
+ : [{ id: MENU_SUBSCRIPTIONS, label: "Connect a provider subscription (SuperGrok, ...)" }]),
351
+ { id: MENU_ADD_OAUTH, label: "+ Add OAuth connection" },
352
+ ];
353
+
354
+ const choice = await callbacks.onSelect({ message: "Switchboard", options });
355
+ if (choice === undefined) throw new Error("Sign-in cancelled");
356
+ if (choice === MENU_SIGN_IN) return deviceLogin(callbacks);
357
+
358
+ const credentials = await ensureSession(callbacks);
359
+
360
+ if (choice === MENU_ADD_OAUTH) {
361
+ await connectCustomOauth(credentials.access, callbacks);
362
+ return credentials;
363
+ }
364
+
365
+ let providerId: string;
366
+ let providerLabel: string;
367
+ if (choice.startsWith(SUBSCRIPTION_PREFIX)) {
368
+ providerId = choice.slice(SUBSCRIPTION_PREFIX.length);
369
+ providerLabel = subscriptionProviders.find((provider) => provider.id === providerId)?.label ?? providerId;
370
+ } else {
371
+ const providers = await listSubscriptionProviders(credentials.access);
372
+ if (providers.length === 0) throw new Error("No subscription providers are available to connect.");
373
+ const picked = providers.length === 1
374
+ ? providers[0].id
375
+ : await callbacks.onSelect({
376
+ message: "Which subscription?",
377
+ options: providers.map((provider) => ({ id: provider.id, label: provider.label })),
378
+ });
379
+ if (picked === undefined) throw new Error("Connection cancelled");
380
+ providerId = picked;
381
+ providerLabel = providers.find((provider) => provider.id === providerId)?.label ?? providerId;
382
+ }
383
+
384
+ await connectSubscription(credentials.access, providerId, providerLabel, callbacks);
385
+ return credentials;
386
+ }
@@ -0,0 +1,12 @@
1
+ export const MILLISECONDS_PER_SECOND = 1000;
2
+ export const PROVIDER_ID = "switchboard";
3
+ export const SENTINEL_SEGMENT = "/pi-switchboard/";
4
+ export const EVENT_STREAM_CONTENT_TYPE = "text/event-stream";
5
+ export const CONNECTION_ID_ENV = "RUNNER_CONNECTION_ID";
6
+
7
+ export function spawnedConnectionId(): string | null {
8
+ const raw = process.env[CONNECTION_ID_ENV];
9
+ if (raw === undefined) return null;
10
+ const trimmed = raw.trim();
11
+ return trimmed === "" ? null : trimmed;
12
+ }
@@ -0,0 +1,127 @@
1
+ import { spawn } from "node:child_process";
2
+ import { hostname } from "node:os";
3
+ import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
4
+ import { MILLISECONDS_PER_SECOND } from "./constants.ts";
5
+ import { clearSessionCredentials, rememberSessionCredentials, resolveAuthBaseUrl, setSessionEndUserId } from "./config.ts";
6
+ import { describeFailure } from "./errors.ts";
7
+
8
+ const DEVICE_AUTHORIZE_PATH = "/v1/device/authorize";
9
+ const DEVICE_TOKEN_PATH = "/v1/device/token";
10
+ const DEVICE_CLIENT_ID = "pi-switchboard";
11
+ const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
12
+ const SLOW_DOWN_EXTRA_SECONDS = 5;
13
+
14
+ interface DeviceTokenResponse {
15
+ access_token?: string;
16
+ refresh_token?: string;
17
+ expires_in?: number;
18
+ end_user_id?: string;
19
+ error?: string;
20
+ }
21
+
22
+ export function openInBrowser(url: string): void {
23
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
24
+ const commandArguments = process.platform === "win32" ? ["/c", "start", "", url] : [url];
25
+ try {
26
+ spawn(command, commandArguments, { detached: true, stdio: "ignore" }).unref();
27
+ } catch {
28
+ }
29
+ }
30
+
31
+ export function sleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
32
+ return new Promise((resolve, reject) => {
33
+ const timer = setTimeout(resolve, milliseconds);
34
+ signal?.addEventListener("abort", () => {
35
+ clearTimeout(timer);
36
+ reject(new Error("Sign-in cancelled"));
37
+ }, { once: true });
38
+ });
39
+ }
40
+
41
+ function toCredentials(token: DeviceTokenResponse): OAuthCredentials {
42
+ if (!token.access_token || !token.refresh_token || !token.expires_in || !token.end_user_id) {
43
+ throw new Error(`Switchboard sign-in returned an incomplete token response`);
44
+ }
45
+ setSessionEndUserId(token.end_user_id);
46
+ const credentials: OAuthCredentials = {
47
+ refresh: token.refresh_token,
48
+ access: token.access_token,
49
+ expires: Date.now() + token.expires_in * MILLISECONDS_PER_SECOND,
50
+ endUserId: token.end_user_id,
51
+ };
52
+ rememberSessionCredentials(credentials);
53
+ return credentials;
54
+ }
55
+
56
+ export async function deviceLogin(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
57
+ clearSessionCredentials();
58
+ const authBase = resolveAuthBaseUrl();
59
+ const authorizeResponse = await fetch(`${authBase}${DEVICE_AUTHORIZE_PATH}`, {
60
+ method: "POST",
61
+ headers: { "Content-Type": "application/json" },
62
+ body: JSON.stringify({ client_id: DEVICE_CLIENT_ID, device_label: hostname() }),
63
+ });
64
+ if (!authorizeResponse.ok) {
65
+ throw new Error(await describeFailure(authorizeResponse));
66
+ }
67
+ const authorization = (await authorizeResponse.json()) as {
68
+ device_code: string;
69
+ user_code: string;
70
+ verification_uri: string;
71
+ verification_uri_complete: string;
72
+ expires_in: number;
73
+ interval: number;
74
+ };
75
+
76
+ callbacks.onDeviceCode({
77
+ userCode: authorization.user_code,
78
+ verificationUri: authorization.verification_uri_complete,
79
+ intervalSeconds: authorization.interval,
80
+ expiresInSeconds: authorization.expires_in,
81
+ });
82
+ openInBrowser(authorization.verification_uri_complete);
83
+
84
+ let intervalSeconds = authorization.interval;
85
+ while (true) {
86
+ await sleep(intervalSeconds * MILLISECONDS_PER_SECOND, callbacks.signal);
87
+ const pollResponse = await fetch(`${authBase}${DEVICE_TOKEN_PATH}`, {
88
+ method: "POST",
89
+ headers: { "Content-Type": "application/json" },
90
+ body: JSON.stringify({
91
+ grant_type: DEVICE_CODE_GRANT,
92
+ device_code: authorization.device_code,
93
+ client_id: DEVICE_CLIENT_ID,
94
+ }),
95
+ });
96
+ const token = (await pollResponse.json()) as DeviceTokenResponse;
97
+ if (token.access_token) {
98
+ callbacks.onProgress?.("Signed in to Switchboard");
99
+ return toCredentials(token);
100
+ }
101
+ if (token.error === "authorization_pending") continue;
102
+ if (token.error === "slow_down") {
103
+ intervalSeconds += SLOW_DOWN_EXTRA_SECONDS;
104
+ continue;
105
+ }
106
+ if (token.error === "access_denied") throw new Error("Sign-in was denied in the browser.");
107
+ if (token.error === "expired_token") throw new Error("The sign-in code expired. Run /login again.");
108
+ throw new Error(`Switchboard sign-in failed: ${token.error ?? pollResponse.status}`);
109
+ }
110
+ }
111
+
112
+ export async function deviceRefresh(credentials: OAuthCredentials): Promise<OAuthCredentials> {
113
+ const response = await fetch(`${resolveAuthBaseUrl()}${DEVICE_TOKEN_PATH}`, {
114
+ method: "POST",
115
+ headers: { "Content-Type": "application/json" },
116
+ body: JSON.stringify({
117
+ grant_type: "refresh_token",
118
+ refresh_token: credentials.refresh,
119
+ client_id: DEVICE_CLIENT_ID,
120
+ }),
121
+ });
122
+ const token = (await response.json()) as DeviceTokenResponse;
123
+ if (!token.access_token) {
124
+ throw new Error("Switchboard session expired. Run /login again.");
125
+ }
126
+ return toCredentials(token);
127
+ }