anywhere-ai 0.0.1
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/.env +4 -0
- package/dist/cli.js +99 -0
- package/dist/server-5PJ4IEOJ.js +459 -0
- package/package.json +36 -0
- package/src/chats.ts +203 -0
- package/src/cli.ts +114 -0
- package/src/routes/chats/index.ts +257 -0
- package/src/routes/git/index.ts +156 -0
- package/src/server.ts +34 -0
- package/test.js +12 -0
- package/tsconfig.json +13 -0
package/.env
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// src/cli.ts
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import { readFileSync } from "fs";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import crypto from "crypto";
|
|
7
|
+
import { spawnSync, execSync } from "child_process";
|
|
8
|
+
var ANYWHERE_DIR = path.join(os.homedir(), ".anywhere");
|
|
9
|
+
var CONFIG_PATH = path.join(ANYWHERE_DIR, "config.json");
|
|
10
|
+
async function createConfigFile() {
|
|
11
|
+
try {
|
|
12
|
+
await fs.access(CONFIG_PATH);
|
|
13
|
+
console.log("Config.json already exists");
|
|
14
|
+
} catch (error) {
|
|
15
|
+
console.log("Config.json does not exists");
|
|
16
|
+
const token = crypto.randomBytes(16).toString("hex");
|
|
17
|
+
await fs.writeFile(
|
|
18
|
+
CONFIG_PATH,
|
|
19
|
+
JSON.stringify({ authToken: token }, null, 2)
|
|
20
|
+
);
|
|
21
|
+
console.log("Config.json created");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function isGhInstalled() {
|
|
25
|
+
try {
|
|
26
|
+
execSync("which gh", { stdio: "ignore" });
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function ensureGhInstalled() {
|
|
33
|
+
if (isGhInstalled()) return;
|
|
34
|
+
const platform = os.platform();
|
|
35
|
+
console.log("gh CLI not found. Installing...");
|
|
36
|
+
if (platform === "darwin") {
|
|
37
|
+
const result = spawnSync("brew", ["install", "gh"], { stdio: "inherit" });
|
|
38
|
+
if (result.status !== 0) {
|
|
39
|
+
console.error(
|
|
40
|
+
"Failed to install gh via Homebrew. Install manually: https://cli.github.com"
|
|
41
|
+
);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
} else if (platform === "linux") {
|
|
45
|
+
const commands = [
|
|
46
|
+
"curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg",
|
|
47
|
+
'echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null',
|
|
48
|
+
"sudo apt update",
|
|
49
|
+
"sudo apt install -y gh"
|
|
50
|
+
];
|
|
51
|
+
for (const cmd of commands) {
|
|
52
|
+
const result = spawnSync("sh", ["-c", cmd], { stdio: "inherit" });
|
|
53
|
+
if (result.status !== 0) {
|
|
54
|
+
console.error(`Failed during: ${cmd}`);
|
|
55
|
+
console.error("Install gh manually: https://cli.github.com");
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
console.error(
|
|
61
|
+
`Unsupported platform: ${platform}. Install gh manually: https://cli.github.com`
|
|
62
|
+
);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
if (!isGhInstalled()) {
|
|
66
|
+
console.error("gh installation completed but binary not found in PATH.");
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
console.log("gh CLI installed successfully.");
|
|
70
|
+
}
|
|
71
|
+
function isGhAuthed() {
|
|
72
|
+
try {
|
|
73
|
+
execSync("gh auth status", { encoding: "utf-8" });
|
|
74
|
+
return true;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function ensureGhAuth() {
|
|
80
|
+
if (isGhAuthed()) {
|
|
81
|
+
console.log("Gh authenticated");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
console.log("gh not authenticated, Launching gh auth login...");
|
|
85
|
+
const result = spawnSync("gh", ["auth", "login"], {
|
|
86
|
+
stdio: "inherit"
|
|
87
|
+
});
|
|
88
|
+
if (result.status !== 0) {
|
|
89
|
+
console.error("GitHub login failed");
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
await fs.mkdir(ANYWHERE_DIR, { recursive: true });
|
|
94
|
+
await createConfigFile();
|
|
95
|
+
ensureGhInstalled();
|
|
96
|
+
ensureGhAuth();
|
|
97
|
+
var config = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
98
|
+
process.env.AUTH_TOKEN = config.authToken;
|
|
99
|
+
import("./server-5PJ4IEOJ.js");
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
// src/server.ts
|
|
2
|
+
import { Hono as Hono3 } from "hono";
|
|
3
|
+
import { serve } from "@hono/node-server";
|
|
4
|
+
import { cors } from "hono/cors";
|
|
5
|
+
|
|
6
|
+
// src/routes/chats/index.ts
|
|
7
|
+
import { Hono } from "hono";
|
|
8
|
+
import os from "os";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { readdir, readFile } from "fs/promises";
|
|
11
|
+
import { streamSSE } from "hono/streaming";
|
|
12
|
+
|
|
13
|
+
// src/chats.ts
|
|
14
|
+
import {
|
|
15
|
+
unstable_v2_createSession,
|
|
16
|
+
unstable_v2_resumeSession
|
|
17
|
+
} from "@anthropic-ai/claude-agent-sdk";
|
|
18
|
+
var sessions = /* @__PURE__ */ new Map();
|
|
19
|
+
var activeSessions = /* @__PURE__ */ new Set();
|
|
20
|
+
var pendingPermissions = /* @__PURE__ */ new Map();
|
|
21
|
+
function getAssistantText(msg) {
|
|
22
|
+
if (msg.type !== "assistant") return null;
|
|
23
|
+
return msg.message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
24
|
+
}
|
|
25
|
+
var permissionCallbacks = /* @__PURE__ */ new Map();
|
|
26
|
+
function setPermissionCallback(sessionId, cb) {
|
|
27
|
+
if (cb) {
|
|
28
|
+
permissionCallbacks.set(sessionId, cb);
|
|
29
|
+
} else {
|
|
30
|
+
permissionCallbacks.delete(sessionId);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
var permReqCounter = 0;
|
|
34
|
+
function makeCanUseTool(sessionHint, permissionMode) {
|
|
35
|
+
return async (toolName, input, options) => {
|
|
36
|
+
console.log(`[canUseTool] tool=${toolName} reason=${options.decisionReason ?? "none"} mode=${permissionMode}`);
|
|
37
|
+
const requestId = `perm_${++permReqCounter}_${Date.now()}`;
|
|
38
|
+
const req = {
|
|
39
|
+
requestId,
|
|
40
|
+
toolName,
|
|
41
|
+
input,
|
|
42
|
+
description: input.description,
|
|
43
|
+
decisionReason: options.decisionReason,
|
|
44
|
+
suggestions: options.suggestions
|
|
45
|
+
};
|
|
46
|
+
const cb = permissionCallbacks.get(sessionHint);
|
|
47
|
+
if (cb) cb(req);
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
pendingPermissions.set(requestId, resolve);
|
|
50
|
+
const timeout = setTimeout(() => {
|
|
51
|
+
if (pendingPermissions.has(requestId)) {
|
|
52
|
+
pendingPermissions.delete(requestId);
|
|
53
|
+
resolve({ behavior: "deny", message: "Permission request timed out" });
|
|
54
|
+
}
|
|
55
|
+
}, 5 * 60 * 1e3);
|
|
56
|
+
options.signal.addEventListener("abort", () => {
|
|
57
|
+
clearTimeout(timeout);
|
|
58
|
+
if (pendingPermissions.has(requestId)) {
|
|
59
|
+
pendingPermissions.delete(requestId);
|
|
60
|
+
resolve({ behavior: "deny", message: "Request aborted" });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
const origResolve = resolve;
|
|
64
|
+
pendingPermissions.set(requestId, (result) => {
|
|
65
|
+
clearTimeout(timeout);
|
|
66
|
+
pendingPermissions.delete(requestId);
|
|
67
|
+
origResolve(result);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
var createChat = async ({
|
|
73
|
+
model,
|
|
74
|
+
permission
|
|
75
|
+
}) => {
|
|
76
|
+
const tempId = `temp_${Date.now()}`;
|
|
77
|
+
const mode = permission || "acceptEdits";
|
|
78
|
+
const session = unstable_v2_createSession({
|
|
79
|
+
model: model || "claude-opus-4-6",
|
|
80
|
+
permissionMode: mode,
|
|
81
|
+
canUseTool: makeCanUseTool(tempId, mode)
|
|
82
|
+
});
|
|
83
|
+
return { session, tempId };
|
|
84
|
+
};
|
|
85
|
+
var sendMessage = async ({
|
|
86
|
+
prompt,
|
|
87
|
+
session
|
|
88
|
+
}) => {
|
|
89
|
+
await session.send(prompt);
|
|
90
|
+
};
|
|
91
|
+
var getSession = async ({
|
|
92
|
+
sessionId,
|
|
93
|
+
model,
|
|
94
|
+
permission
|
|
95
|
+
}) => {
|
|
96
|
+
let session = sessions.get(sessionId);
|
|
97
|
+
if (session && (model || permission)) {
|
|
98
|
+
session.close();
|
|
99
|
+
sessions.delete(sessionId);
|
|
100
|
+
session = void 0;
|
|
101
|
+
}
|
|
102
|
+
if (!session) {
|
|
103
|
+
const mode = permission || "acceptEdits";
|
|
104
|
+
session = unstable_v2_resumeSession(sessionId, {
|
|
105
|
+
model: model || "claude-opus-4-6",
|
|
106
|
+
permissionMode: mode,
|
|
107
|
+
canUseTool: makeCanUseTool(sessionId, mode)
|
|
108
|
+
});
|
|
109
|
+
sessions.set(sessionId, session);
|
|
110
|
+
}
|
|
111
|
+
return session;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// src/routes/chats/index.ts
|
|
115
|
+
var chats = new Hono();
|
|
116
|
+
chats.post("/new", async (c) => {
|
|
117
|
+
const { prompt, model, permission } = await c.req.json();
|
|
118
|
+
if (!prompt) return c.json({ error: "Prompt is required" }, 400);
|
|
119
|
+
try {
|
|
120
|
+
const { session, tempId } = await createChat({ model, permission });
|
|
121
|
+
await sendMessage({ prompt, session });
|
|
122
|
+
return streamSSE(c, async (stream) => {
|
|
123
|
+
let finalSessionId = "";
|
|
124
|
+
const sendPerm = async (req) => {
|
|
125
|
+
try {
|
|
126
|
+
await stream.writeSSE({
|
|
127
|
+
data: JSON.stringify({
|
|
128
|
+
type: "permission_request",
|
|
129
|
+
...req,
|
|
130
|
+
sessionId: finalSessionId || "pending"
|
|
131
|
+
})
|
|
132
|
+
});
|
|
133
|
+
} catch (e) {
|
|
134
|
+
console.error("Failed to send permission SSE:", e);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
setPermissionCallback(tempId, sendPerm);
|
|
138
|
+
try {
|
|
139
|
+
for await (const msg of session.stream()) {
|
|
140
|
+
if (msg.session_id && !finalSessionId) {
|
|
141
|
+
finalSessionId = msg.session_id;
|
|
142
|
+
activeSessions.add(finalSessionId);
|
|
143
|
+
setPermissionCallback(tempId, null);
|
|
144
|
+
setPermissionCallback(finalSessionId, sendPerm);
|
|
145
|
+
}
|
|
146
|
+
const text = getAssistantText(msg);
|
|
147
|
+
if (text)
|
|
148
|
+
await stream.writeSSE({
|
|
149
|
+
data: JSON.stringify({ response: text.trim(), sessionId: msg.session_id })
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (finalSessionId) sessions.set(finalSessionId, session);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
console.error(err);
|
|
155
|
+
await stream.writeSSE({ data: JSON.stringify({ error: "Stream error" }) });
|
|
156
|
+
} finally {
|
|
157
|
+
setPermissionCallback(tempId, null);
|
|
158
|
+
if (finalSessionId) {
|
|
159
|
+
setPermissionCallback(finalSessionId, null);
|
|
160
|
+
activeSessions.delete(finalSessionId);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
} catch (error) {
|
|
165
|
+
console.error(error);
|
|
166
|
+
return c.json({ error: "Failed to create new chat" }, 400);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
chats.post("/:id/message", async (c) => {
|
|
170
|
+
const { prompt, model, permission } = await c.req.json();
|
|
171
|
+
const sessionId = c.req.param("id");
|
|
172
|
+
if (!prompt || !sessionId || /[\/\\]/.test(sessionId))
|
|
173
|
+
return c.json({ error: "Invalid request" }, 400);
|
|
174
|
+
try {
|
|
175
|
+
const session = await getSession({ sessionId, model, permission });
|
|
176
|
+
if (!session) return c.json({ error: "Session is required" }, 400);
|
|
177
|
+
await sendMessage({ prompt, session });
|
|
178
|
+
return streamSSE(c, async (stream) => {
|
|
179
|
+
activeSessions.add(sessionId);
|
|
180
|
+
setPermissionCallback(sessionId, async (req) => {
|
|
181
|
+
try {
|
|
182
|
+
await stream.writeSSE({
|
|
183
|
+
data: JSON.stringify({
|
|
184
|
+
type: "permission_request",
|
|
185
|
+
...req,
|
|
186
|
+
sessionId
|
|
187
|
+
})
|
|
188
|
+
});
|
|
189
|
+
} catch (e) {
|
|
190
|
+
console.error("Failed to send permission SSE:", e);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
try {
|
|
194
|
+
for await (const msg of session.stream()) {
|
|
195
|
+
const text = getAssistantText(msg);
|
|
196
|
+
if (text)
|
|
197
|
+
await stream.writeSSE({
|
|
198
|
+
data: JSON.stringify({
|
|
199
|
+
response: text?.trim() || null,
|
|
200
|
+
sessionId: msg.session_id
|
|
201
|
+
})
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
} catch (err) {
|
|
205
|
+
console.error(err);
|
|
206
|
+
await stream.writeSSE({ data: JSON.stringify({ error: "Stream error" }) });
|
|
207
|
+
} finally {
|
|
208
|
+
setPermissionCallback(sessionId, null);
|
|
209
|
+
activeSessions.delete(sessionId);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
} catch (error) {
|
|
213
|
+
console.error(error);
|
|
214
|
+
return c.json({ error: "Failed to send message" }, 400);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
chats.post("/:id/permission", async (c) => {
|
|
218
|
+
const { requestId, behavior, updatedPermissions } = await c.req.json();
|
|
219
|
+
if (!requestId || !behavior)
|
|
220
|
+
return c.json({ error: "requestId and behavior are required" }, 400);
|
|
221
|
+
const resolve = pendingPermissions.get(requestId);
|
|
222
|
+
if (!resolve)
|
|
223
|
+
return c.json({ error: "No pending permission request found" }, 404);
|
|
224
|
+
if (behavior === "allow") {
|
|
225
|
+
resolve({
|
|
226
|
+
behavior: "allow",
|
|
227
|
+
...updatedPermissions ? { updatedPermissions } : {}
|
|
228
|
+
});
|
|
229
|
+
} else {
|
|
230
|
+
resolve({ behavior: "deny", message: "User denied permission" });
|
|
231
|
+
}
|
|
232
|
+
return c.json({ ok: true });
|
|
233
|
+
});
|
|
234
|
+
chats.get("/", async (c) => {
|
|
235
|
+
try {
|
|
236
|
+
const cwdSlug = process.cwd().replaceAll("/", "-");
|
|
237
|
+
const chatsDir = path.join(os.homedir(), ".claude", "projects", cwdSlug);
|
|
238
|
+
const files = (await readdir(chatsDir)).filter((f) => f.endsWith(".jsonl"));
|
|
239
|
+
const result = await Promise.all(
|
|
240
|
+
files.map(async (file) => {
|
|
241
|
+
const filePath = path.join(chatsDir, file);
|
|
242
|
+
const content = await readFile(filePath, "utf-8");
|
|
243
|
+
const lines = content.split("\n").filter(Boolean);
|
|
244
|
+
const sessionId = file.replace(".jsonl", "");
|
|
245
|
+
let preview = "";
|
|
246
|
+
let timestamp = "";
|
|
247
|
+
for (const line of lines) {
|
|
248
|
+
const obj = JSON.parse(line);
|
|
249
|
+
if (obj.type === "user" && !obj.isMeta) {
|
|
250
|
+
const content2 = obj.message.content;
|
|
251
|
+
if (typeof content2 === "string") {
|
|
252
|
+
preview = content2.slice(0, 100);
|
|
253
|
+
} else {
|
|
254
|
+
preview = content2.filter((b) => b.type === "text").map((b) => b.text).join("").slice(0, 100);
|
|
255
|
+
}
|
|
256
|
+
timestamp = obj.timestamp;
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return { sessionId, text: preview, timestamp, isActive: activeSessions.has(sessionId) };
|
|
261
|
+
})
|
|
262
|
+
);
|
|
263
|
+
return c.json({ result });
|
|
264
|
+
} catch (error) {
|
|
265
|
+
return c.json({ result: [] });
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
chats.get("/:id", async (c) => {
|
|
269
|
+
const sessionId = c.req.param("id");
|
|
270
|
+
if (!sessionId || /[\/\\]/.test(sessionId))
|
|
271
|
+
return c.json({ error: "Invalid session id" }, 400);
|
|
272
|
+
try {
|
|
273
|
+
const cwdSlug = process.cwd().replaceAll("/", "-");
|
|
274
|
+
const chatsDir = path.join(os.homedir(), ".claude", "projects", cwdSlug);
|
|
275
|
+
const file = path.join(chatsDir, `${sessionId}.jsonl`);
|
|
276
|
+
const content = await readFile(file, "utf-8");
|
|
277
|
+
const lines = content.split("\n").filter(Boolean);
|
|
278
|
+
const parsed = lines.map((line) => JSON.parse(line));
|
|
279
|
+
let model;
|
|
280
|
+
let permissionMode;
|
|
281
|
+
for (const obj of [...parsed].reverse()) {
|
|
282
|
+
if (!model && obj.type === "assistant") model = obj.message?.model;
|
|
283
|
+
if (!permissionMode && obj.type === "user" && !obj.isMeta) permissionMode = obj.permissionMode;
|
|
284
|
+
if (model && permissionMode) break;
|
|
285
|
+
}
|
|
286
|
+
const messages = parsed.filter(
|
|
287
|
+
(obj) => obj.type === "user" && !obj.isMeta || obj.type === "assistant"
|
|
288
|
+
).flatMap((obj) => {
|
|
289
|
+
const content2 = obj.message.content;
|
|
290
|
+
if (typeof content2 === "string") {
|
|
291
|
+
return [{ role: obj.message.role, type: "text", text: content2.trim(), timestamp: obj.timestamp }];
|
|
292
|
+
}
|
|
293
|
+
return content2.filter((b) => ["text", "tool_use", "tool_result"].includes(b.type)).map((b) => {
|
|
294
|
+
if (b.type === "text") {
|
|
295
|
+
return { role: obj.message.role, type: "text", text: b.text.trim(), timestamp: obj.timestamp };
|
|
296
|
+
}
|
|
297
|
+
if (b.type === "tool_use") {
|
|
298
|
+
return { role: obj.message.role, type: "tool_use", name: b.name, input: b.input, timestamp: obj.timestamp };
|
|
299
|
+
}
|
|
300
|
+
if (b.type === "tool_result") {
|
|
301
|
+
const resultText = Array.isArray(b.content) ? b.content.filter((r) => r.type === "text").map((r) => r.text).join("") : b.content ?? "";
|
|
302
|
+
return { role: obj.message.role, type: "tool_result", text: resultText.trim(), timestamp: obj.timestamp };
|
|
303
|
+
}
|
|
304
|
+
}).filter(Boolean);
|
|
305
|
+
});
|
|
306
|
+
return c.json({ messages, model, permissionMode });
|
|
307
|
+
} catch {
|
|
308
|
+
return c.json({ error: "Chat not found" }, 404);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// src/routes/git/index.ts
|
|
313
|
+
import { Hono as Hono2 } from "hono";
|
|
314
|
+
import { exec } from "child_process";
|
|
315
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
316
|
+
import path2 from "path";
|
|
317
|
+
import { promisify } from "util";
|
|
318
|
+
var execAsync = promisify(exec);
|
|
319
|
+
var _repoRoot = null;
|
|
320
|
+
async function getRepoRoot() {
|
|
321
|
+
if (!_repoRoot) {
|
|
322
|
+
try {
|
|
323
|
+
const { stdout } = await execAsync("git rev-parse --show-toplevel");
|
|
324
|
+
_repoRoot = stdout.trim();
|
|
325
|
+
} catch {
|
|
326
|
+
_repoRoot = process.cwd();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return _repoRoot;
|
|
330
|
+
}
|
|
331
|
+
var git = new Hono2();
|
|
332
|
+
git.get("/status", async (c) => {
|
|
333
|
+
try {
|
|
334
|
+
const root = await getRepoRoot();
|
|
335
|
+
const { stdout: statusOut } = await execAsync("git status --porcelain", {
|
|
336
|
+
cwd: root
|
|
337
|
+
});
|
|
338
|
+
const [{ stdout: unstagedNum }, { stdout: stagedNum }] = await Promise.all([
|
|
339
|
+
execAsync("git diff --numstat", { cwd: root }).catch(() => ({
|
|
340
|
+
stdout: ""
|
|
341
|
+
})),
|
|
342
|
+
execAsync("git diff --cached --numstat", { cwd: root }).catch(() => ({
|
|
343
|
+
stdout: ""
|
|
344
|
+
}))
|
|
345
|
+
]);
|
|
346
|
+
const parseNumstat = (raw) => {
|
|
347
|
+
const map = /* @__PURE__ */ new Map();
|
|
348
|
+
for (const line of raw.split("\n").filter(Boolean)) {
|
|
349
|
+
const [add, del, file] = line.split(" ");
|
|
350
|
+
if (file) {
|
|
351
|
+
map.set(file, {
|
|
352
|
+
additions: add === "-" ? 0 : parseInt(add, 10),
|
|
353
|
+
deletions: del === "-" ? 0 : parseInt(del, 10)
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return map;
|
|
358
|
+
};
|
|
359
|
+
const unstagedStats = parseNumstat(unstagedNum);
|
|
360
|
+
const stagedStats = parseNumstat(stagedNum);
|
|
361
|
+
const files = [];
|
|
362
|
+
for (const line of statusOut.split("\n").filter(Boolean)) {
|
|
363
|
+
const xy = line.slice(0, 2);
|
|
364
|
+
const filePath = line.slice(3).trim();
|
|
365
|
+
let status = "M";
|
|
366
|
+
let staged = false;
|
|
367
|
+
if (xy === "??") {
|
|
368
|
+
status = "?";
|
|
369
|
+
} else if (xy === "!!") {
|
|
370
|
+
continue;
|
|
371
|
+
} else {
|
|
372
|
+
const indexStatus = xy[0];
|
|
373
|
+
const worktreeStatus = xy[1];
|
|
374
|
+
if (indexStatus !== " " && indexStatus !== "?") {
|
|
375
|
+
staged = true;
|
|
376
|
+
if (indexStatus === "A") status = "A";
|
|
377
|
+
else if (indexStatus === "D") status = "D";
|
|
378
|
+
else if (indexStatus === "R") status = "R";
|
|
379
|
+
else status = "M";
|
|
380
|
+
} else {
|
|
381
|
+
if (worktreeStatus === "D") status = "D";
|
|
382
|
+
else status = "M";
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const stats = staged ? stagedStats.get(filePath) : unstagedStats.get(filePath);
|
|
386
|
+
let additions = stats?.additions ?? 0;
|
|
387
|
+
let deletions = stats?.deletions ?? 0;
|
|
388
|
+
if (status === "?") {
|
|
389
|
+
try {
|
|
390
|
+
const content = await readFile2(path2.join(root, filePath), "utf-8");
|
|
391
|
+
additions = content.split("\n").length;
|
|
392
|
+
} catch {
|
|
393
|
+
additions = 0;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
files.push({ path: filePath, status, staged, additions, deletions });
|
|
397
|
+
}
|
|
398
|
+
return c.json({ files });
|
|
399
|
+
} catch (error) {
|
|
400
|
+
console.error("git status error:", error);
|
|
401
|
+
return c.json({ files: [] });
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
git.get("/diff", async (c) => {
|
|
405
|
+
const filePath = c.req.query("file");
|
|
406
|
+
if (!filePath) return c.json({ error: "file parameter required" }, 400);
|
|
407
|
+
if (filePath.includes("..")) return c.json({ error: "Invalid path" }, 400);
|
|
408
|
+
const root = await getRepoRoot();
|
|
409
|
+
const safePath = filePath.replace(/'/g, "'\\''");
|
|
410
|
+
try {
|
|
411
|
+
let oldContent = "";
|
|
412
|
+
let newContent = "";
|
|
413
|
+
try {
|
|
414
|
+
const { stdout } = await execAsync(`git show 'HEAD:${safePath}'`, {
|
|
415
|
+
cwd: root,
|
|
416
|
+
maxBuffer: 10 * 1024 * 1024
|
|
417
|
+
});
|
|
418
|
+
oldContent = stdout;
|
|
419
|
+
} catch {
|
|
420
|
+
oldContent = "";
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
newContent = await readFile2(path2.join(root, filePath), "utf-8");
|
|
424
|
+
} catch (e) {
|
|
425
|
+
console.error("readFile error for", filePath, e);
|
|
426
|
+
newContent = "";
|
|
427
|
+
}
|
|
428
|
+
return c.json({ path: filePath, oldContent, newContent });
|
|
429
|
+
} catch (error) {
|
|
430
|
+
console.error("git diff error:", error);
|
|
431
|
+
return c.json({ error: "Failed to get diff" }, 500);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// src/server.ts
|
|
436
|
+
import { bearerAuth } from "hono/bearer-auth";
|
|
437
|
+
var app = new Hono3();
|
|
438
|
+
var token = process.env.AUTH_TOKEN;
|
|
439
|
+
if (!token) {
|
|
440
|
+
console.error("AUTH_TOKEN is not set. Run `anywhere-ai init` first.");
|
|
441
|
+
process.exit(1);
|
|
442
|
+
}
|
|
443
|
+
app.use("*", cors());
|
|
444
|
+
app.get("/health", async (c) => {
|
|
445
|
+
return c.json({ message: "server is healthy" }, 200);
|
|
446
|
+
});
|
|
447
|
+
app.use("/v1/*", bearerAuth({ token }));
|
|
448
|
+
app.route("/v1/chats", chats);
|
|
449
|
+
app.route("/v1/git", git);
|
|
450
|
+
serve(
|
|
451
|
+
{
|
|
452
|
+
fetch: app.fetch,
|
|
453
|
+
hostname: "0.0.0.0",
|
|
454
|
+
port: parseInt(process.env.PORT || "3847")
|
|
455
|
+
},
|
|
456
|
+
(info) => {
|
|
457
|
+
console.log(`Server is running on http://localhost:${info.port}`);
|
|
458
|
+
}
|
|
459
|
+
);
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "anywhere-ai",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Code on any repo from your phone",
|
|
6
|
+
"bin": {
|
|
7
|
+
"anywhere-ai": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
11
|
+
"dev": "tsx watch src/server.ts",
|
|
12
|
+
"start": "tsx src/server.ts",
|
|
13
|
+
"build": "tsup src/cli.ts --format esm --outDir dist"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"claude",
|
|
17
|
+
"mobile",
|
|
18
|
+
"coding",
|
|
19
|
+
"agent"
|
|
20
|
+
],
|
|
21
|
+
"author": "yogesharc",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"packageManager": "pnpm@10.30.3",
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.69",
|
|
26
|
+
"@hono/node-server": "^1.19.11",
|
|
27
|
+
"dotenv": "^17.3.1",
|
|
28
|
+
"hono": "^4.12.5"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^25.3.3",
|
|
32
|
+
"tsup": "^8.5.1",
|
|
33
|
+
"tsx": "^4.21.0",
|
|
34
|
+
"typescript": "^5.9.3"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/chats.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import {
|
|
2
|
+
unstable_v2_createSession,
|
|
3
|
+
PermissionMode,
|
|
4
|
+
PermissionResult,
|
|
5
|
+
SDKMessage,
|
|
6
|
+
SDKSession,
|
|
7
|
+
unstable_v2_resumeSession,
|
|
8
|
+
} from "@anthropic-ai/claude-agent-sdk";
|
|
9
|
+
|
|
10
|
+
export const sessions = new Map<string, SDKSession>();
|
|
11
|
+
|
|
12
|
+
// Track which sessions are currently streaming
|
|
13
|
+
export const activeSessions = new Set<string>();
|
|
14
|
+
|
|
15
|
+
// Pending permission requests: requestId -> resolve function
|
|
16
|
+
export const pendingPermissions = new Map<
|
|
17
|
+
string,
|
|
18
|
+
(result: PermissionResult) => void
|
|
19
|
+
>();
|
|
20
|
+
|
|
21
|
+
export function getAssistantText(msg: SDKMessage): string | null {
|
|
22
|
+
if (msg.type !== "assistant") return null;
|
|
23
|
+
return msg.message.content
|
|
24
|
+
.filter((block: any) => block.type === "text")
|
|
25
|
+
.map((block: any) => block.text)
|
|
26
|
+
.join("");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Permission suggestion sent to the client
|
|
30
|
+
export interface PermissionSuggestion {
|
|
31
|
+
type: string; // 'addRules' | 'replaceRules' | 'setMode' etc.
|
|
32
|
+
rules?: Array<{ toolName: string; ruleContent?: string }>;
|
|
33
|
+
behavior?: string;
|
|
34
|
+
destination?: string;
|
|
35
|
+
mode?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Permission request info sent to the client via SSE
|
|
39
|
+
export interface PermissionRequest {
|
|
40
|
+
requestId: string;
|
|
41
|
+
toolName: string;
|
|
42
|
+
input: Record<string, unknown>;
|
|
43
|
+
description?: string;
|
|
44
|
+
decisionReason?: string;
|
|
45
|
+
suggestions?: PermissionSuggestion[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Callback for notifying the SSE stream about permission requests
|
|
49
|
+
type PermissionCallback = (req: PermissionRequest) => void;
|
|
50
|
+
|
|
51
|
+
// Active permission callbacks per session (set during streaming)
|
|
52
|
+
const permissionCallbacks = new Map<string, PermissionCallback>();
|
|
53
|
+
|
|
54
|
+
export function setPermissionCallback(
|
|
55
|
+
sessionId: string,
|
|
56
|
+
cb: PermissionCallback | null,
|
|
57
|
+
) {
|
|
58
|
+
if (cb) {
|
|
59
|
+
permissionCallbacks.set(sessionId, cb);
|
|
60
|
+
} else {
|
|
61
|
+
permissionCallbacks.delete(sessionId);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let permReqCounter = 0;
|
|
66
|
+
|
|
67
|
+
function makeCanUseTool(sessionHint: string, permissionMode: PermissionMode) {
|
|
68
|
+
return async (
|
|
69
|
+
toolName: string,
|
|
70
|
+
input: Record<string, unknown>,
|
|
71
|
+
options: {
|
|
72
|
+
signal: AbortSignal;
|
|
73
|
+
suggestions?: any[];
|
|
74
|
+
blockedPath?: string;
|
|
75
|
+
decisionReason?: string;
|
|
76
|
+
toolUseID: string;
|
|
77
|
+
agentID?: string;
|
|
78
|
+
},
|
|
79
|
+
): Promise<PermissionResult> => {
|
|
80
|
+
console.log(`[canUseTool] tool=${toolName} reason=${options.decisionReason ?? "none"} mode=${permissionMode}`);
|
|
81
|
+
|
|
82
|
+
// In acceptEdits mode: auto-allow file operations, only prompt for others
|
|
83
|
+
// The SDK calls canUseTool for tools that need a permission decision.
|
|
84
|
+
// We forward the decision to the client for interactive approval.
|
|
85
|
+
const requestId = `perm_${++permReqCounter}_${Date.now()}`;
|
|
86
|
+
|
|
87
|
+
const req: PermissionRequest = {
|
|
88
|
+
requestId,
|
|
89
|
+
toolName,
|
|
90
|
+
input,
|
|
91
|
+
description: (input as any).description,
|
|
92
|
+
decisionReason: options.decisionReason,
|
|
93
|
+
suggestions: options.suggestions as PermissionSuggestion[] | undefined,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Notify the active SSE stream (if any)
|
|
97
|
+
const cb = permissionCallbacks.get(sessionHint);
|
|
98
|
+
if (cb) cb(req);
|
|
99
|
+
|
|
100
|
+
// Wait for the client to respond
|
|
101
|
+
return new Promise<PermissionResult>((resolve) => {
|
|
102
|
+
pendingPermissions.set(requestId, resolve);
|
|
103
|
+
|
|
104
|
+
// Timeout after 5 minutes — deny if no response
|
|
105
|
+
const timeout = setTimeout(() => {
|
|
106
|
+
if (pendingPermissions.has(requestId)) {
|
|
107
|
+
pendingPermissions.delete(requestId);
|
|
108
|
+
resolve({ behavior: "deny", message: "Permission request timed out" });
|
|
109
|
+
}
|
|
110
|
+
}, 5 * 60 * 1000);
|
|
111
|
+
|
|
112
|
+
// Clean up timeout if abort signal fires
|
|
113
|
+
options.signal.addEventListener("abort", () => {
|
|
114
|
+
clearTimeout(timeout);
|
|
115
|
+
if (pendingPermissions.has(requestId)) {
|
|
116
|
+
pendingPermissions.delete(requestId);
|
|
117
|
+
resolve({ behavior: "deny", message: "Request aborted" });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Also clean up on resolve
|
|
122
|
+
const origResolve = resolve;
|
|
123
|
+
pendingPermissions.set(requestId, (result) => {
|
|
124
|
+
clearTimeout(timeout);
|
|
125
|
+
pendingPermissions.delete(requestId);
|
|
126
|
+
origResolve(result);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const createChat = async ({
|
|
133
|
+
model,
|
|
134
|
+
permission,
|
|
135
|
+
}: {
|
|
136
|
+
model?: string;
|
|
137
|
+
permission?: PermissionMode;
|
|
138
|
+
}) => {
|
|
139
|
+
// We pass a placeholder sessionId hint — we'll update the callback key
|
|
140
|
+
// once we know the real sessionId from the first streamed message
|
|
141
|
+
const tempId = `temp_${Date.now()}`;
|
|
142
|
+
const mode = permission || "acceptEdits";
|
|
143
|
+
const session = unstable_v2_createSession({
|
|
144
|
+
model: model || "claude-opus-4-6",
|
|
145
|
+
permissionMode: mode,
|
|
146
|
+
canUseTool: makeCanUseTool(tempId, mode),
|
|
147
|
+
});
|
|
148
|
+
return { session, tempId };
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export const sendMessage = async ({
|
|
152
|
+
prompt,
|
|
153
|
+
session,
|
|
154
|
+
}: {
|
|
155
|
+
prompt: string;
|
|
156
|
+
session: SDKSession;
|
|
157
|
+
}): Promise<void> => {
|
|
158
|
+
await session.send(prompt);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export const streamMessage = async (
|
|
162
|
+
session: SDKSession,
|
|
163
|
+
): Promise<{ text: string | null; sessionId: string }> => {
|
|
164
|
+
let sessionId = "";
|
|
165
|
+
let response = "";
|
|
166
|
+
for await (const msg of session.stream()) {
|
|
167
|
+
sessionId = msg.session_id;
|
|
168
|
+
const text = getAssistantText(msg);
|
|
169
|
+
if (text) response += text;
|
|
170
|
+
}
|
|
171
|
+
return { text: response || null, sessionId };
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export const closeSession = (session: SDKSession) => {
|
|
175
|
+
session.close();
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export const getSession = async ({
|
|
179
|
+
sessionId,
|
|
180
|
+
model,
|
|
181
|
+
permission,
|
|
182
|
+
}: {
|
|
183
|
+
sessionId: string;
|
|
184
|
+
model?: string;
|
|
185
|
+
permission?: PermissionMode;
|
|
186
|
+
}): Promise<SDKSession> => {
|
|
187
|
+
let session = sessions.get(sessionId);
|
|
188
|
+
if (session && (model || permission)) {
|
|
189
|
+
session.close();
|
|
190
|
+
sessions.delete(sessionId);
|
|
191
|
+
session = undefined;
|
|
192
|
+
}
|
|
193
|
+
if (!session) {
|
|
194
|
+
const mode = permission || "acceptEdits";
|
|
195
|
+
session = unstable_v2_resumeSession(sessionId, {
|
|
196
|
+
model: model || "claude-opus-4-6",
|
|
197
|
+
permissionMode: mode,
|
|
198
|
+
canUseTool: makeCanUseTool(sessionId, mode),
|
|
199
|
+
});
|
|
200
|
+
sessions.set(sessionId, session);
|
|
201
|
+
}
|
|
202
|
+
return session;
|
|
203
|
+
};
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import crypto from "crypto";
|
|
6
|
+
import { spawnSync, execSync } from "child_process";
|
|
7
|
+
|
|
8
|
+
const ANYWHERE_DIR = path.join(os.homedir(), ".anywhere");
|
|
9
|
+
const CONFIG_PATH = path.join(ANYWHERE_DIR, "config.json");
|
|
10
|
+
|
|
11
|
+
async function createAnywhereDir() {
|
|
12
|
+
await fs.mkdir(ANYWHERE_DIR, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function createConfigFile() {
|
|
16
|
+
try {
|
|
17
|
+
await fs.access(CONFIG_PATH);
|
|
18
|
+
console.log("Config.json already exists");
|
|
19
|
+
} catch (error) {
|
|
20
|
+
console.log("Config.json does not exists");
|
|
21
|
+
const token = crypto.randomBytes(16).toString("hex");
|
|
22
|
+
await fs.writeFile(
|
|
23
|
+
CONFIG_PATH,
|
|
24
|
+
JSON.stringify({ authToken: token }, null, 2),
|
|
25
|
+
);
|
|
26
|
+
console.log("Config.json created");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isGhInstalled(): boolean {
|
|
31
|
+
try {
|
|
32
|
+
execSync("which gh", { stdio: "ignore" });
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ensureGhInstalled() {
|
|
40
|
+
if (isGhInstalled()) return;
|
|
41
|
+
|
|
42
|
+
const platform = os.platform();
|
|
43
|
+
console.log("gh CLI not found. Installing...");
|
|
44
|
+
|
|
45
|
+
if (platform === "darwin") {
|
|
46
|
+
const result = spawnSync("brew", ["install", "gh"], { stdio: "inherit" });
|
|
47
|
+
if (result.status !== 0) {
|
|
48
|
+
console.error(
|
|
49
|
+
"Failed to install gh via Homebrew. Install manually: https://cli.github.com",
|
|
50
|
+
);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
} else if (platform === "linux") {
|
|
54
|
+
const commands = [
|
|
55
|
+
"curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg",
|
|
56
|
+
'echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null',
|
|
57
|
+
"sudo apt update",
|
|
58
|
+
"sudo apt install -y gh",
|
|
59
|
+
];
|
|
60
|
+
for (const cmd of commands) {
|
|
61
|
+
const result = spawnSync("sh", ["-c", cmd], { stdio: "inherit" });
|
|
62
|
+
if (result.status !== 0) {
|
|
63
|
+
console.error(`Failed during: ${cmd}`);
|
|
64
|
+
console.error("Install gh manually: https://cli.github.com");
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
console.error(
|
|
70
|
+
`Unsupported platform: ${platform}. Install gh manually: https://cli.github.com`,
|
|
71
|
+
);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!isGhInstalled()) {
|
|
76
|
+
console.error("gh installation completed but binary not found in PATH.");
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
console.log("gh CLI installed successfully.");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isGhAuthed(): boolean {
|
|
83
|
+
try {
|
|
84
|
+
execSync("gh auth status", { encoding: "utf-8" });
|
|
85
|
+
return true;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function ensureGhAuth() {
|
|
91
|
+
if (isGhAuthed()) {
|
|
92
|
+
console.log("Gh authenticated");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
console.log("gh not authenticated, Launching gh auth login...");
|
|
96
|
+
const result = spawnSync("gh", ["auth", "login"], {
|
|
97
|
+
stdio: "inherit",
|
|
98
|
+
});
|
|
99
|
+
if (result.status !== 0) {
|
|
100
|
+
console.error("GitHub login failed");
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Setup
|
|
106
|
+
await fs.mkdir(ANYWHERE_DIR, { recursive: true });
|
|
107
|
+
await createConfigFile();
|
|
108
|
+
ensureGhInstalled();
|
|
109
|
+
ensureGhAuth();
|
|
110
|
+
|
|
111
|
+
// Start server
|
|
112
|
+
const config = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
113
|
+
process.env.AUTH_TOKEN = config.authToken;
|
|
114
|
+
import("./server.js");
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { readdir, readFile } from "fs/promises";
|
|
5
|
+
import { streamSSE } from "hono/streaming";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
sessions,
|
|
9
|
+
activeSessions,
|
|
10
|
+
createChat,
|
|
11
|
+
sendMessage,
|
|
12
|
+
getSession,
|
|
13
|
+
getAssistantText,
|
|
14
|
+
pendingPermissions,
|
|
15
|
+
setPermissionCallback,
|
|
16
|
+
type PermissionRequest,
|
|
17
|
+
} from "../../chats";
|
|
18
|
+
|
|
19
|
+
export const chats = new Hono();
|
|
20
|
+
|
|
21
|
+
chats.post("/new", async (c) => {
|
|
22
|
+
const { prompt, model, permission } = await c.req.json();
|
|
23
|
+
if (!prompt) return c.json({ error: "Prompt is required" }, 400);
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const { session, tempId } = await createChat({ model, permission });
|
|
27
|
+
await sendMessage({ prompt, session });
|
|
28
|
+
|
|
29
|
+
return streamSSE(c, async (stream) => {
|
|
30
|
+
let finalSessionId = "";
|
|
31
|
+
|
|
32
|
+
// Send permission requests directly from canUseTool callback
|
|
33
|
+
const sendPerm = async (req: PermissionRequest) => {
|
|
34
|
+
try {
|
|
35
|
+
await stream.writeSSE({
|
|
36
|
+
data: JSON.stringify({
|
|
37
|
+
type: "permission_request",
|
|
38
|
+
...req,
|
|
39
|
+
sessionId: finalSessionId || "pending",
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
} catch (e) {
|
|
43
|
+
console.error("Failed to send permission SSE:", e);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
setPermissionCallback(tempId, sendPerm);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
for await (const msg of session.stream()) {
|
|
50
|
+
if (msg.session_id && !finalSessionId) {
|
|
51
|
+
finalSessionId = msg.session_id;
|
|
52
|
+
activeSessions.add(finalSessionId);
|
|
53
|
+
|
|
54
|
+
setPermissionCallback(tempId, null);
|
|
55
|
+
setPermissionCallback(finalSessionId, sendPerm);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const text = getAssistantText(msg);
|
|
59
|
+
if (text)
|
|
60
|
+
await stream.writeSSE({
|
|
61
|
+
data: JSON.stringify({ response: text.trim(), sessionId: msg.session_id }),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (finalSessionId) sessions.set(finalSessionId, session);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
console.error(err);
|
|
67
|
+
await stream.writeSSE({ data: JSON.stringify({ error: "Stream error" }) });
|
|
68
|
+
} finally {
|
|
69
|
+
setPermissionCallback(tempId, null);
|
|
70
|
+
if (finalSessionId) {
|
|
71
|
+
setPermissionCallback(finalSessionId, null);
|
|
72
|
+
activeSessions.delete(finalSessionId);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error(error);
|
|
78
|
+
return c.json({ error: "Failed to create new chat" }, 400);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
chats.post("/:id/message", async (c) => {
|
|
83
|
+
const { prompt, model, permission } = await c.req.json();
|
|
84
|
+
const sessionId = c.req.param("id");
|
|
85
|
+
if (!prompt || !sessionId || /[\/\\]/.test(sessionId))
|
|
86
|
+
return c.json({ error: "Invalid request" }, 400);
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const session = await getSession({ sessionId, model, permission });
|
|
90
|
+
if (!session) return c.json({ error: "Session is required" }, 400);
|
|
91
|
+
|
|
92
|
+
await sendMessage({ prompt, session });
|
|
93
|
+
|
|
94
|
+
return streamSSE(c, async (stream) => {
|
|
95
|
+
activeSessions.add(sessionId);
|
|
96
|
+
|
|
97
|
+
// Permission requests are sent directly from the canUseTool callback
|
|
98
|
+
// (which runs on a different async context than the stream loop)
|
|
99
|
+
setPermissionCallback(sessionId, async (req) => {
|
|
100
|
+
try {
|
|
101
|
+
await stream.writeSSE({
|
|
102
|
+
data: JSON.stringify({
|
|
103
|
+
type: "permission_request",
|
|
104
|
+
...req,
|
|
105
|
+
sessionId,
|
|
106
|
+
}),
|
|
107
|
+
});
|
|
108
|
+
} catch (e) {
|
|
109
|
+
console.error("Failed to send permission SSE:", e);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
for await (const msg of session.stream()) {
|
|
115
|
+
const text = getAssistantText(msg);
|
|
116
|
+
if (text)
|
|
117
|
+
await stream.writeSSE({
|
|
118
|
+
data: JSON.stringify({
|
|
119
|
+
response: text?.trim() || null,
|
|
120
|
+
sessionId: msg.session_id,
|
|
121
|
+
}),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error(err);
|
|
126
|
+
await stream.writeSSE({ data: JSON.stringify({ error: "Stream error" }) });
|
|
127
|
+
} finally {
|
|
128
|
+
setPermissionCallback(sessionId, null);
|
|
129
|
+
activeSessions.delete(sessionId);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
} catch (error) {
|
|
133
|
+
console.error(error);
|
|
134
|
+
return c.json({ error: "Failed to send message" }, 400);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Respond to a pending permission request
|
|
139
|
+
chats.post("/:id/permission", async (c) => {
|
|
140
|
+
const { requestId, behavior, updatedPermissions } = await c.req.json();
|
|
141
|
+
if (!requestId || !behavior)
|
|
142
|
+
return c.json({ error: "requestId and behavior are required" }, 400);
|
|
143
|
+
|
|
144
|
+
const resolve = pendingPermissions.get(requestId);
|
|
145
|
+
if (!resolve)
|
|
146
|
+
return c.json({ error: "No pending permission request found" }, 404);
|
|
147
|
+
|
|
148
|
+
if (behavior === "allow") {
|
|
149
|
+
resolve({
|
|
150
|
+
behavior: "allow",
|
|
151
|
+
...(updatedPermissions ? { updatedPermissions } : {}),
|
|
152
|
+
});
|
|
153
|
+
} else {
|
|
154
|
+
resolve({ behavior: "deny", message: "User denied permission" });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return c.json({ ok: true });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
chats.get("/", async (c) => {
|
|
161
|
+
try {
|
|
162
|
+
const cwdSlug = process.cwd().replaceAll("/", "-");
|
|
163
|
+
const chatsDir = path.join(os.homedir(), ".claude", "projects", cwdSlug);
|
|
164
|
+
const files = (await readdir(chatsDir)).filter((f) => f.endsWith(".jsonl"));
|
|
165
|
+
|
|
166
|
+
const result = await Promise.all(
|
|
167
|
+
files.map(async (file) => {
|
|
168
|
+
const filePath = path.join(chatsDir, file);
|
|
169
|
+
const content = await readFile(filePath, "utf-8");
|
|
170
|
+
const lines = content.split("\n").filter(Boolean);
|
|
171
|
+
const sessionId = file.replace(".jsonl", "");
|
|
172
|
+
|
|
173
|
+
let preview = "";
|
|
174
|
+
let timestamp = "";
|
|
175
|
+
for (const line of lines) {
|
|
176
|
+
const obj = JSON.parse(line);
|
|
177
|
+
if (obj.type === "user" && !obj.isMeta) {
|
|
178
|
+
const content = obj.message.content;
|
|
179
|
+
if (typeof content === "string") {
|
|
180
|
+
preview = content.slice(0, 100);
|
|
181
|
+
} else {
|
|
182
|
+
preview = content
|
|
183
|
+
.filter((b: any) => b.type === "text")
|
|
184
|
+
.map((b: any) => b.text)
|
|
185
|
+
.join("")
|
|
186
|
+
.slice(0, 100);
|
|
187
|
+
}
|
|
188
|
+
timestamp = obj.timestamp;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return { sessionId, text: preview, timestamp, isActive: activeSessions.has(sessionId) };
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
return c.json({ result });
|
|
196
|
+
} catch (error) {
|
|
197
|
+
return c.json({ result: [] });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
chats.get("/:id", async (c) => {
|
|
202
|
+
const sessionId = c.req.param("id");
|
|
203
|
+
if (!sessionId || /[\/\\]/.test(sessionId))
|
|
204
|
+
return c.json({ error: "Invalid session id" }, 400);
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const cwdSlug = process.cwd().replaceAll("/", "-");
|
|
208
|
+
const chatsDir = path.join(os.homedir(), ".claude", "projects", cwdSlug);
|
|
209
|
+
const file = path.join(chatsDir, `${sessionId}.jsonl`);
|
|
210
|
+
const content = await readFile(file, "utf-8");
|
|
211
|
+
const lines = content.split("\n").filter(Boolean);
|
|
212
|
+
|
|
213
|
+
const parsed = lines.map((line) => JSON.parse(line));
|
|
214
|
+
|
|
215
|
+
// Extract last known model and permissionMode from JSONL
|
|
216
|
+
let model: string | undefined;
|
|
217
|
+
let permissionMode: string | undefined;
|
|
218
|
+
for (const obj of [...parsed].reverse()) {
|
|
219
|
+
if (!model && obj.type === "assistant") model = obj.message?.model;
|
|
220
|
+
if (!permissionMode && obj.type === "user" && !obj.isMeta) permissionMode = obj.permissionMode;
|
|
221
|
+
if (model && permissionMode) break;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const messages = parsed
|
|
225
|
+
.filter(
|
|
226
|
+
(obj) =>
|
|
227
|
+
(obj.type === "user" && !obj.isMeta) || obj.type === "assistant",
|
|
228
|
+
)
|
|
229
|
+
.flatMap((obj) => {
|
|
230
|
+
const content = obj.message.content;
|
|
231
|
+
if (typeof content === "string") {
|
|
232
|
+
return [{ role: obj.message.role, type: "text", text: content.trim(), timestamp: obj.timestamp }];
|
|
233
|
+
}
|
|
234
|
+
return content
|
|
235
|
+
.filter((b: any) => ["text", "tool_use", "tool_result"].includes(b.type))
|
|
236
|
+
.map((b: any) => {
|
|
237
|
+
if (b.type === "text") {
|
|
238
|
+
return { role: obj.message.role, type: "text", text: b.text.trim(), timestamp: obj.timestamp };
|
|
239
|
+
}
|
|
240
|
+
if (b.type === "tool_use") {
|
|
241
|
+
return { role: obj.message.role, type: "tool_use", name: b.name, input: b.input, timestamp: obj.timestamp };
|
|
242
|
+
}
|
|
243
|
+
if (b.type === "tool_result") {
|
|
244
|
+
const resultText = Array.isArray(b.content)
|
|
245
|
+
? b.content.filter((r: any) => r.type === "text").map((r: any) => r.text).join("")
|
|
246
|
+
: b.content ?? "";
|
|
247
|
+
return { role: obj.message.role, type: "tool_result", text: resultText.trim(), timestamp: obj.timestamp };
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
.filter(Boolean);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
return c.json({ messages, model, permissionMode });
|
|
254
|
+
} catch {
|
|
255
|
+
return c.json({ error: "Chat not found" }, 404);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { exec } from "child_process";
|
|
3
|
+
import { readFile } from "fs/promises";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { promisify } from "util";
|
|
6
|
+
|
|
7
|
+
const execAsync = promisify(exec);
|
|
8
|
+
|
|
9
|
+
// Resolve git repo root lazily (process.cwd() may be a subdir like api/)
|
|
10
|
+
let _repoRoot: string | null = null;
|
|
11
|
+
async function getRepoRoot(): Promise<string> {
|
|
12
|
+
if (!_repoRoot) {
|
|
13
|
+
try {
|
|
14
|
+
const { stdout } = await execAsync("git rev-parse --show-toplevel");
|
|
15
|
+
_repoRoot = stdout.trim();
|
|
16
|
+
} catch {
|
|
17
|
+
_repoRoot = process.cwd();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return _repoRoot;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const git = new Hono();
|
|
24
|
+
|
|
25
|
+
interface FileChange {
|
|
26
|
+
path: string;
|
|
27
|
+
status: "M" | "A" | "D" | "?" | "R";
|
|
28
|
+
staged: boolean;
|
|
29
|
+
additions: number;
|
|
30
|
+
deletions: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
git.get("/status", async (c) => {
|
|
34
|
+
try {
|
|
35
|
+
const root = await getRepoRoot();
|
|
36
|
+
|
|
37
|
+
const { stdout: statusOut } = await execAsync("git status --porcelain", {
|
|
38
|
+
cwd: root,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const [{ stdout: unstagedNum }, { stdout: stagedNum }] = await Promise.all([
|
|
42
|
+
execAsync("git diff --numstat", { cwd: root }).catch(() => ({
|
|
43
|
+
stdout: "",
|
|
44
|
+
})),
|
|
45
|
+
execAsync("git diff --cached --numstat", { cwd: root }).catch(() => ({
|
|
46
|
+
stdout: "",
|
|
47
|
+
})),
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const parseNumstat = (raw: string) => {
|
|
51
|
+
const map = new Map<string, { additions: number; deletions: number }>();
|
|
52
|
+
for (const line of raw.split("\n").filter(Boolean)) {
|
|
53
|
+
const [add, del, file] = line.split("\t");
|
|
54
|
+
if (file) {
|
|
55
|
+
map.set(file, {
|
|
56
|
+
additions: add === "-" ? 0 : parseInt(add, 10),
|
|
57
|
+
deletions: del === "-" ? 0 : parseInt(del, 10),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return map;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const unstagedStats = parseNumstat(unstagedNum);
|
|
65
|
+
const stagedStats = parseNumstat(stagedNum);
|
|
66
|
+
|
|
67
|
+
const files: FileChange[] = [];
|
|
68
|
+
for (const line of statusOut.split("\n").filter(Boolean)) {
|
|
69
|
+
const xy = line.slice(0, 2);
|
|
70
|
+
const filePath = line.slice(3).trim();
|
|
71
|
+
|
|
72
|
+
let status: FileChange["status"] = "M";
|
|
73
|
+
let staged = false;
|
|
74
|
+
|
|
75
|
+
if (xy === "??") {
|
|
76
|
+
status = "?";
|
|
77
|
+
} else if (xy === "!!") {
|
|
78
|
+
continue;
|
|
79
|
+
} else {
|
|
80
|
+
const indexStatus = xy[0];
|
|
81
|
+
const worktreeStatus = xy[1];
|
|
82
|
+
|
|
83
|
+
if (indexStatus !== " " && indexStatus !== "?") {
|
|
84
|
+
staged = true;
|
|
85
|
+
if (indexStatus === "A") status = "A";
|
|
86
|
+
else if (indexStatus === "D") status = "D";
|
|
87
|
+
else if (indexStatus === "R") status = "R";
|
|
88
|
+
else status = "M";
|
|
89
|
+
} else {
|
|
90
|
+
if (worktreeStatus === "D") status = "D";
|
|
91
|
+
else status = "M";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const stats = staged
|
|
96
|
+
? stagedStats.get(filePath)
|
|
97
|
+
: unstagedStats.get(filePath);
|
|
98
|
+
|
|
99
|
+
let additions = stats?.additions ?? 0;
|
|
100
|
+
let deletions = stats?.deletions ?? 0;
|
|
101
|
+
|
|
102
|
+
if (status === "?") {
|
|
103
|
+
try {
|
|
104
|
+
const content = await readFile(path.join(root, filePath), "utf-8");
|
|
105
|
+
additions = content.split("\n").length;
|
|
106
|
+
} catch {
|
|
107
|
+
additions = 0;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
files.push({ path: filePath, status, staged, additions, deletions });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return c.json({ files });
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.error("git status error:", error);
|
|
117
|
+
return c.json({ files: [] });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
git.get("/diff", async (c) => {
|
|
122
|
+
const filePath = c.req.query("file");
|
|
123
|
+
if (!filePath) return c.json({ error: "file parameter required" }, 400);
|
|
124
|
+
|
|
125
|
+
if (filePath.includes("..")) return c.json({ error: "Invalid path" }, 400);
|
|
126
|
+
|
|
127
|
+
const root = await getRepoRoot();
|
|
128
|
+
const safePath = filePath.replace(/'/g, "'\\''");
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
let oldContent = "";
|
|
132
|
+
let newContent = "";
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const { stdout } = await execAsync(`git show 'HEAD:${safePath}'`, {
|
|
136
|
+
cwd: root,
|
|
137
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
138
|
+
});
|
|
139
|
+
oldContent = stdout;
|
|
140
|
+
} catch {
|
|
141
|
+
oldContent = "";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
newContent = await readFile(path.join(root, filePath), "utf-8");
|
|
146
|
+
} catch (e) {
|
|
147
|
+
console.error("readFile error for", filePath, e);
|
|
148
|
+
newContent = "";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return c.json({ path: filePath, oldContent, newContent });
|
|
152
|
+
} catch (error) {
|
|
153
|
+
console.error("git diff error:", error);
|
|
154
|
+
return c.json({ error: "Failed to get diff" }, 500);
|
|
155
|
+
}
|
|
156
|
+
});
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { serve } from "@hono/node-server";
|
|
3
|
+
import { cors } from "hono/cors";
|
|
4
|
+
import { chats } from "./routes/chats";
|
|
5
|
+
import { git } from "./routes/git";
|
|
6
|
+
import { bearerAuth } from "hono/bearer-auth";
|
|
7
|
+
const app = new Hono();
|
|
8
|
+
const token = process.env.AUTH_TOKEN;
|
|
9
|
+
|
|
10
|
+
if (!token) {
|
|
11
|
+
console.error("AUTH_TOKEN is not set. Run `anywhere-ai init` first.");
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
app.use("*", cors());
|
|
16
|
+
|
|
17
|
+
app.get("/health", async (c) => {
|
|
18
|
+
return c.json({ message: "server is healthy" }, 200);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
app.use("/v1/*", bearerAuth({ token }));
|
|
22
|
+
app.route("/v1/chats", chats);
|
|
23
|
+
app.route("/v1/git", git);
|
|
24
|
+
|
|
25
|
+
serve(
|
|
26
|
+
{
|
|
27
|
+
fetch: app.fetch,
|
|
28
|
+
hostname: "0.0.0.0",
|
|
29
|
+
port: parseInt(process.env.PORT || "3847"),
|
|
30
|
+
},
|
|
31
|
+
(info) => {
|
|
32
|
+
console.log(`Server is running on http://localhost:${info.port}`);
|
|
33
|
+
},
|
|
34
|
+
);
|
package/test.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const greet = (name) => `Yo, what's up ${name}?!`;
|
|
2
|
+
console.log(greet("Yogesh"));
|
|
3
|
+
|
|
4
|
+
const add = (a, b) => a + b;
|
|
5
|
+
const multiply = (a, b) => a * b;
|
|
6
|
+
const subtract = (a, b) => a - b;
|
|
7
|
+
|
|
8
|
+
console.log(`2 + 3 = ${add(2, 3)}`);
|
|
9
|
+
console.log(`4 x 5 = ${multiply(4, 5)}`);
|
|
10
|
+
|
|
11
|
+
const fruits = ["apple", "banana", "mango", "kiwi"];
|
|
12
|
+
fruits.forEach((fruit, i) => console.log(`${i + 1}. ${fruit}`));
|
package/tsconfig.json
ADDED