happy-coder 0.1.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/README.md +38 -0
- package/bin/happy +2 -0
- package/bin/happy.cmd +2 -0
- package/dist/auth/auth.d.ts +38 -0
- package/dist/auth/auth.js +76 -0
- package/dist/auth/auth.test.d.ts +7 -0
- package/dist/auth/auth.test.js +96 -0
- package/dist/auth/crypto.d.ts +25 -0
- package/dist/auth/crypto.js +36 -0
- package/dist/claude/claude.d.ts +54 -0
- package/dist/claude/claude.js +170 -0
- package/dist/claude/claude.test.d.ts +7 -0
- package/dist/claude/claude.test.js +130 -0
- package/dist/claude/types.d.ts +37 -0
- package/dist/claude/types.js +7 -0
- package/dist/commands/start.d.ts +38 -0
- package/dist/commands/start.js +161 -0
- package/dist/commands/start.test.d.ts +7 -0
- package/dist/commands/start.test.js +307 -0
- package/dist/handlers/message-handler.d.ts +65 -0
- package/dist/handlers/message-handler.js +187 -0
- package/dist/index.cjs +603 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.mjs +583 -0
- package/dist/session/service.d.ts +27 -0
- package/dist/session/service.js +93 -0
- package/dist/session/service.test.d.ts +7 -0
- package/dist/session/service.test.js +71 -0
- package/dist/session/types.d.ts +44 -0
- package/dist/session/types.js +4 -0
- package/dist/socket/client.d.ts +50 -0
- package/dist/socket/client.js +136 -0
- package/dist/socket/client.test.d.ts +7 -0
- package/dist/socket/client.test.js +74 -0
- package/dist/socket/types.d.ts +80 -0
- package/dist/socket/types.js +12 -0
- package/dist/utils/config.d.ts +22 -0
- package/dist/utils/config.js +23 -0
- package/dist/utils/logger.d.ts +26 -0
- package/dist/utils/logger.js +60 -0
- package/dist/utils/paths.d.ts +18 -0
- package/dist/utils/paths.js +24 -0
- package/dist/utils/qrcode.d.ts +19 -0
- package/dist/utils/qrcode.js +37 -0
- package/dist/utils/qrcode.test.d.ts +7 -0
- package/dist/utils/qrcode.test.js +14 -0
- package/package.json +60 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { chmod } from 'node:fs/promises';
|
|
5
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
6
|
+
import tweetnacl from 'tweetnacl';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { join, basename } from 'node:path';
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
import { EventEmitter } from 'node:events';
|
|
11
|
+
import { io } from 'socket.io-client';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import qrcode from 'qrcode-terminal';
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
|
|
16
|
+
function encodeBase64(buffer) {
|
|
17
|
+
return Buffer.from(buffer).toString("base64");
|
|
18
|
+
}
|
|
19
|
+
function encodeBase64Url(buffer) {
|
|
20
|
+
return Buffer.from(buffer).toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
21
|
+
}
|
|
22
|
+
function decodeBase64(base64) {
|
|
23
|
+
return new Uint8Array(Buffer.from(base64, "base64"));
|
|
24
|
+
}
|
|
25
|
+
function getRandomBytes(size) {
|
|
26
|
+
return new Uint8Array(randomBytes(size));
|
|
27
|
+
}
|
|
28
|
+
function encrypt(data, secret) {
|
|
29
|
+
const nonce = getRandomBytes(tweetnacl.secretbox.nonceLength);
|
|
30
|
+
const encrypted = tweetnacl.secretbox(new TextEncoder().encode(JSON.stringify(data)), nonce, secret);
|
|
31
|
+
const result = new Uint8Array(nonce.length + encrypted.length);
|
|
32
|
+
result.set(nonce);
|
|
33
|
+
result.set(encrypted, nonce.length);
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
function decrypt(data, secret) {
|
|
37
|
+
const nonce = data.slice(0, tweetnacl.secretbox.nonceLength);
|
|
38
|
+
const encrypted = data.slice(tweetnacl.secretbox.nonceLength);
|
|
39
|
+
const decrypted = tweetnacl.secretbox.open(encrypted, nonce, secret);
|
|
40
|
+
if (!decrypted) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return JSON.parse(new TextDecoder().decode(decrypted));
|
|
44
|
+
}
|
|
45
|
+
function authChallenge(secret) {
|
|
46
|
+
const keypair = tweetnacl.sign.keyPair.fromSeed(secret);
|
|
47
|
+
const challenge = getRandomBytes(32);
|
|
48
|
+
const signature = tweetnacl.sign.detached(challenge, keypair.secretKey);
|
|
49
|
+
return {
|
|
50
|
+
challenge,
|
|
51
|
+
publicKey: keypair.publicKey,
|
|
52
|
+
signature
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function getOrCreateSecretKey() {
|
|
57
|
+
const keyPath = join(homedir(), ".handy", "access.key");
|
|
58
|
+
if (existsSync(keyPath)) {
|
|
59
|
+
const keyBase642 = readFileSync(keyPath, "utf8").trim();
|
|
60
|
+
return new Uint8Array(Buffer.from(keyBase642, "base64"));
|
|
61
|
+
}
|
|
62
|
+
const secret = getRandomBytes(32);
|
|
63
|
+
const keyBase64 = encodeBase64(secret);
|
|
64
|
+
mkdirSync(join(homedir(), ".handy"), { recursive: true });
|
|
65
|
+
writeFileSync(keyPath, keyBase64);
|
|
66
|
+
await chmod(keyPath, 384);
|
|
67
|
+
return secret;
|
|
68
|
+
}
|
|
69
|
+
async function authGetToken(secret) {
|
|
70
|
+
const { challenge, publicKey, signature } = authChallenge(secret);
|
|
71
|
+
const response = await axios.post(`https://handy-api.korshakov.org/v1/auth`, {
|
|
72
|
+
challenge: encodeBase64(challenge),
|
|
73
|
+
publicKey: encodeBase64(publicKey),
|
|
74
|
+
signature: encodeBase64(signature)
|
|
75
|
+
});
|
|
76
|
+
if (!response.data.success || !response.data.token) {
|
|
77
|
+
throw new Error("Authentication failed");
|
|
78
|
+
}
|
|
79
|
+
return response.data.token;
|
|
80
|
+
}
|
|
81
|
+
function generateAppUrl(secret) {
|
|
82
|
+
const secretBase64Url = encodeBase64Url(secret);
|
|
83
|
+
return `handy://${secretBase64Url}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
class Logger {
|
|
87
|
+
debug(message, ...args) {
|
|
88
|
+
if (process.env.DEBUG) {
|
|
89
|
+
this.log("DEBUG" /* DEBUG */, message, ...args);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
error(message, ...args) {
|
|
93
|
+
this.log("ERROR" /* ERROR */, message, ...args);
|
|
94
|
+
}
|
|
95
|
+
info(message, ...args) {
|
|
96
|
+
this.log("INFO" /* INFO */, message, ...args);
|
|
97
|
+
}
|
|
98
|
+
warn(message, ...args) {
|
|
99
|
+
this.log("WARN" /* WARN */, message, ...args);
|
|
100
|
+
}
|
|
101
|
+
getTimestamp() {
|
|
102
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
103
|
+
}
|
|
104
|
+
log(level, message, ...args) {
|
|
105
|
+
const timestamp = this.getTimestamp();
|
|
106
|
+
const prefix = `[${timestamp}] [${level}]`;
|
|
107
|
+
switch (level) {
|
|
108
|
+
case "DEBUG" /* DEBUG */: {
|
|
109
|
+
console.log(chalk.gray(prefix), message, ...args);
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case "ERROR" /* ERROR */: {
|
|
113
|
+
console.error(chalk.red(prefix), message, ...args);
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
case "INFO" /* INFO */: {
|
|
117
|
+
console.log(chalk.blue(prefix), message, ...args);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case "WARN" /* WARN */: {
|
|
121
|
+
console.log(chalk.yellow(prefix), message, ...args);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const logger = new Logger();
|
|
128
|
+
|
|
129
|
+
const SessionMessageContentSchema = z.object({
|
|
130
|
+
c: z.string(),
|
|
131
|
+
// Base64 encoded encrypted content
|
|
132
|
+
t: z.literal("encrypted")
|
|
133
|
+
});
|
|
134
|
+
const UpdateBodySchema = z.object({
|
|
135
|
+
message: z.object({
|
|
136
|
+
id: z.string(),
|
|
137
|
+
seq: z.number(),
|
|
138
|
+
content: SessionMessageContentSchema
|
|
139
|
+
}),
|
|
140
|
+
sid: z.string(),
|
|
141
|
+
// Session ID
|
|
142
|
+
t: z.literal("new-message")
|
|
143
|
+
});
|
|
144
|
+
z.object({
|
|
145
|
+
id: z.string(),
|
|
146
|
+
seq: z.number(),
|
|
147
|
+
body: UpdateBodySchema,
|
|
148
|
+
createdAt: z.number()
|
|
149
|
+
});
|
|
150
|
+
z.object({
|
|
151
|
+
createdAt: z.number(),
|
|
152
|
+
id: z.string(),
|
|
153
|
+
seq: z.number(),
|
|
154
|
+
updatedAt: z.number()
|
|
155
|
+
});
|
|
156
|
+
z.object({
|
|
157
|
+
content: SessionMessageContentSchema,
|
|
158
|
+
createdAt: z.number(),
|
|
159
|
+
id: z.string(),
|
|
160
|
+
seq: z.number(),
|
|
161
|
+
updatedAt: z.number()
|
|
162
|
+
});
|
|
163
|
+
z.object({
|
|
164
|
+
session: z.object({
|
|
165
|
+
id: z.string(),
|
|
166
|
+
tag: z.string(),
|
|
167
|
+
seq: z.number(),
|
|
168
|
+
createdAt: z.number(),
|
|
169
|
+
updatedAt: z.number()
|
|
170
|
+
})
|
|
171
|
+
});
|
|
172
|
+
const UserMessageSchema = z.object({
|
|
173
|
+
role: z.literal("user"),
|
|
174
|
+
content: z.object({
|
|
175
|
+
type: z.literal("text"),
|
|
176
|
+
text: z.string()
|
|
177
|
+
})
|
|
178
|
+
});
|
|
179
|
+
const AgentMessageSchema = z.object({
|
|
180
|
+
role: z.literal("agent"),
|
|
181
|
+
content: z.any()
|
|
182
|
+
});
|
|
183
|
+
z.union([UserMessageSchema, AgentMessageSchema]);
|
|
184
|
+
|
|
185
|
+
class ApiSessionClient extends EventEmitter {
|
|
186
|
+
token;
|
|
187
|
+
secret;
|
|
188
|
+
sessionId;
|
|
189
|
+
socket;
|
|
190
|
+
receivedMessages = /* @__PURE__ */ new Set();
|
|
191
|
+
pendingMessages = [];
|
|
192
|
+
pendingMessageCallback = null;
|
|
193
|
+
constructor(token, secret, sessionId) {
|
|
194
|
+
super();
|
|
195
|
+
this.token = token;
|
|
196
|
+
this.secret = secret;
|
|
197
|
+
this.sessionId = sessionId;
|
|
198
|
+
this.socket = io("https://handy-api.korshakov.org", {
|
|
199
|
+
auth: {
|
|
200
|
+
token: this.token
|
|
201
|
+
},
|
|
202
|
+
path: "/v1/updates",
|
|
203
|
+
reconnection: true,
|
|
204
|
+
reconnectionAttempts: Infinity,
|
|
205
|
+
reconnectionDelay: 1e3,
|
|
206
|
+
reconnectionDelayMax: 5e3,
|
|
207
|
+
transports: ["websocket"],
|
|
208
|
+
withCredentials: true,
|
|
209
|
+
autoConnect: false
|
|
210
|
+
});
|
|
211
|
+
this.socket.on("connect", () => {
|
|
212
|
+
logger.info("Socket connected successfully");
|
|
213
|
+
});
|
|
214
|
+
this.socket.on("disconnect", (reason) => {
|
|
215
|
+
logger.warn("Socket disconnected:", reason);
|
|
216
|
+
});
|
|
217
|
+
this.socket.on("connect_error", (error) => {
|
|
218
|
+
logger.error("Socket connection error:", error.message);
|
|
219
|
+
});
|
|
220
|
+
this.socket.on("update", (data) => {
|
|
221
|
+
if (data.body.t === "new-message" && data.body.message.content.t === "encrypted") {
|
|
222
|
+
const body = decrypt(decodeBase64(data.body.message.content.c), this.secret);
|
|
223
|
+
const result = UserMessageSchema.safeParse(body);
|
|
224
|
+
if (result.success) {
|
|
225
|
+
if (!this.receivedMessages.has(data.body.message.id)) {
|
|
226
|
+
this.receivedMessages.add(data.body.message.id);
|
|
227
|
+
if (this.pendingMessageCallback) {
|
|
228
|
+
this.pendingMessageCallback(result.data);
|
|
229
|
+
} else {
|
|
230
|
+
this.pendingMessages.push(result.data);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
this.socket.connect();
|
|
237
|
+
}
|
|
238
|
+
onUserMessage(callback) {
|
|
239
|
+
this.pendingMessageCallback = callback;
|
|
240
|
+
while (this.pendingMessages.length > 0) {
|
|
241
|
+
callback(this.pendingMessages.shift());
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Send message to session
|
|
246
|
+
* @param body - Message body
|
|
247
|
+
*/
|
|
248
|
+
sendMessage(body) {
|
|
249
|
+
let content = {
|
|
250
|
+
role: "agent",
|
|
251
|
+
content: body
|
|
252
|
+
};
|
|
253
|
+
const encrypted = encodeBase64(encrypt(content, this.secret));
|
|
254
|
+
this.socket.emit("message", {
|
|
255
|
+
sid: this.sessionId,
|
|
256
|
+
message: encrypted
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
async close() {
|
|
260
|
+
this.socket.close();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
class ApiClient {
|
|
265
|
+
token;
|
|
266
|
+
secret;
|
|
267
|
+
constructor(token, secret) {
|
|
268
|
+
this.token = token;
|
|
269
|
+
this.secret = secret;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Create a new session or load existing one with the given tag
|
|
273
|
+
*/
|
|
274
|
+
async getOrCreateSession(tag) {
|
|
275
|
+
try {
|
|
276
|
+
const response = await axios.post(
|
|
277
|
+
`https://handy-api.korshakov.org/v1/sessions`,
|
|
278
|
+
{ tag },
|
|
279
|
+
{
|
|
280
|
+
headers: {
|
|
281
|
+
"Authorization": `Bearer ${this.token}`,
|
|
282
|
+
"Content-Type": "application/json"
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
);
|
|
286
|
+
logger.info(`Session created/loaded: ${response.data.session.id} (tag: ${tag})`);
|
|
287
|
+
return response.data;
|
|
288
|
+
} catch (error) {
|
|
289
|
+
logger.error("Failed to get or create session:", error);
|
|
290
|
+
throw new Error(`Failed to get or create session: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Start realtime session client
|
|
295
|
+
* @param id - Session ID
|
|
296
|
+
* @returns Session client
|
|
297
|
+
*/
|
|
298
|
+
session(id) {
|
|
299
|
+
return new ApiSessionClient(this.token, this.secret, id);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function displayQRCode(url) {
|
|
304
|
+
try {
|
|
305
|
+
logger.info("=".repeat(50));
|
|
306
|
+
logger.info("\u{1F4F1} Scan this QR code with your mobile device:");
|
|
307
|
+
logger.info("=".repeat(50));
|
|
308
|
+
qrcode.generate(url, { small: true }, (qr) => {
|
|
309
|
+
for (let l of qr.split("\n")) {
|
|
310
|
+
logger.info(" " + l);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
logger.info("=".repeat(50));
|
|
314
|
+
} catch (error) {
|
|
315
|
+
logger.error("Failed to generate QR code:", error);
|
|
316
|
+
logger.info(`\u{1F4CB} Use this URL to connect: ${url}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function claudePath() {
|
|
321
|
+
if (fs.existsSync(process.env.HOME + "/.claude/local/claude")) {
|
|
322
|
+
return process.env.HOME + "/.claude/local/claude";
|
|
323
|
+
} else {
|
|
324
|
+
return "claude";
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function* claude(options) {
|
|
329
|
+
const args = buildArgs(options);
|
|
330
|
+
const path = claudePath();
|
|
331
|
+
logger.info("Spawning Claude CLI with args:", args);
|
|
332
|
+
const process = spawn(path, args, {
|
|
333
|
+
cwd: options.workingDirectory,
|
|
334
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
335
|
+
shell: false
|
|
336
|
+
});
|
|
337
|
+
process.stdin?.end();
|
|
338
|
+
let outputBuffer = "";
|
|
339
|
+
let stderrBuffer = "";
|
|
340
|
+
let processExited = false;
|
|
341
|
+
const outputQueue = [];
|
|
342
|
+
let outputResolve = null;
|
|
343
|
+
process.stdout?.on("data", (data) => {
|
|
344
|
+
outputBuffer += data.toString();
|
|
345
|
+
const lines = outputBuffer.split("\n");
|
|
346
|
+
outputBuffer = lines.pop() || "";
|
|
347
|
+
for (const line of lines) {
|
|
348
|
+
if (line.trim()) {
|
|
349
|
+
try {
|
|
350
|
+
const json = JSON.parse(line);
|
|
351
|
+
outputQueue.push({ type: "json", data: json });
|
|
352
|
+
} catch {
|
|
353
|
+
outputQueue.push({ type: "text", data: line });
|
|
354
|
+
}
|
|
355
|
+
if (outputResolve) {
|
|
356
|
+
outputResolve();
|
|
357
|
+
outputResolve = null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
process.stderr?.on("data", (data) => {
|
|
363
|
+
stderrBuffer += data.toString();
|
|
364
|
+
const lines = stderrBuffer.split("\n");
|
|
365
|
+
stderrBuffer = lines.pop() || "";
|
|
366
|
+
for (const line of lines) {
|
|
367
|
+
if (line.trim()) {
|
|
368
|
+
outputQueue.push({ type: "error", error: line });
|
|
369
|
+
if (outputResolve) {
|
|
370
|
+
outputResolve();
|
|
371
|
+
outputResolve = null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
process.on("exit", (code, signal) => {
|
|
377
|
+
processExited = true;
|
|
378
|
+
outputQueue.push({ type: "exit", code, signal });
|
|
379
|
+
if (outputResolve) {
|
|
380
|
+
outputResolve();
|
|
381
|
+
outputResolve = null;
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
process.on("error", (error) => {
|
|
385
|
+
outputQueue.push({ type: "error", error: error.message });
|
|
386
|
+
processExited = true;
|
|
387
|
+
if (outputResolve) {
|
|
388
|
+
outputResolve();
|
|
389
|
+
outputResolve = null;
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
while (!processExited || outputQueue.length > 0) {
|
|
393
|
+
if (outputQueue.length === 0) {
|
|
394
|
+
await new Promise((resolve) => {
|
|
395
|
+
outputResolve = resolve;
|
|
396
|
+
if (outputQueue.length > 0 || processExited) {
|
|
397
|
+
resolve();
|
|
398
|
+
outputResolve = null;
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
while (outputQueue.length > 0) {
|
|
403
|
+
const output = outputQueue.shift();
|
|
404
|
+
yield output;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (outputBuffer.trim()) {
|
|
408
|
+
try {
|
|
409
|
+
const json = JSON.parse(outputBuffer);
|
|
410
|
+
yield { type: "json", data: json };
|
|
411
|
+
} catch {
|
|
412
|
+
yield { type: "text", data: outputBuffer };
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (stderrBuffer.trim()) {
|
|
416
|
+
yield { type: "error", error: stderrBuffer };
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
function buildArgs(options) {
|
|
420
|
+
const args = [
|
|
421
|
+
"--print",
|
|
422
|
+
options.command,
|
|
423
|
+
"--output-format",
|
|
424
|
+
"stream-json",
|
|
425
|
+
"--verbose"
|
|
426
|
+
];
|
|
427
|
+
if (options.model) {
|
|
428
|
+
args.push("--model", options.model);
|
|
429
|
+
}
|
|
430
|
+
if (options.permissionMode) {
|
|
431
|
+
const modeMap = {
|
|
432
|
+
"auto": "acceptEdits",
|
|
433
|
+
"default": "default",
|
|
434
|
+
"plan": "bypassPermissions"
|
|
435
|
+
};
|
|
436
|
+
args.push("--permission-mode", modeMap[options.permissionMode]);
|
|
437
|
+
}
|
|
438
|
+
if (options.skipPermissions) {
|
|
439
|
+
args.push("--dangerously-skip-permissions");
|
|
440
|
+
}
|
|
441
|
+
if (options.sessionId) {
|
|
442
|
+
args.push("--resume", options.sessionId);
|
|
443
|
+
}
|
|
444
|
+
return args;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function startClaudeLoop(opts, session) {
|
|
448
|
+
let exiting = false;
|
|
449
|
+
const messageQueue = [];
|
|
450
|
+
let messageResolve = null;
|
|
451
|
+
let promise = (async () => {
|
|
452
|
+
session.onUserMessage((message) => {
|
|
453
|
+
messageQueue.push(message);
|
|
454
|
+
if (messageResolve) {
|
|
455
|
+
messageResolve();
|
|
456
|
+
messageResolve = null;
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
while (!exiting) {
|
|
460
|
+
if (messageQueue.length > 0) {
|
|
461
|
+
const message = messageQueue.shift();
|
|
462
|
+
if (message) {
|
|
463
|
+
for await (const output of claude({
|
|
464
|
+
command: message.content.text,
|
|
465
|
+
workingDirectory: opts.path,
|
|
466
|
+
model: opts.model,
|
|
467
|
+
permissionMode: opts.permissionMode
|
|
468
|
+
})) {
|
|
469
|
+
if (output.type === "exit") {
|
|
470
|
+
if (output.code !== 0 || output.code === void 0) {
|
|
471
|
+
session.sendMessage({
|
|
472
|
+
content: {
|
|
473
|
+
type: "error",
|
|
474
|
+
error: output.error,
|
|
475
|
+
code: output.code
|
|
476
|
+
},
|
|
477
|
+
role: "assistant"
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
if (output.type === "json") {
|
|
483
|
+
session.sendMessage({
|
|
484
|
+
data: output.data,
|
|
485
|
+
type: "output"
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
await new Promise((resolve) => {
|
|
492
|
+
messageResolve = resolve;
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
})();
|
|
496
|
+
return async () => {
|
|
497
|
+
exiting = true;
|
|
498
|
+
if (messageResolve) {
|
|
499
|
+
messageResolve();
|
|
500
|
+
}
|
|
501
|
+
await promise;
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function start(options = {}) {
|
|
506
|
+
const workingDirectory = process.cwd();
|
|
507
|
+
const projectName = basename(workingDirectory);
|
|
508
|
+
const sessionTag = randomUUID();
|
|
509
|
+
logger.info(`Starting happy session for project: ${projectName}`);
|
|
510
|
+
const secret = await getOrCreateSecretKey();
|
|
511
|
+
logger.info("Secret key loaded");
|
|
512
|
+
const token = await authGetToken(secret);
|
|
513
|
+
logger.info("Authenticated with handy server");
|
|
514
|
+
const api = new ApiClient(token, secret);
|
|
515
|
+
const response = await api.getOrCreateSession(sessionTag);
|
|
516
|
+
logger.info(`Session created: ${response.session.id}`);
|
|
517
|
+
const handyUrl = generateAppUrl(secret);
|
|
518
|
+
displayQRCode(handyUrl);
|
|
519
|
+
const session = api.session(response.session.id);
|
|
520
|
+
const loopDestroy = startClaudeLoop({ path: workingDirectory }, session);
|
|
521
|
+
const shutdown = async () => {
|
|
522
|
+
logger.info("Shutting down...");
|
|
523
|
+
await loopDestroy();
|
|
524
|
+
await session.close();
|
|
525
|
+
process.exit(0);
|
|
526
|
+
};
|
|
527
|
+
process.on("SIGINT", shutdown);
|
|
528
|
+
process.on("SIGTERM", shutdown);
|
|
529
|
+
logger.info("Happy CLI is running. Press Ctrl+C to stop.");
|
|
530
|
+
await new Promise(() => {
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const args = process.argv.slice(2);
|
|
535
|
+
const options = {};
|
|
536
|
+
let showHelp = false;
|
|
537
|
+
let showVersion = false;
|
|
538
|
+
for (let i = 0; i < args.length; i++) {
|
|
539
|
+
const arg = args[i];
|
|
540
|
+
if (arg === "-h" || arg === "--help") {
|
|
541
|
+
showHelp = true;
|
|
542
|
+
} else if (arg === "-v" || arg === "--version") {
|
|
543
|
+
showVersion = true;
|
|
544
|
+
} else if (arg === "-m" || arg === "--model") {
|
|
545
|
+
options.model = args[++i];
|
|
546
|
+
} else if (arg === "-p" || arg === "--permission-mode") {
|
|
547
|
+
options.permissionMode = args[++i];
|
|
548
|
+
} else {
|
|
549
|
+
console.error(chalk.red(`Unknown argument: ${arg}`));
|
|
550
|
+
process.exit(1);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (showHelp) {
|
|
554
|
+
console.log(`
|
|
555
|
+
${chalk.bold("happy")} - Claude Code session sharing
|
|
556
|
+
|
|
557
|
+
${chalk.bold("Usage:")}
|
|
558
|
+
happy [options]
|
|
559
|
+
|
|
560
|
+
${chalk.bold("Options:")}
|
|
561
|
+
-h, --help Show this help message
|
|
562
|
+
-v, --version Show version
|
|
563
|
+
-m, --model <model> Claude model to use (default: sonnet)
|
|
564
|
+
-p, --permission-mode Permission mode: auto, default, or plan
|
|
565
|
+
|
|
566
|
+
${chalk.bold("Examples:")}
|
|
567
|
+
happy Start a session with default settings
|
|
568
|
+
happy -m opus Use Claude Opus model
|
|
569
|
+
happy -p plan Use plan permission mode
|
|
570
|
+
`);
|
|
571
|
+
process.exit(0);
|
|
572
|
+
}
|
|
573
|
+
if (showVersion) {
|
|
574
|
+
console.log("0.1.0");
|
|
575
|
+
process.exit(0);
|
|
576
|
+
}
|
|
577
|
+
start(options).catch((error) => {
|
|
578
|
+
console.error(chalk.red("Error:"), error.message);
|
|
579
|
+
if (process.env.DEBUG) {
|
|
580
|
+
console.error(error);
|
|
581
|
+
}
|
|
582
|
+
process.exit(1);
|
|
583
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session service for managing handy-server sessions
|
|
3
|
+
*/
|
|
4
|
+
import type { CreateSessionResponse, GetMessagesResponse, SendMessageResponse } from '#session/types';
|
|
5
|
+
export declare class SessionService {
|
|
6
|
+
private readonly serverUrl;
|
|
7
|
+
private readonly authToken;
|
|
8
|
+
constructor(serverUrl: string, authToken: string);
|
|
9
|
+
/**
|
|
10
|
+
* Create a new session or load existing one with the given tag
|
|
11
|
+
*/
|
|
12
|
+
createSession(tag: string): Promise<CreateSessionResponse>;
|
|
13
|
+
/**
|
|
14
|
+
* Decrypt a message content
|
|
15
|
+
* Note: In real implementation, this would use proper decryption
|
|
16
|
+
*/
|
|
17
|
+
decryptContent(encryptedContent: string): unknown;
|
|
18
|
+
/**
|
|
19
|
+
* Get messages from a session
|
|
20
|
+
*/
|
|
21
|
+
getMessages(sessionId: string): Promise<GetMessagesResponse>;
|
|
22
|
+
/**
|
|
23
|
+
* Send a message to a session
|
|
24
|
+
* Note: In real implementation, we'd encrypt the content before sending
|
|
25
|
+
*/
|
|
26
|
+
sendMessage(sessionId: string, content: unknown): Promise<SendMessageResponse>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session service for managing handy-server sessions
|
|
3
|
+
*/
|
|
4
|
+
import { logger } from '#utils/logger';
|
|
5
|
+
import axios from 'axios';
|
|
6
|
+
export class SessionService {
|
|
7
|
+
serverUrl;
|
|
8
|
+
authToken;
|
|
9
|
+
constructor(serverUrl, authToken) {
|
|
10
|
+
this.serverUrl = serverUrl;
|
|
11
|
+
this.authToken = authToken;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Create a new session or load existing one with the given tag
|
|
15
|
+
*/
|
|
16
|
+
async createSession(tag) {
|
|
17
|
+
try {
|
|
18
|
+
const response = await axios.post(`${this.serverUrl}/v1/sessions`, { tag }, {
|
|
19
|
+
headers: {
|
|
20
|
+
'Authorization': `Bearer ${this.authToken}`,
|
|
21
|
+
'Content-Type': 'application/json'
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
logger.info(`Session created/loaded: ${response.data.session.id} (tag: ${tag})`);
|
|
25
|
+
return response.data;
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
logger.error('Failed to create session:', error);
|
|
29
|
+
throw new Error(`Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Decrypt a message content
|
|
34
|
+
* Note: In real implementation, this would use proper decryption
|
|
35
|
+
*/
|
|
36
|
+
decryptContent(encryptedContent) {
|
|
37
|
+
try {
|
|
38
|
+
const jsonContent = Buffer.from(encryptedContent, 'base64').toString('utf8');
|
|
39
|
+
return JSON.parse(jsonContent);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
logger.error('Failed to decrypt content:', error);
|
|
43
|
+
throw new Error('Failed to decrypt message content');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Get messages from a session
|
|
48
|
+
*/
|
|
49
|
+
async getMessages(sessionId) {
|
|
50
|
+
try {
|
|
51
|
+
const response = await axios.get(`${this.serverUrl}/v1/sessions/${sessionId}/messages`, {
|
|
52
|
+
headers: {
|
|
53
|
+
'Authorization': `Bearer ${this.authToken}`,
|
|
54
|
+
'Content-Type': 'application/json'
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
logger.debug(`Retrieved ${response.data.messages.length} messages from session ${sessionId}`);
|
|
58
|
+
return response.data;
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
logger.error('Failed to get messages:', error);
|
|
62
|
+
throw new Error(`Failed to get messages: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Send a message to a session
|
|
67
|
+
* Note: In real implementation, we'd encrypt the content before sending
|
|
68
|
+
*/
|
|
69
|
+
async sendMessage(sessionId, content) {
|
|
70
|
+
try {
|
|
71
|
+
// For now, we'll just base64 encode the JSON content
|
|
72
|
+
// In production, this should use proper encryption
|
|
73
|
+
const jsonContent = JSON.stringify(content);
|
|
74
|
+
const base64Content = Buffer.from(jsonContent).toString('base64');
|
|
75
|
+
const messageContent = {
|
|
76
|
+
c: base64Content,
|
|
77
|
+
t: 'encrypted'
|
|
78
|
+
};
|
|
79
|
+
const response = await axios.post(`${this.serverUrl}/v1/sessions/${sessionId}/messages`, messageContent, {
|
|
80
|
+
headers: {
|
|
81
|
+
'Authorization': `Bearer ${this.authToken}`,
|
|
82
|
+
'Content-Type': 'application/json'
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
logger.debug(`Message sent to session ${sessionId}, seq: ${response.data.message.seq}`);
|
|
86
|
+
return response.data;
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
logger.error('Failed to send message:', error);
|
|
90
|
+
throw new Error(`Failed to send message: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|