heyhank 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/README.md +40 -0
  2. package/bin/cli.ts +168 -0
  3. package/bin/ctl.ts +528 -0
  4. package/bin/generate-token.ts +28 -0
  5. package/dist/apple-touch-icon.png +0 -0
  6. package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
  7. package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
  8. package/dist/assets/CronManager-DDbz-yiT.js +1 -0
  9. package/dist/assets/HelpPage-DMfkzERp.js +1 -0
  10. package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
  11. package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
  12. package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
  13. package/dist/assets/Playground-Fc5cdc5p.js +109 -0
  14. package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
  15. package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
  16. package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
  17. package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
  18. package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
  19. package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
  20. package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
  21. package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
  22. package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
  23. package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
  24. package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
  25. package/dist/assets/index-C8M_PUmX.css +32 -0
  26. package/dist/assets/index-CEqZnThB.js +204 -0
  27. package/dist/assets/sw-register-LSSpj6RU.js +1 -0
  28. package/dist/assets/time-ago-B6r_l9u1.js +1 -0
  29. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  30. package/dist/favicon-32-original.png +0 -0
  31. package/dist/favicon-32.png +0 -0
  32. package/dist/favicon.ico +0 -0
  33. package/dist/favicon.svg +8 -0
  34. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  35. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  36. package/dist/heyhank-mascot-poster.png +0 -0
  37. package/dist/heyhank-mascot.mp4 +0 -0
  38. package/dist/heyhank-mascot.webm +0 -0
  39. package/dist/icon-192-original.png +0 -0
  40. package/dist/icon-192.png +0 -0
  41. package/dist/icon-512-original.png +0 -0
  42. package/dist/icon-512.png +0 -0
  43. package/dist/index.html +21 -0
  44. package/dist/logo-192.png +0 -0
  45. package/dist/logo-512.png +0 -0
  46. package/dist/logo-codex.svg +14 -0
  47. package/dist/logo-docker.svg +4 -0
  48. package/dist/logo-original.png +0 -0
  49. package/dist/logo.png +0 -0
  50. package/dist/logo.svg +14 -0
  51. package/dist/manifest.json +24 -0
  52. package/dist/push-sw.js +34 -0
  53. package/dist/sw.js +1 -0
  54. package/dist/workbox-d2a0910a.js +1 -0
  55. package/package.json +109 -0
  56. package/server/agent-cron-migrator.ts +85 -0
  57. package/server/agent-executor.ts +357 -0
  58. package/server/agent-store.ts +185 -0
  59. package/server/agent-timeout.ts +107 -0
  60. package/server/agent-types.ts +122 -0
  61. package/server/ai-validation-settings.ts +37 -0
  62. package/server/ai-validator.ts +181 -0
  63. package/server/anthropic-provider-migration.ts +48 -0
  64. package/server/assistant-store.ts +272 -0
  65. package/server/auth-manager.ts +150 -0
  66. package/server/auto-approve.ts +153 -0
  67. package/server/auto-namer.ts +36 -0
  68. package/server/backend-adapter.ts +54 -0
  69. package/server/cache-headers.ts +61 -0
  70. package/server/calendar-service.ts +434 -0
  71. package/server/claude-adapter.ts +889 -0
  72. package/server/claude-container-auth.ts +30 -0
  73. package/server/claude-session-discovery.ts +157 -0
  74. package/server/claude-session-history.ts +410 -0
  75. package/server/cli-launcher.ts +1303 -0
  76. package/server/codex-adapter.ts +3027 -0
  77. package/server/codex-container-auth.ts +24 -0
  78. package/server/codex-home.ts +27 -0
  79. package/server/codex-ws-proxy.cjs +226 -0
  80. package/server/commands-discovery.ts +81 -0
  81. package/server/constants.ts +7 -0
  82. package/server/container-manager.ts +1053 -0
  83. package/server/cost-tracker.ts +222 -0
  84. package/server/cron-scheduler.ts +243 -0
  85. package/server/cron-store.ts +148 -0
  86. package/server/cron-types.ts +63 -0
  87. package/server/email-service.ts +354 -0
  88. package/server/env-manager.ts +161 -0
  89. package/server/event-bus-types.ts +75 -0
  90. package/server/event-bus.ts +124 -0
  91. package/server/execution-store.ts +170 -0
  92. package/server/federation/node-connection.ts +190 -0
  93. package/server/federation/node-manager.ts +366 -0
  94. package/server/federation/node-store.ts +86 -0
  95. package/server/federation/node-types.ts +121 -0
  96. package/server/fs-utils.ts +15 -0
  97. package/server/git-utils.ts +421 -0
  98. package/server/github-pr.ts +379 -0
  99. package/server/google-media.ts +342 -0
  100. package/server/image-pull-manager.ts +279 -0
  101. package/server/index.ts +491 -0
  102. package/server/internal-ai.ts +237 -0
  103. package/server/kill-switch.ts +99 -0
  104. package/server/llm-providers.ts +342 -0
  105. package/server/logger.ts +259 -0
  106. package/server/mcp-registry.ts +401 -0
  107. package/server/message-bus.ts +271 -0
  108. package/server/message-delivery.ts +128 -0
  109. package/server/metrics-collector.ts +350 -0
  110. package/server/metrics-types.ts +108 -0
  111. package/server/middleware/managed-auth.ts +195 -0
  112. package/server/novnc-proxy.ts +99 -0
  113. package/server/path-resolver.ts +186 -0
  114. package/server/paths.ts +13 -0
  115. package/server/pr-poller.ts +162 -0
  116. package/server/prompt-manager.ts +211 -0
  117. package/server/protocol/claude-upstream/README.md +19 -0
  118. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  119. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  120. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  121. package/server/protocol/codex-upstream/README.md +18 -0
  122. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  123. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  124. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  125. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  126. package/server/protocol-monitor.ts +50 -0
  127. package/server/provider-manager.ts +111 -0
  128. package/server/provider-registry.ts +393 -0
  129. package/server/push-notifications.ts +221 -0
  130. package/server/recorder.ts +374 -0
  131. package/server/recording-hub/compat-validator.ts +284 -0
  132. package/server/recording-hub/diagnostics.ts +299 -0
  133. package/server/recording-hub/hub-config.ts +19 -0
  134. package/server/recording-hub/hub-routes.ts +236 -0
  135. package/server/recording-hub/hub-store.ts +265 -0
  136. package/server/recording-hub/replay-adapter.ts +207 -0
  137. package/server/relay-client.ts +320 -0
  138. package/server/reminder-scheduler.ts +38 -0
  139. package/server/replay.ts +78 -0
  140. package/server/routes/agent-routes.ts +264 -0
  141. package/server/routes/assistant-routes.ts +90 -0
  142. package/server/routes/cron-routes.ts +103 -0
  143. package/server/routes/env-routes.ts +95 -0
  144. package/server/routes/federation-routes.ts +76 -0
  145. package/server/routes/fs-routes.ts +622 -0
  146. package/server/routes/git-routes.ts +97 -0
  147. package/server/routes/llm-routes.ts +166 -0
  148. package/server/routes/media-routes.ts +135 -0
  149. package/server/routes/metrics-routes.ts +13 -0
  150. package/server/routes/platform-routes.ts +1379 -0
  151. package/server/routes/prompt-routes.ts +67 -0
  152. package/server/routes/provider-routes.ts +109 -0
  153. package/server/routes/sandbox-routes.ts +127 -0
  154. package/server/routes/settings-routes.ts +285 -0
  155. package/server/routes/skills-routes.ts +100 -0
  156. package/server/routes/socialmedia-routes.ts +208 -0
  157. package/server/routes/system-routes.ts +228 -0
  158. package/server/routes/tailscale-routes.ts +22 -0
  159. package/server/routes/telephony-routes.ts +259 -0
  160. package/server/routes.ts +1379 -0
  161. package/server/sandbox-manager.ts +168 -0
  162. package/server/service.ts +718 -0
  163. package/server/session-creation-service.ts +457 -0
  164. package/server/session-git-info.ts +104 -0
  165. package/server/session-names.ts +67 -0
  166. package/server/session-orchestrator.ts +824 -0
  167. package/server/session-state-machine.ts +207 -0
  168. package/server/session-store.ts +146 -0
  169. package/server/session-types.ts +511 -0
  170. package/server/settings-manager.ts +149 -0
  171. package/server/shared-context.ts +157 -0
  172. package/server/socialmedia/adapter.ts +15 -0
  173. package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
  174. package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
  175. package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
  176. package/server/socialmedia/manager.ts +227 -0
  177. package/server/socialmedia/store.ts +98 -0
  178. package/server/socialmedia/types.ts +89 -0
  179. package/server/tailscale-manager.ts +451 -0
  180. package/server/telephony/audio-bridge.ts +331 -0
  181. package/server/telephony/call-manager.ts +457 -0
  182. package/server/telephony/call-types.ts +108 -0
  183. package/server/telephony/telephony-store.ts +119 -0
  184. package/server/terminal-manager.ts +240 -0
  185. package/server/update-checker.ts +192 -0
  186. package/server/usage-limits.ts +225 -0
  187. package/server/web-push.d.ts +51 -0
  188. package/server/worktree-tracker.ts +84 -0
  189. package/server/ws-auth.ts +41 -0
  190. package/server/ws-bridge-browser-ingest.ts +72 -0
  191. package/server/ws-bridge-browser.ts +112 -0
  192. package/server/ws-bridge-cli-ingest.ts +81 -0
  193. package/server/ws-bridge-codex.ts +266 -0
  194. package/server/ws-bridge-controls.ts +20 -0
  195. package/server/ws-bridge-persist.ts +66 -0
  196. package/server/ws-bridge-publish.ts +79 -0
  197. package/server/ws-bridge-replay.ts +61 -0
  198. package/server/ws-bridge-types.ts +121 -0
  199. package/server/ws-bridge.ts +1240 -0
