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.
- package/README.md +40 -0
- package/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
- package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
- package/dist/assets/CronManager-DDbz-yiT.js +1 -0
- package/dist/assets/HelpPage-DMfkzERp.js +1 -0
- package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
- package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
- package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
- package/dist/assets/Playground-Fc5cdc5p.js +109 -0
- package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
- package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
- package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
- package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
- package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
- package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
- package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
- package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
- package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
- package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
- package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
- package/dist/assets/index-C8M_PUmX.css +32 -0
- package/dist/assets/index-CEqZnThB.js +204 -0
- package/dist/assets/sw-register-LSSpj6RU.js +1 -0
- package/dist/assets/time-ago-B6r_l9u1.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon-32-original.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/heyhank-mascot-poster.png +0 -0
- package/dist/heyhank-mascot.mp4 +0 -0
- package/dist/heyhank-mascot.webm +0 -0
- package/dist/icon-192-original.png +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512-original.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +21 -0
- package/dist/logo-192.png +0 -0
- package/dist/logo-512.png +0 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo-original.png +0 -0
- package/dist/logo.png +0 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/push-sw.js +34 -0
- package/dist/sw.js +1 -0
- package/dist/workbox-d2a0910a.js +1 -0
- package/package.json +109 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.ts +357 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-timeout.ts +107 -0
- package/server/agent-types.ts +122 -0
- package/server/ai-validation-settings.ts +37 -0
- package/server/ai-validator.ts +181 -0
- package/server/anthropic-provider-migration.ts +48 -0
- package/server/assistant-store.ts +272 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-approve.ts +153 -0
- package/server/auto-namer.ts +36 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.ts +61 -0
- package/server/calendar-service.ts +434 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.ts +1303 -0
- package/server/codex-adapter.ts +3027 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.ts +27 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.ts +1053 -0
- package/server/cost-tracker.ts +222 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/email-service.ts +354 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +75 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.ts +170 -0
- package/server/federation/node-connection.ts +190 -0
- package/server/federation/node-manager.ts +366 -0
- package/server/federation/node-store.ts +86 -0
- package/server/federation/node-types.ts +121 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.ts +379 -0
- package/server/google-media.ts +342 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +491 -0
- package/server/internal-ai.ts +237 -0
- package/server/kill-switch.ts +99 -0
- package/server/llm-providers.ts +342 -0
- package/server/logger.ts +259 -0
- package/server/mcp-registry.ts +401 -0
- package/server/message-bus.ts +271 -0
- package/server/message-delivery.ts +128 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.ts +13 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/provider-manager.ts +111 -0
- package/server/provider-registry.ts +393 -0
- package/server/push-notifications.ts +221 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.ts +320 -0
- package/server/reminder-scheduler.ts +38 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.ts +264 -0
- package/server/routes/assistant-routes.ts +90 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/federation-routes.ts +76 -0
- package/server/routes/fs-routes.ts +622 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/llm-routes.ts +166 -0
- package/server/routes/media-routes.ts +135 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/platform-routes.ts +1379 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/provider-routes.ts +109 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +285 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/socialmedia-routes.ts +208 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes/telephony-routes.ts +259 -0
- package/server/routes.ts +1379 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.ts +457 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.ts +824 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +511 -0
- package/server/settings-manager.ts +149 -0
- package/server/shared-context.ts +157 -0
- package/server/socialmedia/adapter.ts +15 -0
- package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
- package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
- package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
- package/server/socialmedia/manager.ts +227 -0
- package/server/socialmedia/store.ts +98 -0
- package/server/socialmedia/types.ts +89 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/telephony/audio-bridge.ts +331 -0
- package/server/telephony/call-manager.ts +457 -0
- package/server/telephony/call-types.ts +108 -0
- package/server/telephony/telephony-store.ts +119 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.ts +192 -0
- package/server/usage-limits.ts +225 -0
- package/server/web-push.d.ts +51 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +121 -0
- package/server/ws-bridge.ts +1240 -0
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { readdir, readFile, stat, writeFile, mkdir } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import type { Hono } from "hono";
|
|
6
|
+
|
|
7
|
+
/** Sensitive paths that must never be exposed via the FS API */
|
|
8
|
+
const SENSITIVE_PATTERNS = [
|
|
9
|
+
"/.heyhank/assistant/", // email accounts with passwords
|
|
10
|
+
"/.heyhank/settings.json", // API keys
|
|
11
|
+
"/.heyhank/push-keys.json", // VAPID keys
|
|
12
|
+
"/.companion/assistant/", // legacy path — backward compat
|
|
13
|
+
"/.companion/settings.json", // legacy path — backward compat
|
|
14
|
+
"/.companion/push-keys.json", // legacy path — backward compat
|
|
15
|
+
"/email-accounts.json",
|
|
16
|
+
"/calendar-accounts.json",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/** Ensure a resolved path is within one of the allowed base directories.
|
|
20
|
+
* Returns the resolved absolute path, or null if it escapes all bases.
|
|
21
|
+
* Also blocks access to sensitive files (credentials, API keys). */
|
|
22
|
+
function guardPath(raw: string, allowedBases: string[]): string | null {
|
|
23
|
+
const abs = resolve(raw);
|
|
24
|
+
// Block sensitive files
|
|
25
|
+
for (const pattern of SENSITIVE_PATTERNS) {
|
|
26
|
+
if (abs.includes(pattern)) return null;
|
|
27
|
+
}
|
|
28
|
+
for (const base of allowedBases) {
|
|
29
|
+
if (abs === base || abs.startsWith(base + "/")) return abs;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function shellEscapeArg(value: string): string {
|
|
35
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function execCaptureStdout(
|
|
39
|
+
command: string,
|
|
40
|
+
options: { cwd: string; encoding: "utf-8"; timeout: number },
|
|
41
|
+
): string {
|
|
42
|
+
try {
|
|
43
|
+
return execSync(command, options);
|
|
44
|
+
} catch (err: unknown) {
|
|
45
|
+
const maybe = err as { stdout?: Buffer | string };
|
|
46
|
+
if (typeof maybe.stdout === "string") return maybe.stdout;
|
|
47
|
+
if (maybe.stdout && Buffer.isBuffer(maybe.stdout)) {
|
|
48
|
+
return maybe.stdout.toString("utf-8");
|
|
49
|
+
}
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveBranchDiffBases(repoRoot: string): string[] {
|
|
55
|
+
const options = { cwd: repoRoot, encoding: "utf-8", timeout: 5000 } as const;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const originHead = execSync("git symbolic-ref refs/remotes/origin/HEAD", options).trim();
|
|
59
|
+
const match = originHead.match(/^refs\/remotes\/origin\/(.+)$/);
|
|
60
|
+
if (match?.[1]) {
|
|
61
|
+
return [`origin/${match[1]}`, match[1]];
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// No remote HEAD ref available, fallback to common local defaults.
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const branches = execSync("git branch --list main master", options).trim();
|
|
69
|
+
if (branches.includes("main")) return ["main"];
|
|
70
|
+
if (branches.includes("master")) return ["master"];
|
|
71
|
+
} catch {
|
|
72
|
+
// Ignore and use a conservative fallback below.
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return ["main"];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function registerFsRoutes(api: Hono, opts?: { allowedBases?: string[] }): void {
|
|
79
|
+
// Allowed base directories for filesystem access.
|
|
80
|
+
// Requests must target paths under the user's home directory or process cwd.
|
|
81
|
+
const allowedBases = () => opts?.allowedBases ?? [homedir(), process.cwd()];
|
|
82
|
+
|
|
83
|
+
api.get("/fs/list", async (c) => {
|
|
84
|
+
const rawPath = c.req.query("path") || homedir();
|
|
85
|
+
const basePath = guardPath(rawPath, allowedBases());
|
|
86
|
+
if (!basePath) return c.json({ error: "Path outside allowed directories" }, 403);
|
|
87
|
+
try {
|
|
88
|
+
const entries = await readdir(basePath, { withFileTypes: true });
|
|
89
|
+
const dirs: { name: string; path: string }[] = [];
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
92
|
+
dirs.push({ name: entry.name, path: join(basePath, entry.name) });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
dirs.sort((a, b) => a.name.localeCompare(b.name));
|
|
96
|
+
return c.json({ path: basePath, dirs, home: homedir() });
|
|
97
|
+
} catch {
|
|
98
|
+
return c.json(
|
|
99
|
+
{
|
|
100
|
+
error: "Cannot read directory",
|
|
101
|
+
path: basePath,
|
|
102
|
+
dirs: [],
|
|
103
|
+
home: homedir(),
|
|
104
|
+
},
|
|
105
|
+
400,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
api.get("/fs/home", (c) => {
|
|
111
|
+
const home = homedir();
|
|
112
|
+
const cwd = process.cwd();
|
|
113
|
+
// Only report cwd if the user launched heyhank from a real project directory
|
|
114
|
+
// (not from the package root or the home directory itself)
|
|
115
|
+
const packageRoot = process.env.__HEYHANK_PACKAGE_ROOT || process.env.__COMPANION_PACKAGE_ROOT;
|
|
116
|
+
const isProjectDir =
|
|
117
|
+
cwd !== home &&
|
|
118
|
+
(!packageRoot || !cwd.startsWith(packageRoot));
|
|
119
|
+
return c.json({ home, cwd: isProjectDir ? cwd : home });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
api.get("/fs/tree", async (c) => {
|
|
123
|
+
const rawPath = c.req.query("path");
|
|
124
|
+
if (!rawPath) return c.json({ error: "path required" }, 400);
|
|
125
|
+
const basePath = guardPath(rawPath, allowedBases());
|
|
126
|
+
if (!basePath) return c.json({ error: "Path outside allowed directories" }, 403);
|
|
127
|
+
|
|
128
|
+
interface TreeNode {
|
|
129
|
+
name: string;
|
|
130
|
+
path: string;
|
|
131
|
+
type: "file" | "directory";
|
|
132
|
+
children?: TreeNode[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function buildTree(dir: string, depth: number): Promise<TreeNode[]> {
|
|
136
|
+
if (depth > 10) return [];
|
|
137
|
+
try {
|
|
138
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
139
|
+
const nodes: TreeNode[] = [];
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
142
|
+
const fullPath = join(dir, entry.name);
|
|
143
|
+
if (entry.isDirectory()) {
|
|
144
|
+
const children = await buildTree(fullPath, depth + 1);
|
|
145
|
+
nodes.push({
|
|
146
|
+
name: entry.name,
|
|
147
|
+
path: fullPath,
|
|
148
|
+
type: "directory",
|
|
149
|
+
children,
|
|
150
|
+
});
|
|
151
|
+
} else if (entry.isFile()) {
|
|
152
|
+
nodes.push({ name: entry.name, path: fullPath, type: "file" });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
nodes.sort((a, b) => {
|
|
156
|
+
if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
|
|
157
|
+
return a.name.localeCompare(b.name);
|
|
158
|
+
});
|
|
159
|
+
return nodes;
|
|
160
|
+
} catch {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const tree = await buildTree(basePath, 0);
|
|
166
|
+
return c.json({ path: basePath, tree });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
api.get("/fs/read", async (c) => {
|
|
170
|
+
const filePath = c.req.query("path");
|
|
171
|
+
if (!filePath) return c.json({ error: "path required" }, 400);
|
|
172
|
+
const absPath = guardPath(filePath, allowedBases());
|
|
173
|
+
if (!absPath) return c.json({ error: "Path outside allowed directories" }, 403);
|
|
174
|
+
try {
|
|
175
|
+
const info = await stat(absPath);
|
|
176
|
+
if (info.size > 2 * 1024 * 1024) {
|
|
177
|
+
return c.json({ error: "File too large (>2MB)" }, 413);
|
|
178
|
+
}
|
|
179
|
+
const content = await readFile(absPath, "utf-8");
|
|
180
|
+
return c.json({ path: absPath, content });
|
|
181
|
+
} catch (e: unknown) {
|
|
182
|
+
return c.json(
|
|
183
|
+
{ error: e instanceof Error ? e.message : "Cannot read file" },
|
|
184
|
+
404,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
api.get("/fs/raw", async (c) => {
|
|
190
|
+
const filePath = c.req.query("path");
|
|
191
|
+
if (!filePath) return c.json({ error: "path required" }, 400);
|
|
192
|
+
const absPath = guardPath(filePath, allowedBases());
|
|
193
|
+
if (!absPath) return c.json({ error: "Path outside allowed directories" }, 403);
|
|
194
|
+
try {
|
|
195
|
+
const info = await stat(absPath);
|
|
196
|
+
if (info.size > 10 * 1024 * 1024) {
|
|
197
|
+
return c.json({ error: "File too large (>10MB)" }, 413);
|
|
198
|
+
}
|
|
199
|
+
} catch (e: unknown) {
|
|
200
|
+
return c.json({ error: e instanceof Error ? e.message : "File not found" }, 404);
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
const buffer = await readFile(absPath);
|
|
204
|
+
const ext = absPath.split(".").pop()?.toLowerCase() ?? "";
|
|
205
|
+
const mimeMap: Record<string, string> = {
|
|
206
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
|
|
207
|
+
gif: "image/gif", webp: "image/webp", svg: "image/svg+xml",
|
|
208
|
+
avif: "image/avif", ico: "image/x-icon", bmp: "image/bmp",
|
|
209
|
+
tiff: "image/tiff", tif: "image/tiff",
|
|
210
|
+
};
|
|
211
|
+
const contentType = mimeMap[ext] || "application/octet-stream";
|
|
212
|
+
return new Response(buffer, {
|
|
213
|
+
headers: {
|
|
214
|
+
"Content-Type": contentType,
|
|
215
|
+
"Cache-Control": "private, max-age=60",
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
} catch (e: unknown) {
|
|
219
|
+
return c.json({ error: e instanceof Error ? e.message : "Cannot read file" }, 404);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
api.put("/fs/write", async (c) => {
|
|
224
|
+
const body = await c.req.json().catch(() => ({}));
|
|
225
|
+
const { path: filePath, content } = body;
|
|
226
|
+
if (!filePath || typeof content !== "string") {
|
|
227
|
+
return c.json({ error: "path and content required" }, 400);
|
|
228
|
+
}
|
|
229
|
+
const absPath = guardPath(filePath, allowedBases());
|
|
230
|
+
if (!absPath) return c.json({ error: "Path outside allowed directories" }, 403);
|
|
231
|
+
try {
|
|
232
|
+
await writeFile(absPath, content, "utf-8");
|
|
233
|
+
return c.json({ ok: true, path: absPath });
|
|
234
|
+
} catch (e: unknown) {
|
|
235
|
+
return c.json(
|
|
236
|
+
{ error: e instanceof Error ? e.message : "Cannot write file" },
|
|
237
|
+
500,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
api.get("/fs/diff", (c) => {
|
|
243
|
+
const filePath = c.req.query("path");
|
|
244
|
+
if (!filePath) return c.json({ error: "path required" }, 400);
|
|
245
|
+
const base = c.req.query("base");
|
|
246
|
+
const absPath = resolve(filePath);
|
|
247
|
+
try {
|
|
248
|
+
const repoRoot = execSync("git rev-parse --show-toplevel", {
|
|
249
|
+
cwd: dirname(absPath),
|
|
250
|
+
encoding: "utf-8",
|
|
251
|
+
timeout: 5000,
|
|
252
|
+
}).trim();
|
|
253
|
+
const relPath = execSync(`git -C "${repoRoot}" ls-files --full-name -- "${absPath}"`, {
|
|
254
|
+
encoding: "utf-8",
|
|
255
|
+
timeout: 5000,
|
|
256
|
+
}).trim() || absPath;
|
|
257
|
+
|
|
258
|
+
let diff = "";
|
|
259
|
+
|
|
260
|
+
if (base === "default-branch") {
|
|
261
|
+
const diffBases = resolveBranchDiffBases(repoRoot);
|
|
262
|
+
for (const b of diffBases) {
|
|
263
|
+
try {
|
|
264
|
+
diff = execCaptureStdout(`git diff ${b} -- "${relPath}"`, {
|
|
265
|
+
cwd: repoRoot,
|
|
266
|
+
encoding: "utf-8",
|
|
267
|
+
timeout: 5000,
|
|
268
|
+
});
|
|
269
|
+
break;
|
|
270
|
+
} catch {
|
|
271
|
+
// If a base ref is unavailable, try the next candidate.
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
try {
|
|
276
|
+
diff = execCaptureStdout(`git diff HEAD -- "${relPath}"`, {
|
|
277
|
+
cwd: repoRoot,
|
|
278
|
+
encoding: "utf-8",
|
|
279
|
+
timeout: 5000,
|
|
280
|
+
});
|
|
281
|
+
} catch {
|
|
282
|
+
// HEAD may not exist in a fresh repo with no commits; fall through to untracked handling.
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!diff.trim()) {
|
|
287
|
+
const untracked = execSync(`git ls-files --others --exclude-standard -- "${relPath}"`, {
|
|
288
|
+
cwd: repoRoot,
|
|
289
|
+
encoding: "utf-8",
|
|
290
|
+
timeout: 5000,
|
|
291
|
+
}).trim();
|
|
292
|
+
if (untracked) {
|
|
293
|
+
diff = execCaptureStdout(`git diff --no-index -- /dev/null "${absPath}"`, {
|
|
294
|
+
cwd: repoRoot,
|
|
295
|
+
encoding: "utf-8",
|
|
296
|
+
timeout: 5000,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return c.json({ path: absPath, diff });
|
|
302
|
+
} catch {
|
|
303
|
+
return c.json({ path: absPath, diff: "" });
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
/** List all files changed vs git base (name-status), including untracked new files.
|
|
308
|
+
* base="default-branch" (default): comprehensive — committed changes on this branch vs origin
|
|
309
|
+
* plus uncommitted local changes.
|
|
310
|
+
* base="last-commit": only uncommitted changes vs HEAD plus untracked files. */
|
|
311
|
+
api.get("/fs/changed-files", (c) => {
|
|
312
|
+
const cwd = c.req.query("cwd");
|
|
313
|
+
if (!cwd) return c.json({ error: "cwd required" }, 400);
|
|
314
|
+
const base = c.req.query("base"); // "last-commit" | "default-branch" | undefined
|
|
315
|
+
const resolvedCwd = resolve(cwd);
|
|
316
|
+
try {
|
|
317
|
+
const repoRoot = execSync("git rev-parse --show-toplevel", {
|
|
318
|
+
cwd: resolvedCwd,
|
|
319
|
+
encoding: "utf-8",
|
|
320
|
+
timeout: 5000,
|
|
321
|
+
}).trim();
|
|
322
|
+
|
|
323
|
+
// Map from abs path → status ("A", "M", "D"). Later writes win, but "A" is preserved.
|
|
324
|
+
const fileMap = new Map<string, string>();
|
|
325
|
+
|
|
326
|
+
const applyNameStatus = (nameStatus: string) => {
|
|
327
|
+
for (const line of nameStatus.split("\n")) {
|
|
328
|
+
const trimmed = line.trim();
|
|
329
|
+
if (!trimmed) continue;
|
|
330
|
+
const parts = trimmed.split("\t");
|
|
331
|
+
const statusChar = parts[0][0];
|
|
332
|
+
if (statusChar === "R" && parts[2]) {
|
|
333
|
+
if (!fileMap.has(join(repoRoot, parts[1]))) fileMap.set(join(repoRoot, parts[1]), "D");
|
|
334
|
+
fileMap.set(join(repoRoot, parts[2]), "A");
|
|
335
|
+
} else {
|
|
336
|
+
const abs = join(repoRoot, parts[1] || "");
|
|
337
|
+
if (!abs || abs === repoRoot) continue;
|
|
338
|
+
// Preserve "A" — don't downgrade to "M"
|
|
339
|
+
if (!(fileMap.get(abs) === "A" && statusChar === "M")) {
|
|
340
|
+
fileMap.set(abs, statusChar);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
if (base !== "last-commit") {
|
|
347
|
+
// default-branch (or unset): committed changes on this branch vs origin base
|
|
348
|
+
const diffBases = resolveBranchDiffBases(repoRoot);
|
|
349
|
+
for (const b of diffBases) {
|
|
350
|
+
try {
|
|
351
|
+
applyNameStatus(execCaptureStdout(`git diff ${shellEscapeArg(b)}...HEAD --name-status`, {
|
|
352
|
+
cwd: repoRoot, encoding: "utf-8", timeout: 5000,
|
|
353
|
+
}));
|
|
354
|
+
break;
|
|
355
|
+
} catch { /* try next */ }
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Always include uncommitted changes (staged + unstaged vs HEAD)
|
|
360
|
+
try {
|
|
361
|
+
applyNameStatus(execCaptureStdout("git diff HEAD --name-status", {
|
|
362
|
+
cwd: repoRoot, encoding: "utf-8", timeout: 5000,
|
|
363
|
+
}));
|
|
364
|
+
} catch { /* fresh repo */ }
|
|
365
|
+
|
|
366
|
+
// Always include untracked files not yet staged
|
|
367
|
+
try {
|
|
368
|
+
const untracked = execSync("git ls-files --others --exclude-standard", {
|
|
369
|
+
cwd: repoRoot, encoding: "utf-8", timeout: 5000,
|
|
370
|
+
}).trim();
|
|
371
|
+
for (const rel of untracked.split("\n")) {
|
|
372
|
+
if (rel.trim()) {
|
|
373
|
+
const abs = join(repoRoot, rel.trim());
|
|
374
|
+
if (!fileMap.has(abs)) fileMap.set(abs, "A");
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
} catch { /* ignore */ }
|
|
378
|
+
|
|
379
|
+
const files = [...fileMap.entries()].map(([path, status]) => ({ path, status }));
|
|
380
|
+
return c.json({ files });
|
|
381
|
+
} catch {
|
|
382
|
+
return c.json({ files: [] });
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
/** Find CLAUDE.md files for a project (root + .claude/) */
|
|
387
|
+
api.get("/fs/claude-md", async (c) => {
|
|
388
|
+
const cwd = c.req.query("cwd");
|
|
389
|
+
if (!cwd) return c.json({ error: "cwd required" }, 400);
|
|
390
|
+
const resolvedCwd = resolve(cwd);
|
|
391
|
+
|
|
392
|
+
// Find the git repo root so we can search upward from cwd.
|
|
393
|
+
let repoRoot: string | null = null;
|
|
394
|
+
try {
|
|
395
|
+
repoRoot = execSync("git rev-parse --show-toplevel", {
|
|
396
|
+
cwd: resolvedCwd,
|
|
397
|
+
encoding: "utf-8",
|
|
398
|
+
timeout: 3000,
|
|
399
|
+
}).trim();
|
|
400
|
+
} catch {
|
|
401
|
+
// Not a git repo — only search the exact cwd
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Collect candidate directories: cwd, then each parent up to repo root.
|
|
405
|
+
const searchDirs: string[] = [];
|
|
406
|
+
let dir = resolvedCwd;
|
|
407
|
+
const stop = repoRoot ? resolve(repoRoot) : resolvedCwd;
|
|
408
|
+
while (true) {
|
|
409
|
+
searchDirs.push(dir);
|
|
410
|
+
if (dir === stop) break;
|
|
411
|
+
const parent = dirname(dir);
|
|
412
|
+
if (parent === dir) break; // filesystem root
|
|
413
|
+
dir = parent;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Check CLAUDE.md and .claude/CLAUDE.md in each directory.
|
|
417
|
+
const seen = new Set<string>();
|
|
418
|
+
const files: { path: string; content: string }[] = [];
|
|
419
|
+
for (const d of searchDirs) {
|
|
420
|
+
for (const rel of ["CLAUDE.md", join(".claude", "CLAUDE.md")]) {
|
|
421
|
+
const p = join(d, rel);
|
|
422
|
+
if (seen.has(p)) continue;
|
|
423
|
+
seen.add(p);
|
|
424
|
+
try {
|
|
425
|
+
const content = await readFile(p, "utf-8");
|
|
426
|
+
files.push({ path: p, content });
|
|
427
|
+
} catch {
|
|
428
|
+
// file doesn't exist — skip
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return c.json({ cwd: resolvedCwd, files });
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
api.get("/fs/claude-config", async (c) => {
|
|
437
|
+
const cwd = c.req.query("cwd");
|
|
438
|
+
if (!cwd) return c.json({ error: "cwd required" }, 400);
|
|
439
|
+
const resolvedCwd = resolve(cwd);
|
|
440
|
+
|
|
441
|
+
// Find repo root
|
|
442
|
+
let repoRoot: string | null = null;
|
|
443
|
+
try {
|
|
444
|
+
repoRoot = execSync("git rev-parse --show-toplevel", {
|
|
445
|
+
cwd: resolvedCwd,
|
|
446
|
+
encoding: "utf-8",
|
|
447
|
+
timeout: 3000,
|
|
448
|
+
}).trim();
|
|
449
|
+
} catch {
|
|
450
|
+
// Not a git repo
|
|
451
|
+
}
|
|
452
|
+
const projectRoot = repoRoot ? resolve(repoRoot) : resolvedCwd;
|
|
453
|
+
|
|
454
|
+
// ── Project-level items ─────────────────────────────────────────────
|
|
455
|
+
// CLAUDE.md files — reuse walk-up logic
|
|
456
|
+
const searchDirs: string[] = [];
|
|
457
|
+
let dir = resolvedCwd;
|
|
458
|
+
const stop = projectRoot;
|
|
459
|
+
while (true) {
|
|
460
|
+
searchDirs.push(dir);
|
|
461
|
+
if (dir === stop) break;
|
|
462
|
+
const parent = dirname(dir);
|
|
463
|
+
if (parent === dir) break;
|
|
464
|
+
dir = parent;
|
|
465
|
+
}
|
|
466
|
+
const seen = new Set<string>();
|
|
467
|
+
const projectClaudeMd: { path: string; content: string }[] = [];
|
|
468
|
+
for (const d of searchDirs) {
|
|
469
|
+
for (const rel of ["CLAUDE.md", join(".claude", "CLAUDE.md")]) {
|
|
470
|
+
const p = join(d, rel);
|
|
471
|
+
if (seen.has(p)) continue;
|
|
472
|
+
seen.add(p);
|
|
473
|
+
try {
|
|
474
|
+
const content = await readFile(p, "utf-8");
|
|
475
|
+
projectClaudeMd.push({ path: p, content });
|
|
476
|
+
} catch {
|
|
477
|
+
// file doesn't exist
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// settings.json / settings.local.json
|
|
483
|
+
const claudeDir = join(projectRoot, ".claude");
|
|
484
|
+
let projectSettings: { path: string; content: string } | null = null;
|
|
485
|
+
let projectSettingsLocal: { path: string; content: string } | null = null;
|
|
486
|
+
try {
|
|
487
|
+
const p = join(claudeDir, "settings.json");
|
|
488
|
+
projectSettings = { path: p, content: await readFile(p, "utf-8") };
|
|
489
|
+
} catch { /* missing */ }
|
|
490
|
+
try {
|
|
491
|
+
const p = join(claudeDir, "settings.local.json");
|
|
492
|
+
projectSettingsLocal = { path: p, content: await readFile(p, "utf-8") };
|
|
493
|
+
} catch { /* missing */ }
|
|
494
|
+
|
|
495
|
+
// commands/*.md
|
|
496
|
+
const projectCommands: { name: string; path: string }[] = [];
|
|
497
|
+
try {
|
|
498
|
+
const commandsDir = join(claudeDir, "commands");
|
|
499
|
+
const entries = await readdir(commandsDir, { withFileTypes: true });
|
|
500
|
+
for (const e of entries) {
|
|
501
|
+
if (e.isFile() && e.name.endsWith(".md")) {
|
|
502
|
+
projectCommands.push({ name: e.name.replace(/\.md$/, ""), path: join(commandsDir, e.name) });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
projectCommands.sort((a, b) => a.name.localeCompare(b.name));
|
|
506
|
+
} catch { /* missing dir */ }
|
|
507
|
+
|
|
508
|
+
// ── User-level items ────────────────────────────────────────────────
|
|
509
|
+
const userRoot = join(homedir(), ".claude");
|
|
510
|
+
|
|
511
|
+
// User CLAUDE.md
|
|
512
|
+
let userClaudeMd: { path: string; content: string } | null = null;
|
|
513
|
+
try {
|
|
514
|
+
const p = join(userRoot, "CLAUDE.md");
|
|
515
|
+
userClaudeMd = { path: p, content: await readFile(p, "utf-8") };
|
|
516
|
+
} catch { /* missing */ }
|
|
517
|
+
|
|
518
|
+
// Skills
|
|
519
|
+
const userSkills: { slug: string; name: string; description: string; path: string }[] = [];
|
|
520
|
+
try {
|
|
521
|
+
const skillsDir = join(userRoot, "skills");
|
|
522
|
+
const entries = await readdir(skillsDir, { withFileTypes: true });
|
|
523
|
+
for (const entry of entries) {
|
|
524
|
+
if (!entry.isDirectory()) continue;
|
|
525
|
+
const skillMdPath = join(skillsDir, entry.name, "SKILL.md");
|
|
526
|
+
try {
|
|
527
|
+
const content = await readFile(skillMdPath, "utf-8");
|
|
528
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
529
|
+
let name = entry.name;
|
|
530
|
+
let description = "";
|
|
531
|
+
if (fmMatch) {
|
|
532
|
+
for (const line of fmMatch[1].split("\n")) {
|
|
533
|
+
const nameMatch = line.match(/^name:\s*(.+)/);
|
|
534
|
+
if (nameMatch) name = nameMatch[1].trim().replace(/^["']|["']$/g, "");
|
|
535
|
+
const descMatch = line.match(/^description:\s*["']?(.+?)["']?\s*$/);
|
|
536
|
+
if (descMatch) description = descMatch[1];
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
userSkills.push({ slug: entry.name, name, description, path: skillMdPath });
|
|
540
|
+
} catch { /* no SKILL.md */ }
|
|
541
|
+
}
|
|
542
|
+
userSkills.sort((a, b) => a.name.localeCompare(b.name));
|
|
543
|
+
} catch { /* missing dir */ }
|
|
544
|
+
|
|
545
|
+
// Agents
|
|
546
|
+
const userAgents: { name: string; path: string }[] = [];
|
|
547
|
+
try {
|
|
548
|
+
const agentsDir = join(userRoot, "agents");
|
|
549
|
+
const entries = await readdir(agentsDir, { withFileTypes: true });
|
|
550
|
+
for (const e of entries) {
|
|
551
|
+
if (e.isFile() && e.name.endsWith(".md")) {
|
|
552
|
+
userAgents.push({ name: e.name.replace(/\.md$/, ""), path: join(agentsDir, e.name) });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
userAgents.sort((a, b) => a.name.localeCompare(b.name));
|
|
556
|
+
} catch { /* missing dir */ }
|
|
557
|
+
|
|
558
|
+
// User settings.json
|
|
559
|
+
let userSettings: { path: string; content: string } | null = null;
|
|
560
|
+
try {
|
|
561
|
+
const p = join(userRoot, "settings.json");
|
|
562
|
+
userSettings = { path: p, content: await readFile(p, "utf-8") };
|
|
563
|
+
} catch { /* missing */ }
|
|
564
|
+
|
|
565
|
+
// User commands/*.md
|
|
566
|
+
const userCommands: { name: string; path: string }[] = [];
|
|
567
|
+
try {
|
|
568
|
+
const commandsDir = join(userRoot, "commands");
|
|
569
|
+
const entries = await readdir(commandsDir, { withFileTypes: true });
|
|
570
|
+
for (const e of entries) {
|
|
571
|
+
if (e.isFile() && e.name.endsWith(".md")) {
|
|
572
|
+
userCommands.push({ name: e.name.replace(/\.md$/, ""), path: join(commandsDir, e.name) });
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
userCommands.sort((a, b) => a.name.localeCompare(b.name));
|
|
576
|
+
} catch { /* missing dir */ }
|
|
577
|
+
|
|
578
|
+
return c.json({
|
|
579
|
+
project: {
|
|
580
|
+
root: projectRoot,
|
|
581
|
+
claudeMd: projectClaudeMd,
|
|
582
|
+
settings: projectSettings,
|
|
583
|
+
settingsLocal: projectSettingsLocal,
|
|
584
|
+
commands: projectCommands,
|
|
585
|
+
},
|
|
586
|
+
user: {
|
|
587
|
+
root: userRoot,
|
|
588
|
+
claudeMd: userClaudeMd,
|
|
589
|
+
skills: userSkills,
|
|
590
|
+
agents: userAgents,
|
|
591
|
+
settings: userSettings,
|
|
592
|
+
commands: userCommands,
|
|
593
|
+
},
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
api.put("/fs/claude-md", async (c) => {
|
|
598
|
+
const body = await c.req.json().catch(() => ({}));
|
|
599
|
+
const { path: filePath, content } = body;
|
|
600
|
+
if (!filePath || typeof content !== "string") {
|
|
601
|
+
return c.json({ error: "path and content required" }, 400);
|
|
602
|
+
}
|
|
603
|
+
const base = filePath.split("/").pop();
|
|
604
|
+
if (base !== "CLAUDE.md") {
|
|
605
|
+
return c.json({ error: "Can only write CLAUDE.md files" }, 400);
|
|
606
|
+
}
|
|
607
|
+
const absPath = resolve(filePath);
|
|
608
|
+
if (!absPath.endsWith("/CLAUDE.md") && !absPath.endsWith("/.claude/CLAUDE.md")) {
|
|
609
|
+
return c.json({ error: "Invalid CLAUDE.md path" }, 400);
|
|
610
|
+
}
|
|
611
|
+
try {
|
|
612
|
+
await mkdir(dirname(absPath), { recursive: true });
|
|
613
|
+
await writeFile(absPath, content, "utf-8");
|
|
614
|
+
return c.json({ ok: true, path: absPath });
|
|
615
|
+
} catch (e: unknown) {
|
|
616
|
+
return c.json(
|
|
617
|
+
{ error: e instanceof Error ? e.message : "Cannot write file" },
|
|
618
|
+
500,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
}
|