@@ -0,0 +1,67 @@
1
+ import type { Hono } from "hono";
2
+ import * as promptManager from "../prompt-manager.js";
3
+
4
+ function sanitizePaths(value: unknown): string[] | undefined {
5
+ if (!Array.isArray(value)) return undefined;
6
+ return value.filter((p): p is string => typeof p === "string");
7
+ }
8
+
9
+ export function registerPromptRoutes(api: Hono): void {
10
+ api.get("/prompts", (c) => {
11
+ try {
12
+ const cwd = c.req.query("cwd");
13
+ const scope = c.req.query("scope");
14
+ const normalizedScope =
15
+ scope === "global" || scope === "project" || scope === "all"
16
+ ? scope
17
+ : undefined;
18
+ return c.json(promptManager.listPrompts({ cwd, scope: normalizedScope }));
19
+ } catch (e: unknown) {
20
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 500);
21
+ }
22
+ });
23
+
24
+ api.get("/prompts/:id", (c) => {
25
+ const prompt = promptManager.getPrompt(c.req.param("id"));
26
+ if (!prompt) return c.json({ error: "Prompt not found" }, 404);
27
+ return c.json(prompt);
28
+ });
29
+
30
+ api.post("/prompts", async (c) => {
31
+ const body = await c.req.json().catch(() => ({}));
32
+ try {
33
+ const prompt = promptManager.createPrompt(
34
+ String(body.title || body.name || ""),
35
+ String(body.content || ""),
36
+ body.scope,
37
+ body.cwd ?? body.projectPath,
38
+ sanitizePaths(body.projectPaths),
39
+ );
40
+ return c.json(prompt, 201);
41
+ } catch (e: unknown) {
42
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
43
+ }
44
+ });
45
+
46
+ api.put("/prompts/:id", async (c) => {
47
+ const body = await c.req.json().catch(() => ({}));
48
+ try {
49
+ const prompt = promptManager.updatePrompt(c.req.param("id"), {
50
+ name: body.title ?? body.name,
51
+ content: body.content,
52
+ scope: body.scope,
53
+ projectPaths: sanitizePaths(body.projectPaths),
54
+ });
55
+ if (!prompt) return c.json({ error: "Prompt not found" }, 404);
56
+ return c.json(prompt);
57
+ } catch (e: unknown) {
58
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
59
+ }
60
+ });
61
+
62
+ api.delete("/prompts/:id", (c) => {
63
+ const deleted = promptManager.deletePrompt(c.req.param("id"));
64
+ if (!deleted) return c.json({ error: "Prompt not found" }, 404);
65
+ return c.json({ ok: true });
66
+ });
67
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * REST API routes for provider management
3
+ */
4
+ import type { Hono } from "hono";
5
+ import { getAllProviders, getProviderById } from "../provider-registry.js";
6
+ import {
7
+ listProviderConfigs,
8
+ getProviderConfig,
9
+ upsertProviderConfig,
10
+ deleteProviderConfig,
11
+ } from "../provider-manager.js";
12
+
13
+ export function registerProviderRoutes(app: Hono): void {
14
+ // Static registry — all known providers with their metadata
15
+ app.get("/providers/registry", (c) => {
16
+ return c.json(getAllProviders());
17
+ });
18
+
19
+ // User's configured providers — merged with registry metadata, secrets masked
20
+ app.get("/providers", (c) => {
21
+ const registry = getAllProviders();
22
+ const configs = listProviderConfigs();
23
+ const configMap = new Map(configs.map((cfg) => [cfg.providerId, cfg]));
24
+
25
+ const merged = registry.map((def) => {
26
+ const cfg = configMap.get(def.id);
27
+ const envConfigured: Record<string, boolean> = {};
28
+ for (const field of def.envFields) {
29
+ envConfigured[field.key] = !!(cfg?.envValues[field.key]);
30
+ }
31
+ return {
32
+ ...def,
33
+ configured: !!cfg,
34
+ enabled: cfg?.enabled ?? false,
35
+ envConfigured,
36
+ customModel: cfg?.customModel ?? undefined,
37
+ };
38
+ });
39
+
40
+ return c.json(merged);
41
+ });
42
+
43
+ // Single provider detail
44
+ app.get("/providers/:id", (c) => {
45
+ const id = c.req.param("id");
46
+ const def = getProviderById(id);
47
+ if (!def) return c.json({ error: "Unknown provider" }, 404);
48
+
49
+ const cfg = getProviderConfig(id);
50
+ const envConfigured: Record<string, boolean> = {};
51
+ // For non-secret fields, also return the actual value
52
+ const envValues: Record<string, string> = {};
53
+ for (const field of def.envFields) {
54
+ envConfigured[field.key] = !!(cfg?.envValues[field.key]);
55
+ if (!field.secret && cfg?.envValues[field.key]) {
56
+ envValues[field.key] = cfg.envValues[field.key];
57
+ }
58
+ }
59
+
60
+ return c.json({
61
+ ...def,
62
+ configured: !!cfg,
63
+ enabled: cfg?.enabled ?? false,
64
+ envConfigured,
65
+ envValues,
66
+ customModel: cfg?.customModel ?? undefined,
67
+ });
68
+ });
69
+
70
+ // Create or update a provider config
71
+ app.put("/providers/:id", async (c) => {
72
+ const id = c.req.param("id");
73
+ const def = getProviderById(id);
74
+ if (!def) return c.json({ error: "Unknown provider" }, 404);
75
+
76
+ const body = await c.req.json();
77
+ const config = upsertProviderConfig(id, {
78
+ enabled: body.enabled,
79
+ envValues: body.envValues,
80
+ customModel: body.customModel,
81
+ });
82
+
83
+ // Return masked response
84
+ const envConfigured: Record<string, boolean> = {};
85
+ const envValues: Record<string, string> = {};
86
+ for (const field of def.envFields) {
87
+ envConfigured[field.key] = !!(config.envValues[field.key]);
88
+ if (!field.secret && config.envValues[field.key]) {
89
+ envValues[field.key] = config.envValues[field.key];
90
+ }
91
+ }
92
+
93
+ return c.json({
94
+ ...def,
95
+ configured: true,
96
+ enabled: config.enabled,
97
+ envConfigured,
98
+ envValues,
99
+ customModel: config.customModel ?? undefined,
100
+ });
101
+ });
102
+
103
+ // Delete a provider config
104
+ app.delete("/providers/:id", (c) => {
105
+ const id = c.req.param("id");
106
+ const deleted = deleteProviderConfig(id);
107
+ return c.json({ ok: deleted });
108
+ });
109
+ }
@@ -0,0 +1,127 @@
1
+ import { resolve } from "node:path";
2
+ import type { Hono } from "hono";
3
+ import * as sandboxManager from "../sandbox-manager.js";
4
+ import { containerManager, type ContainerConfig } from "../container-manager.js";
5
+ import { imagePullManager } from "../image-pull-manager.js";
6
+
7
+ export function registerSandboxRoutes(
8
+ api: Hono,
9
+ ): void {
10
+ api.get("/sandboxes", (c) => {
11
+ try {
12
+ return c.json(sandboxManager.listSandboxes());
13
+ } catch (e: unknown) {
14
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 500);
15
+ }
16
+ });
17
+
18
+ api.get("/sandboxes/:slug", (c) => {
19
+ const sandbox = sandboxManager.getSandbox(c.req.param("slug"));
20
+ if (!sandbox) return c.json({ error: "Sandbox not found" }, 404);
21
+ return c.json(sandbox);
22
+ });
23
+
24
+ api.post("/sandboxes", async (c) => {
25
+ const body = await c.req.json().catch(() => ({}));
26
+ try {
27
+ const sandbox = sandboxManager.createSandbox(body.name, {
28
+ initScript: body.initScript,
29
+ });
30
+ return c.json(sandbox, 201);
31
+ } catch (e: unknown) {
32
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
33
+ }
34
+ });
35
+
36
+ api.put("/sandboxes/:slug", async (c) => {
37
+ const slug = c.req.param("slug");
38
+ const body = await c.req.json().catch(() => ({}));
39
+ try {
40
+ const sandbox = sandboxManager.updateSandbox(slug, {
41
+ name: body.name,
42
+ initScript: body.initScript,
43
+ });
44
+ if (!sandbox) return c.json({ error: "Sandbox not found" }, 404);
45
+ return c.json(sandbox);
46
+ } catch (e: unknown) {
47
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
48
+ }
49
+ });
50
+
51
+ api.delete("/sandboxes/:slug", (c) => {
52
+ try {
53
+ const deleted = sandboxManager.deleteSandbox(c.req.param("slug"));
54
+ if (!deleted) return c.json({ error: "Sandbox not found" }, 404);
55
+ return c.json({ ok: true });
56
+ } catch (e: unknown) {
57
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
58
+ }
59
+ });
60
+
61
+ // Test the init script of a sandbox in an ephemeral container.
62
+ // Accepts an optional `initScript` body param to test unsaved content
63
+ // without persisting it first. Falls back to the stored script.
64
+ api.post("/sandboxes/:slug/test-init", async (c) => {
65
+ const slug = c.req.param("slug");
66
+ const body = await c.req.json().catch(() => ({}));
67
+ const rawCwd = body.cwd;
68
+
69
+ const sandbox = sandboxManager.getSandbox(slug);
70
+ if (!sandbox) return c.json({ error: "Sandbox not found" }, 404);
71
+
72
+ // Prefer body initScript (unsaved draft) over stored value
73
+ const initScript = (typeof body.initScript === "string" ? body.initScript : sandbox.initScript ?? "").trim();
74
+ if (!initScript) return c.json({ error: "No init script configured for this sandbox" }, 400);
75
+ if (!rawCwd) return c.json({ error: "Working directory (cwd) is required" }, 400);
76
+
77
+ // Require an absolute path from the caller, then normalize
78
+ const cwdStr = String(rawCwd);
79
+ if (!cwdStr.startsWith("/")) return c.json({ error: "Working directory must be an absolute path" }, 400);
80
+ const cwd = resolve(cwdStr);
81
+
82
+ if (!containerManager.checkDocker()) return c.json({ error: "Docker is not available" }, 503);
83
+
84
+ const effectiveImage = "the-companion:latest";
85
+ if (!imagePullManager.isReady(effectiveImage)) {
86
+ return c.json({ error: `Docker image ${effectiveImage} is not available. Pull it first.` }, 503);
87
+ }
88
+
89
+ const tempId = `t${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
90
+ let containerId: string | undefined;
91
+
92
+ try {
93
+ const config: ContainerConfig = {
94
+ image: effectiveImage,
95
+ ports: [],
96
+ };
97
+ const containerInfo = containerManager.createContainer(tempId, cwd, config);
98
+ containerId = containerInfo.containerId;
99
+
100
+ await containerManager.copyWorkspaceToContainer(containerId, cwd);
101
+
102
+ const initTimeout = Number(process.env.HEYHANK_INIT_SCRIPT_TIMEOUT || process.env.COMPANION_INIT_SCRIPT_TIMEOUT) || 120_000;
103
+ const result = await containerManager.execInContainerAsync(
104
+ containerId,
105
+ ["sh", "-lc", initScript],
106
+ { timeout: initTimeout },
107
+ );
108
+
109
+ const output = result.output.length > 2000
110
+ ? result.output.slice(0, 500) + "\n...[truncated]...\n" + result.output.slice(-1500)
111
+ : result.output;
112
+
113
+ return c.json({
114
+ success: result.exitCode === 0,
115
+ exitCode: result.exitCode,
116
+ output,
117
+ });
118
+ } catch (e: unknown) {
119
+ const msg = e instanceof Error ? e.message : String(e);
120
+ return c.json({ success: false, exitCode: -1, output: msg }, 500);
121
+ } finally {
122
+ if (containerId) {
123
+ try { containerManager.removeContainer(tempId); } catch { /* best effort cleanup */ }
124
+ }
125
+ }
126
+ });
127
+ }
@@ -0,0 +1,285 @@
1
+ import type { Hono } from "hono";
2
+ import { DEFAULT_ANTHROPIC_MODEL, getSettings, updateSettings, type UpdateChannel } from "../settings-manager.js";
3
+ import { hasContainerCodexAuth } from "../codex-container-auth.js";
4
+ import { hasContainerClaudeAuth } from "../claude-container-auth.js";
5
+ import { existsSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { execSync } from "node:child_process";
9
+
10
+ /** Detect Claude CLI auth method */
11
+ function detectClaudeAuthStatus(): { authenticated: boolean; method: string } {
12
+ const home = process.env.HOME || process.env.USERPROFILE || homedir();
13
+
14
+ // Check for credentials file (claude login)
15
+ const credFiles = [
16
+ join(home, ".claude", ".credentials.json"),
17
+ join(home, ".claude", "auth.json"),
18
+ join(home, ".claude", ".auth.json"),
19
+ join(home, ".claude", "credentials.json"),
20
+ ];
21
+ if (credFiles.some((p) => existsSync(p))) {
22
+ return { authenticated: true, method: "cli_login" };
23
+ }
24
+
25
+ // Check env vars
26
+ if (process.env.ANTHROPIC_API_KEY) return { authenticated: true, method: "env_api_key" };
27
+ if (process.env.CLAUDE_CODE_OAUTH_TOKEN) return { authenticated: true, method: "env_oauth" };
28
+ if (process.env.ANTHROPIC_AUTH_TOKEN) return { authenticated: true, method: "env_auth_token" };
29
+
30
+ return { authenticated: false, method: "none" };
31
+ }
32
+
33
+ /** Detect Codex CLI auth method */
34
+ function detectCodexAuthStatus(): { authenticated: boolean; method: string } {
35
+ const home = process.env.HOME || process.env.USERPROFILE || homedir();
36
+
37
+ // Check for codex auth file
38
+ const codexAuth = join(home, ".codex", "auth.json");
39
+ if (existsSync(codexAuth)) {
40
+ return { authenticated: true, method: "cli_login" };
41
+ }
42
+
43
+ // Check env var
44
+ if (process.env.OPENAI_API_KEY) return { authenticated: true, method: "env_api_key" };
45
+
46
+ return { authenticated: false, method: "none" };
47
+ }
48
+
49
+ /** Detect Claude CLI version */
50
+ function detectClaudeVersion(): string | null {
51
+ try {
52
+ return execSync("claude --version 2>/dev/null", { timeout: 3000 }).toString().trim().split("\n")[0] || null;
53
+ } catch { return null; }
54
+ }
55
+
56
+ /** Detect Codex CLI version */
57
+ function detectCodexVersion(): string | null {
58
+ try {
59
+ return execSync("codex --version 2>/dev/null", { timeout: 3000 }).toString().trim().split("\n")[0] || null;
60
+ } catch { return null; }
61
+ }
62
+
63
+ export function registerSettingsRoutes(api: Hono): void {
64
+ api.get("/settings", (c) => {
65
+ const settings = getSettings();
66
+ const claudeAuth = detectClaudeAuthStatus();
67
+ const codexAuth = detectCodexAuthStatus();
68
+ return c.json({
69
+ anthropicApiKeyConfigured: !!settings.anthropicApiKey.trim(),
70
+ anthropicModel: settings.anthropicModel || DEFAULT_ANTHROPIC_MODEL,
71
+ claudeCodeOAuthTokenConfigured: !!settings.claudeCodeOAuthToken.trim(),
72
+ openaiApiKeyConfigured: !!settings.openaiApiKey.trim(),
73
+ codexDeviceAuthConfigured: hasContainerCodexAuth(),
74
+ // Enhanced auth detection
75
+ claudeCliAuth: { ...claudeAuth, oauthTokenConfigured: !!settings.claudeCodeOAuthToken.trim(), cliVersion: detectClaudeVersion() },
76
+ codexCliAuth: { ...codexAuth, apiKeyConfigured: !!settings.openaiApiKey.trim(), cliVersion: detectCodexVersion() },
77
+ onboardingCompleted: settings.onboardingCompleted,
78
+ geminiApiKeyConfigured: !!settings.geminiApiKey.trim(),
79
+ geminiVoice: settings.geminiVoice || "Kore",
80
+ assistantName: settings.assistantName || "",
81
+ userName: settings.userName || "",
82
+ editorTabEnabled: settings.editorTabEnabled,
83
+ internalAiProvider: settings.internalAiProvider || "",
84
+ aiValidationEnabled: settings.aiValidationEnabled,
85
+ aiValidationAutoApprove: settings.aiValidationAutoApprove,
86
+ aiValidationAutoDeny: settings.aiValidationAutoDeny,
87
+ publicUrl: settings.publicUrl,
88
+ updateChannel: settings.updateChannel,
89
+ dockerAutoUpdate: settings.dockerAutoUpdate,
90
+ });
91
+ });
92
+
93
+ api.put("/settings", async (c) => {
94
+ const body = await c.req.json().catch(() => ({}));
95
+ if (body.anthropicApiKey !== undefined && typeof body.anthropicApiKey !== "string") {
96
+ return c.json({ error: "anthropicApiKey must be a string" }, 400);
97
+ }
98
+ if (body.anthropicModel !== undefined && typeof body.anthropicModel !== "string") {
99
+ return c.json({ error: "anthropicModel must be a string" }, 400);
100
+ }
101
+ if (body.geminiApiKey !== undefined && typeof body.geminiApiKey !== "string") {
102
+ return c.json({ error: "geminiApiKey must be a string" }, 400);
103
+ }
104
+ if (body.geminiVoice !== undefined && typeof body.geminiVoice !== "string") {
105
+ return c.json({ error: "geminiVoice must be a string" }, 400);
106
+ }
107
+ if (body.editorTabEnabled !== undefined && typeof body.editorTabEnabled !== "boolean") {
108
+ return c.json({ error: "editorTabEnabled must be a boolean" }, 400);
109
+ }
110
+ if (body.aiValidationEnabled !== undefined && typeof body.aiValidationEnabled !== "boolean") {
111
+ return c.json({ error: "aiValidationEnabled must be a boolean" }, 400);
112
+ }
113
+ if (body.aiValidationAutoApprove !== undefined && typeof body.aiValidationAutoApprove !== "boolean") {
114
+ return c.json({ error: "aiValidationAutoApprove must be a boolean" }, 400);
115
+ }
116
+ if (body.aiValidationAutoDeny !== undefined && typeof body.aiValidationAutoDeny !== "boolean") {
117
+ return c.json({ error: "aiValidationAutoDeny must be a boolean" }, 400);
118
+ }
119
+ if (body.publicUrl !== undefined) {
120
+ if (typeof body.publicUrl !== "string") {
121
+ return c.json({ error: "publicUrl must be a string" }, 400);
122
+ }
123
+ const trimmed = body.publicUrl.trim().replace(/\/+$/, "");
124
+ if (trimmed !== "" && !/^https?:\/\/.+/.test(trimmed)) {
125
+ return c.json({ error: "publicUrl must be a valid http/https URL" }, 400);
126
+ }
127
+ }
128
+ if (body.updateChannel !== undefined && body.updateChannel !== "stable" && body.updateChannel !== "prerelease") {
129
+ return c.json({ error: "updateChannel must be 'stable' or 'prerelease'" }, 400);
130
+ }
131
+ if (body.claudeCodeOAuthToken !== undefined && typeof body.claudeCodeOAuthToken !== "string") {
132
+ return c.json({ error: "claudeCodeOAuthToken must be a string" }, 400);
133
+ }
134
+ if (body.openaiApiKey !== undefined && typeof body.openaiApiKey !== "string") {
135
+ return c.json({ error: "openaiApiKey must be a string" }, 400);
136
+ }
137
+ if (body.onboardingCompleted !== undefined && typeof body.onboardingCompleted !== "boolean") {
138
+ return c.json({ error: "onboardingCompleted must be a boolean" }, 400);
139
+ }
140
+ if (body.dockerAutoUpdate !== undefined && typeof body.dockerAutoUpdate !== "boolean") {
141
+ return c.json({ error: "dockerAutoUpdate must be a boolean" }, 400);
142
+ }
143
+ const hasAnyField = body.anthropicApiKey !== undefined || body.anthropicModel !== undefined
144
+ || body.claudeCodeOAuthToken !== undefined || body.openaiApiKey !== undefined
145
+ || body.onboardingCompleted !== undefined
146
+ || body.geminiApiKey !== undefined || body.geminiVoice !== undefined || body.assistantName !== undefined || body.userName !== undefined
147
+ || body.editorTabEnabled !== undefined
148
+ || body.internalAiProvider !== undefined
149
+ || body.aiValidationEnabled !== undefined || body.aiValidationAutoApprove !== undefined
150
+ || body.aiValidationAutoDeny !== undefined
151
+ || body.publicUrl !== undefined
152
+ || body.updateChannel !== undefined
153
+ || body.dockerAutoUpdate !== undefined;
154
+ if (!hasAnyField) {
155
+ return c.json({ error: "At least one settings field is required" }, 400);
156
+ }
157
+
158
+ const settings = updateSettings({
159
+ anthropicApiKey:
160
+ typeof body.anthropicApiKey === "string"
161
+ ? body.anthropicApiKey.trim()
162
+ : undefined,
163
+ anthropicModel:
164
+ typeof body.anthropicModel === "string"
165
+ ? (body.anthropicModel.trim() || DEFAULT_ANTHROPIC_MODEL)
166
+ : undefined,
167
+ claudeCodeOAuthToken:
168
+ typeof body.claudeCodeOAuthToken === "string"
169
+ ? body.claudeCodeOAuthToken.trim()
170
+ : undefined,
171
+ openaiApiKey:
172
+ typeof body.openaiApiKey === "string"
173
+ ? body.openaiApiKey.trim()
174
+ : undefined,
175
+ onboardingCompleted:
176
+ typeof body.onboardingCompleted === "boolean"
177
+ ? body.onboardingCompleted
178
+ : undefined,
179
+ geminiApiKey:
180
+ typeof body.geminiApiKey === "string"
181
+ ? body.geminiApiKey.trim()
182
+ : undefined,
183
+ geminiVoice:
184
+ typeof body.geminiVoice === "string"
185
+ ? body.geminiVoice.trim()
186
+ : undefined,
187
+ assistantName:
188
+ typeof body.assistantName === "string"
189
+ ? body.assistantName.trim()
190
+ : undefined,
191
+ userName:
192
+ typeof body.userName === "string"
193
+ ? body.userName.trim()
194
+ : undefined,
195
+ editorTabEnabled:
196
+ typeof body.editorTabEnabled === "boolean"
197
+ ? body.editorTabEnabled
198
+ : undefined,
199
+ internalAiProvider:
200
+ typeof body.internalAiProvider === "string"
201
+ ? body.internalAiProvider.trim()
202
+ : undefined,
203
+ aiValidationEnabled:
204
+ typeof body.aiValidationEnabled === "boolean"
205
+ ? body.aiValidationEnabled
206
+ : undefined,
207
+ aiValidationAutoApprove:
208
+ typeof body.aiValidationAutoApprove === "boolean"
209
+ ? body.aiValidationAutoApprove
210
+ : undefined,
211
+ aiValidationAutoDeny:
212
+ typeof body.aiValidationAutoDeny === "boolean"
213
+ ? body.aiValidationAutoDeny
214
+ : undefined,
215
+ publicUrl:
216
+ typeof body.publicUrl === "string"
217
+ ? body.publicUrl.trim().replace(/\/+$/, "")
218
+ : undefined,
219
+ updateChannel:
220
+ body.updateChannel === "stable" || body.updateChannel === "prerelease"
221
+ ? (body.updateChannel as UpdateChannel)
222
+ : undefined,
223
+ dockerAutoUpdate:
224
+ typeof body.dockerAutoUpdate === "boolean"
225
+ ? body.dockerAutoUpdate
226
+ : undefined,
227
+ });
228
+
229
+ const claudeAuthAfter = detectClaudeAuthStatus();
230
+ const codexAuthAfter = detectCodexAuthStatus();
231
+ return c.json({
232
+ anthropicApiKeyConfigured: !!settings.anthropicApiKey.trim(),
233
+ anthropicModel: settings.anthropicModel || DEFAULT_ANTHROPIC_MODEL,
234
+ claudeCodeOAuthTokenConfigured: !!settings.claudeCodeOAuthToken.trim(),
235
+ openaiApiKeyConfigured: !!settings.openaiApiKey.trim(),
236
+ codexDeviceAuthConfigured: hasContainerCodexAuth(),
237
+ claudeCliAuth: { ...claudeAuthAfter, oauthTokenConfigured: !!settings.claudeCodeOAuthToken.trim(), cliVersion: detectClaudeVersion() },
238
+ codexCliAuth: { ...codexAuthAfter, apiKeyConfigured: !!settings.openaiApiKey.trim(), cliVersion: detectCodexVersion() },
239
+ onboardingCompleted: settings.onboardingCompleted,
240
+ geminiApiKeyConfigured: !!settings.geminiApiKey.trim(),
241
+ geminiVoice: settings.geminiVoice || "Kore",
242
+ assistantName: settings.assistantName || "",
243
+ userName: settings.userName || "",
244
+ editorTabEnabled: settings.editorTabEnabled,
245
+ internalAiProvider: settings.internalAiProvider || "",
246
+ aiValidationEnabled: settings.aiValidationEnabled,
247
+ aiValidationAutoApprove: settings.aiValidationAutoApprove,
248
+ aiValidationAutoDeny: settings.aiValidationAutoDeny,
249
+ publicUrl: settings.publicUrl,
250
+ updateChannel: settings.updateChannel,
251
+ dockerAutoUpdate: settings.dockerAutoUpdate,
252
+ });
253
+ });
254
+
255
+ api.post("/settings/anthropic/verify", async (c) => {
256
+ const body = await c.req.json().catch(() => ({} as { apiKey?: string }));
257
+ const apiKey = typeof body.apiKey === "string" ? body.apiKey.trim() : "";
258
+ if (!apiKey) {
259
+ return c.json({ valid: false, error: "API key is required" }, 400);
260
+ }
261
+
262
+ const controller = new AbortController();
263
+ const timer = setTimeout(() => controller.abort(), 10_000);
264
+
265
+ try {
266
+ const res = await fetch("https://api.anthropic.com/v1/models", {
267
+ headers: {
268
+ "x-api-key": apiKey,
269
+ "anthropic-version": "2023-06-01",
270
+ },
271
+ signal: controller.signal,
272
+ });
273
+
274
+ if (res.ok) {
275
+ return c.json({ valid: true });
276
+ }
277
+ return c.json({ valid: false, error: `API returned ${res.status}` });
278
+ } catch (err) {
279
+ const isAbort = err instanceof Error && err.name === "AbortError";
280
+ return c.json({ valid: false, error: isAbort ? "Request timed out" : "Request failed" });
281
+ } finally {
282
+ clearTimeout(timer);
283
+ }
284
+ });
285
+ }
@@ -0,0 +1,100 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readdir, readFile, writeFile, rm, mkdir } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { Hono } from "hono";
6
+
7
+ const SKILLS_DIR = join(homedir(), ".claude", "skills");
8
+
9
+ export function registerSkillRoutes(api: Hono): void {
10
+ api.get("/skills", async (c) => {
11
+ try {
12
+ if (!existsSync(SKILLS_DIR)) return c.json([]);
13
+ const entries = await readdir(SKILLS_DIR, { withFileTypes: true });
14
+ const skills = [];
15
+ for (const entry of entries) {
16
+ if (!entry.isDirectory()) continue;
17
+ const skillMdPath = join(SKILLS_DIR, entry.name, "SKILL.md");
18
+ if (!existsSync(skillMdPath)) continue;
19
+ const content = await readFile(skillMdPath, "utf-8");
20
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
21
+ let name = entry.name;
22
+ let description = "";
23
+ if (fmMatch) {
24
+ for (const line of fmMatch[1].split("\n")) {
25
+ const nameMatch = line.match(/^name:\s*(.+)/);
26
+ if (nameMatch) name = nameMatch[1].trim().replace(/^["']|["']$/g, "");
27
+ const descMatch = line.match(/^description:\s*["']?(.+?)["']?\s*$/);
28
+ if (descMatch) description = descMatch[1];
29
+ }
30
+ }
31
+ skills.push({ slug: entry.name, name, description, path: skillMdPath });
32
+ }
33
+ return c.json(skills);
34
+ } catch (e) {
35
+ return c.json({ error: String(e) }, 500);
36
+ }
37
+ });
38
+
39
+ api.get("/skills/:slug", async (c) => {
40
+ const slug = c.req.param("slug");
41
+ if (!slug || slug.includes("..") || slug.includes("/") || slug.includes("\\")) {
42
+ return c.json({ error: "Invalid slug" }, 400);
43
+ }
44
+ const skillMdPath = join(SKILLS_DIR, slug, "SKILL.md");
45
+ if (!existsSync(skillMdPath)) return c.json({ error: "Skill not found" }, 404);
46
+ const content = await readFile(skillMdPath, "utf-8");
47
+ return c.json({ slug, path: skillMdPath, content });
48
+ });
49
+
50
+ api.post("/skills", async (c) => {
51
+ const body = await c.req.json().catch(() => ({}));
52
+ const { name, description, content } = body;
53
+ if (!name || typeof name !== "string") {
54
+ return c.json({ error: "name is required" }, 400);
55
+ }
56
+
57
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
58
+ if (!slug) return c.json({ error: "Invalid name" }, 400);
59
+
60
+ const skillDir = join(SKILLS_DIR, slug);
61
+ const skillMdPath = join(skillDir, "SKILL.md");
62
+ if (existsSync(skillMdPath)) {
63
+ return c.json({ error: `Skill "${slug}" already exists` }, 409);
64
+ }
65
+
66
+ await mkdir(SKILLS_DIR, { recursive: true });
67
+ await mkdir(skillDir, { recursive: true });
68
+
69
+ const md = `---\nname: ${slug}\ndescription: ${JSON.stringify(description || `Skill: ${name}`)}\n---\n\n${content || `# ${name}\n\nDescribe what this skill does and how to use it.\n`}`;
70
+ await writeFile(skillMdPath, md, "utf-8");
71
+
72
+ return c.json({ slug, name, description: description || `Skill: ${name}`, path: skillMdPath });
73
+ });
74
+
75
+ api.put("/skills/:slug", async (c) => {
76
+ const slug = c.req.param("slug");
77
+ if (!slug || slug.includes("..") || slug.includes("/") || slug.includes("\\")) {
78
+ return c.json({ error: "Invalid slug" }, 400);
79
+ }
80
+ const skillMdPath = join(SKILLS_DIR, slug, "SKILL.md");
81
+ if (!existsSync(skillMdPath)) return c.json({ error: "Skill not found" }, 404);
82
+ const body = await c.req.json().catch(() => ({}));
83
+ if (typeof body.content !== "string") {
84
+ return c.json({ error: "content is required" }, 400);
85
+ }
86
+ await writeFile(skillMdPath, body.content, "utf-8");
87
+ return c.json({ ok: true, slug, path: skillMdPath });
88
+ });
89
+
90
+ api.delete("/skills/:slug", async (c) => {
91
+ const slug = c.req.param("slug");
92
+ if (!slug || slug.includes("..") || slug.includes("/") || slug.includes("\\")) {
93
+ return c.json({ error: "Invalid slug" }, 400);
94
+ }
95
+ const skillDir = join(SKILLS_DIR, slug);
96
+ if (!existsSync(skillDir)) return c.json({ error: "Skill not found" }, 404);
97
+ await rm(skillDir, { recursive: true, force: true });
98
+ return c.json({ ok: true, slug });
99
+ });
100
+ }