@punkcode/cli 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/LICENSE +21 -0
- package/README.md +43 -0
- package/dist/cli.js +1030 -0
- package/package.json +65 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 punkcode
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# @punkcode/cli
|
|
2
|
+
|
|
3
|
+
Control Claude Code from your phone with [punk](https://punkcode.dev).
|
|
4
|
+
|
|
5
|
+
## Getting started
|
|
6
|
+
|
|
7
|
+
1. Download the punk app on your phone and create an account
|
|
8
|
+
2. Install the CLI on your dev machine:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install -g @punkcode/cli
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
3. Log in:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
punk login
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
4. Connect:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
punk connect
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Your machine will appear in the punk app — you can now send prompts to Claude Code from your phone.
|
|
27
|
+
|
|
28
|
+
## Commands
|
|
29
|
+
|
|
30
|
+
| Command | Description |
|
|
31
|
+
|-----------------|--------------------------------------|
|
|
32
|
+
| `punk login` | Log in with your punk account |
|
|
33
|
+
| `punk connect` | Connect this machine to punk |
|
|
34
|
+
| `punk logout` | Log out and clear stored credentials |
|
|
35
|
+
|
|
36
|
+
## Requirements
|
|
37
|
+
|
|
38
|
+
- Node 18+
|
|
39
|
+
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI installed
|
|
40
|
+
|
|
41
|
+
## License
|
|
42
|
+
|
|
43
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1030 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { program } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/version.ts
|
|
7
|
+
var version = "0.1.0";
|
|
8
|
+
|
|
9
|
+
// src/commands/connect.ts
|
|
10
|
+
import { io } from "socket.io-client";
|
|
11
|
+
|
|
12
|
+
// src/lib/claude-sdk.ts
|
|
13
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
14
|
+
async function* promptWithImages(text, images, sessionId) {
|
|
15
|
+
yield {
|
|
16
|
+
type: "user",
|
|
17
|
+
message: {
|
|
18
|
+
role: "user",
|
|
19
|
+
content: [
|
|
20
|
+
...images.map((img) => ({
|
|
21
|
+
type: "image",
|
|
22
|
+
source: {
|
|
23
|
+
type: "base64",
|
|
24
|
+
media_type: img.media_type,
|
|
25
|
+
data: img.data
|
|
26
|
+
}
|
|
27
|
+
})),
|
|
28
|
+
...text ? [{ type: "text", text }] : []
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
parent_tool_use_id: null,
|
|
32
|
+
session_id: sessionId
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function runClaude(options, callbacks) {
|
|
36
|
+
const opts = options.options || {};
|
|
37
|
+
const isBypass = opts.permissionMode === "bypassPermissions";
|
|
38
|
+
const pendingPermissions = /* @__PURE__ */ new Map();
|
|
39
|
+
let q;
|
|
40
|
+
try {
|
|
41
|
+
q = query({
|
|
42
|
+
prompt: options.images?.length ? promptWithImages(options.prompt, options.images, options.sessionId || "") : options.prompt,
|
|
43
|
+
options: {
|
|
44
|
+
permissionMode: opts.permissionMode || "default",
|
|
45
|
+
...isBypass && { allowDangerouslySkipPermissions: true },
|
|
46
|
+
...opts.model && { model: opts.model },
|
|
47
|
+
...opts.allowedTools && { allowedTools: opts.allowedTools },
|
|
48
|
+
...opts.disallowedTools && { disallowedTools: opts.disallowedTools },
|
|
49
|
+
...opts.maxTurns && { maxTurns: opts.maxTurns },
|
|
50
|
+
...options.cwd && { cwd: options.cwd },
|
|
51
|
+
...options.sessionId && { resume: options.sessionId },
|
|
52
|
+
maxThinkingTokens: 1e4,
|
|
53
|
+
includePartialMessages: true,
|
|
54
|
+
canUseTool: async (toolName, input, toolOpts) => {
|
|
55
|
+
if (!callbacks.onPermissionRequest) {
|
|
56
|
+
return { behavior: "allow", updatedInput: input };
|
|
57
|
+
}
|
|
58
|
+
const promise = new Promise((resolve) => {
|
|
59
|
+
pendingPermissions.set(toolOpts.toolUseID, resolve);
|
|
60
|
+
});
|
|
61
|
+
callbacks.onPermissionRequest({
|
|
62
|
+
toolUseId: toolOpts.toolUseID,
|
|
63
|
+
toolName,
|
|
64
|
+
input,
|
|
65
|
+
reason: toolOpts.decisionReason,
|
|
66
|
+
blockedPath: toolOpts.blockedPath
|
|
67
|
+
});
|
|
68
|
+
const result = await promise;
|
|
69
|
+
pendingPermissions.delete(toolOpts.toolUseID);
|
|
70
|
+
if (!result.allow) {
|
|
71
|
+
return { behavior: "deny", message: result.feedback || "Denied by user" };
|
|
72
|
+
}
|
|
73
|
+
if (toolName === "AskUserQuestion" && result.answers) {
|
|
74
|
+
return {
|
|
75
|
+
behavior: "allow",
|
|
76
|
+
updatedInput: { ...input, answers: result.answers }
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return { behavior: "allow", updatedInput: input };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
} catch (err) {
|
|
84
|
+
callbacks.onError(`Failed to create query: ${err}`);
|
|
85
|
+
return {
|
|
86
|
+
abort: () => {
|
|
87
|
+
},
|
|
88
|
+
resolvePermission: () => {
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
(async () => {
|
|
93
|
+
try {
|
|
94
|
+
for await (const message of q) {
|
|
95
|
+
switch (message.type) {
|
|
96
|
+
case "assistant": {
|
|
97
|
+
const content = message.message?.content ?? [];
|
|
98
|
+
for (const block of content) {
|
|
99
|
+
if (block.type === "tool_use") {
|
|
100
|
+
const tb = block;
|
|
101
|
+
callbacks.onToolUse(tb.id, tb.name, tb.input, message.parent_tool_use_id);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
case "stream_event": {
|
|
107
|
+
const evt = message.event;
|
|
108
|
+
if (evt.type === "content_block_delta") {
|
|
109
|
+
if (evt.delta.type === "text_delta") {
|
|
110
|
+
callbacks.onText(evt.delta.text);
|
|
111
|
+
} else if (evt.delta.type === "thinking_delta") {
|
|
112
|
+
callbacks.onThinking?.(evt.delta.thinking);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
case "user": {
|
|
118
|
+
const userContent = message.message?.content;
|
|
119
|
+
if (Array.isArray(userContent)) {
|
|
120
|
+
for (const block of userContent) {
|
|
121
|
+
if (typeof block === "object" && block !== null && "type" in block && block.type === "tool_result") {
|
|
122
|
+
const tr = block;
|
|
123
|
+
callbacks.onToolResult(
|
|
124
|
+
tr.tool_use_id,
|
|
125
|
+
tr.content,
|
|
126
|
+
tr.is_error === true
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
case "result": {
|
|
134
|
+
callbacks.onResult(message.session_id);
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} catch (err) {
|
|
140
|
+
callbacks.onError(`Query error: ${err}`);
|
|
141
|
+
}
|
|
142
|
+
})();
|
|
143
|
+
return {
|
|
144
|
+
abort: () => {
|
|
145
|
+
try {
|
|
146
|
+
q.close();
|
|
147
|
+
} catch {
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
resolvePermission: (toolUseId, allow, answers, feedback) => {
|
|
151
|
+
pendingPermissions.get(toolUseId)?.({ allow, answers, feedback });
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/lib/device-info.ts
|
|
157
|
+
import os from "os";
|
|
158
|
+
import path from "path";
|
|
159
|
+
import fs from "fs";
|
|
160
|
+
import crypto from "crypto";
|
|
161
|
+
import { execaSync } from "execa";
|
|
162
|
+
var PUNK_DIR = path.join(os.homedir(), ".punk");
|
|
163
|
+
var CONFIG_FILE = path.join(PUNK_DIR, "config.json");
|
|
164
|
+
function getOrCreateDeviceId() {
|
|
165
|
+
try {
|
|
166
|
+
const config2 = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
|
|
167
|
+
if (config2.deviceId) return config2.deviceId;
|
|
168
|
+
} catch {
|
|
169
|
+
}
|
|
170
|
+
const id = crypto.randomUUID();
|
|
171
|
+
fs.mkdirSync(PUNK_DIR, { recursive: true });
|
|
172
|
+
let config = {};
|
|
173
|
+
try {
|
|
174
|
+
config = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
|
|
175
|
+
} catch {
|
|
176
|
+
}
|
|
177
|
+
config.deviceId = id;
|
|
178
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
179
|
+
return id;
|
|
180
|
+
}
|
|
181
|
+
function collectDeviceInfo(deviceId) {
|
|
182
|
+
const cpus = os.cpus();
|
|
183
|
+
return {
|
|
184
|
+
deviceId,
|
|
185
|
+
name: getDeviceName(),
|
|
186
|
+
platform: process.platform,
|
|
187
|
+
arch: process.arch,
|
|
188
|
+
username: os.userInfo().username,
|
|
189
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
190
|
+
cwd: process.cwd(),
|
|
191
|
+
model: getModel(),
|
|
192
|
+
cpuModel: cpus.length > 0 ? cpus[0].model : "Unknown",
|
|
193
|
+
memoryGB: Math.round(os.totalmem() / 1024 ** 3),
|
|
194
|
+
battery: parseBattery(),
|
|
195
|
+
claudeCodeVersion: getClaudeVersion()
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function getDeviceName() {
|
|
199
|
+
if (process.platform === "darwin") {
|
|
200
|
+
try {
|
|
201
|
+
const { stdout } = execaSync("scutil", ["--get", "ComputerName"], { timeout: 3e3 });
|
|
202
|
+
const name = stdout.trim();
|
|
203
|
+
if (name) return name;
|
|
204
|
+
} catch {
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return os.hostname();
|
|
208
|
+
}
|
|
209
|
+
function parseBattery() {
|
|
210
|
+
try {
|
|
211
|
+
if (process.platform === "darwin") {
|
|
212
|
+
const { stdout: out } = execaSync("pmset", ["-g", "batt"], { timeout: 3e3 });
|
|
213
|
+
const match = out.match(/(\d+)%;\s*(charging|discharging|charged|finishing charge)/i);
|
|
214
|
+
if (match) {
|
|
215
|
+
return {
|
|
216
|
+
level: parseInt(match[1], 10),
|
|
217
|
+
charging: match[2].toLowerCase() !== "discharging"
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
} else if (process.platform === "linux") {
|
|
221
|
+
const capacity = fs.readFileSync("/sys/class/power_supply/BAT0/capacity", "utf-8").trim();
|
|
222
|
+
const status = fs.readFileSync("/sys/class/power_supply/BAT0/status", "utf-8").trim();
|
|
223
|
+
return {
|
|
224
|
+
level: parseInt(capacity, 10),
|
|
225
|
+
charging: status.toLowerCase() !== "discharging"
|
|
226
|
+
};
|
|
227
|
+
} else if (process.platform === "win32") {
|
|
228
|
+
const { stdout: out } = execaSync("wmic", [
|
|
229
|
+
"path",
|
|
230
|
+
"Win32_Battery",
|
|
231
|
+
"get",
|
|
232
|
+
"EstimatedChargeRemaining,BatteryStatus",
|
|
233
|
+
"/format:csv"
|
|
234
|
+
], { timeout: 3e3 });
|
|
235
|
+
const lines = out.trim().split("\n").filter(Boolean);
|
|
236
|
+
if (lines.length >= 2) {
|
|
237
|
+
const parts = lines[lines.length - 1].split(",");
|
|
238
|
+
if (parts.length >= 3) {
|
|
239
|
+
return {
|
|
240
|
+
level: parseInt(parts[2], 10),
|
|
241
|
+
charging: parts[1] !== "1"
|
|
242
|
+
// 1 = discharging
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
function getClaudeVersion() {
|
|
252
|
+
try {
|
|
253
|
+
const { stdout } = execaSync("claude", ["--version"], { timeout: 5e3 });
|
|
254
|
+
return stdout.trim() || null;
|
|
255
|
+
} catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function getModel() {
|
|
260
|
+
try {
|
|
261
|
+
if (process.platform === "darwin") {
|
|
262
|
+
return execaSync("sysctl", ["-n", "hw.model"], { timeout: 3e3 }).stdout.trim() || null;
|
|
263
|
+
} else if (process.platform === "linux") {
|
|
264
|
+
return fs.readFileSync("/sys/devices/virtual/dmi/id/product_name", "utf-8").trim() || null;
|
|
265
|
+
} else if (process.platform === "win32") {
|
|
266
|
+
const { stdout: out } = execaSync("wmic", ["csproduct", "get", "name", "/format:csv"], { timeout: 3e3 });
|
|
267
|
+
const lines = out.trim().split("\n").filter(Boolean);
|
|
268
|
+
if (lines.length >= 2) {
|
|
269
|
+
const parts = lines[lines.length - 1].split(",");
|
|
270
|
+
return parts.length >= 2 ? parts[1].trim() || null : null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} catch {
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// src/lib/session.ts
|
|
279
|
+
import { readdir, readFile, stat, open } from "fs/promises";
|
|
280
|
+
import { join } from "path";
|
|
281
|
+
import { homedir } from "os";
|
|
282
|
+
var CLAUDE_DIR = join(homedir(), ".claude", "projects");
|
|
283
|
+
async function loadSession(sessionId) {
|
|
284
|
+
const sessionFile = `${sessionId}.jsonl`;
|
|
285
|
+
let projectDirs;
|
|
286
|
+
try {
|
|
287
|
+
projectDirs = await readdir(CLAUDE_DIR);
|
|
288
|
+
} catch {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
for (const projectDir of projectDirs) {
|
|
292
|
+
const sessionPath = join(CLAUDE_DIR, projectDir, sessionFile);
|
|
293
|
+
try {
|
|
294
|
+
const content = await readFile(sessionPath, "utf-8");
|
|
295
|
+
return parseSessionFile(content);
|
|
296
|
+
} catch {
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
302
|
+
function isValidSessionUUID(name) {
|
|
303
|
+
return UUID_RE.test(name);
|
|
304
|
+
}
|
|
305
|
+
async function listSessions() {
|
|
306
|
+
let projectDirs;
|
|
307
|
+
try {
|
|
308
|
+
projectDirs = await readdir(CLAUDE_DIR);
|
|
309
|
+
} catch {
|
|
310
|
+
return [];
|
|
311
|
+
}
|
|
312
|
+
const candidates = [];
|
|
313
|
+
for (const projectDir of projectDirs) {
|
|
314
|
+
const projectPath = join(CLAUDE_DIR, projectDir);
|
|
315
|
+
let files;
|
|
316
|
+
try {
|
|
317
|
+
files = await readdir(projectPath);
|
|
318
|
+
} catch {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
for (const file of files) {
|
|
322
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
323
|
+
const sessionId = file.replace(".jsonl", "");
|
|
324
|
+
if (!isValidSessionUUID(sessionId)) continue;
|
|
325
|
+
candidates.push({
|
|
326
|
+
sessionId,
|
|
327
|
+
project: projectDir,
|
|
328
|
+
filePath: join(projectPath, file)
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const results = await Promise.all(
|
|
333
|
+
candidates.map(async (c) => {
|
|
334
|
+
try {
|
|
335
|
+
const fileStat = await stat(c.filePath);
|
|
336
|
+
const [titleInfo, turnCount] = await Promise.all([
|
|
337
|
+
extractTitle(c.filePath),
|
|
338
|
+
countTurns(c.filePath)
|
|
339
|
+
]);
|
|
340
|
+
return {
|
|
341
|
+
sessionId: c.sessionId,
|
|
342
|
+
project: c.project,
|
|
343
|
+
title: titleInfo.title,
|
|
344
|
+
lastModified: fileStat.mtimeMs,
|
|
345
|
+
turnCount,
|
|
346
|
+
...titleInfo.summary && { summary: titleInfo.summary }
|
|
347
|
+
};
|
|
348
|
+
} catch {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
);
|
|
353
|
+
const sessions = results.filter((s) => s !== null);
|
|
354
|
+
sessions.sort((a, b) => b.lastModified - a.lastModified);
|
|
355
|
+
return sessions.slice(0, 50);
|
|
356
|
+
}
|
|
357
|
+
var HEAD_READ_BYTES = 8192;
|
|
358
|
+
var TAIL_READ_BYTES = 16384;
|
|
359
|
+
async function extractTitle(filePath) {
|
|
360
|
+
const fh = await open(filePath, "r");
|
|
361
|
+
try {
|
|
362
|
+
const tailResult = await extractFromTail(fh, filePath);
|
|
363
|
+
if (tailResult) return tailResult;
|
|
364
|
+
const firstMsg = await extractFirstUserMessage(fh);
|
|
365
|
+
return { title: firstMsg };
|
|
366
|
+
} finally {
|
|
367
|
+
await fh.close();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
async function extractFromTail(fh, filePath) {
|
|
371
|
+
const fileStat = await stat(filePath);
|
|
372
|
+
const fileSize = fileStat.size;
|
|
373
|
+
if (fileSize === 0) return null;
|
|
374
|
+
const readSize = Math.min(TAIL_READ_BYTES, fileSize);
|
|
375
|
+
const offset = fileSize - readSize;
|
|
376
|
+
const buf = Buffer.alloc(readSize);
|
|
377
|
+
const { bytesRead } = await fh.read(buf, 0, readSize, offset);
|
|
378
|
+
const chunk = buf.toString("utf-8", 0, bytesRead);
|
|
379
|
+
const lines = chunk.split("\n");
|
|
380
|
+
let customTitle = null;
|
|
381
|
+
let summary = null;
|
|
382
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
383
|
+
const line = lines[i].trim();
|
|
384
|
+
if (!line) continue;
|
|
385
|
+
try {
|
|
386
|
+
const entry = JSON.parse(line);
|
|
387
|
+
if (entry.type === "custom-title" && entry.title && !customTitle) {
|
|
388
|
+
customTitle = entry.title;
|
|
389
|
+
}
|
|
390
|
+
if (entry.type === "summary" && entry.summary && !summary) {
|
|
391
|
+
summary = typeof entry.summary === "string" ? entry.summary : null;
|
|
392
|
+
}
|
|
393
|
+
if (customTitle && summary) break;
|
|
394
|
+
} catch {
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (customTitle) return { title: customTitle, summary: summary ?? void 0 };
|
|
398
|
+
if (summary) return { title: summary, summary };
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
async function extractFirstUserMessage(fh) {
|
|
402
|
+
const buf = Buffer.alloc(HEAD_READ_BYTES);
|
|
403
|
+
const { bytesRead } = await fh.read(buf, 0, HEAD_READ_BYTES, 0);
|
|
404
|
+
const chunk = buf.toString("utf-8", 0, bytesRead);
|
|
405
|
+
const lines = chunk.split("\n");
|
|
406
|
+
const metaUuids = /* @__PURE__ */ new Set();
|
|
407
|
+
for (const line of lines.slice(0, 20)) {
|
|
408
|
+
if (!line.trim()) continue;
|
|
409
|
+
try {
|
|
410
|
+
const entry = JSON.parse(line);
|
|
411
|
+
if (entry.isMeta || entry.parentUuid && metaUuids.has(entry.parentUuid)) {
|
|
412
|
+
if (entry.uuid) metaUuids.add(entry.uuid);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (entry.type === "user" && entry.message?.role === "user") {
|
|
416
|
+
const content = entry.message.content;
|
|
417
|
+
if (typeof content === "string" && content.trim()) {
|
|
418
|
+
return content.trim().slice(0, 100);
|
|
419
|
+
}
|
|
420
|
+
if (Array.isArray(content)) {
|
|
421
|
+
const textBlock = content.find(
|
|
422
|
+
(b) => b.type === "text" && b.text && !b.text.startsWith("[Request interrupted")
|
|
423
|
+
);
|
|
424
|
+
if (textBlock?.text) {
|
|
425
|
+
return textBlock.text.slice(0, 100);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
} catch {
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return "Untitled session";
|
|
433
|
+
}
|
|
434
|
+
async function countTurns(filePath) {
|
|
435
|
+
const content = await readFile(filePath, "utf-8");
|
|
436
|
+
const lines = content.split("\n");
|
|
437
|
+
let count = 0;
|
|
438
|
+
const metaUuids = /* @__PURE__ */ new Set();
|
|
439
|
+
for (const line of lines) {
|
|
440
|
+
if (!line.trim()) continue;
|
|
441
|
+
try {
|
|
442
|
+
const entry = JSON.parse(line);
|
|
443
|
+
if (entry.isMeta || entry.parentUuid && metaUuids.has(entry.parentUuid)) {
|
|
444
|
+
if (entry.uuid) metaUuids.add(entry.uuid);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (entry.type !== "user" || !entry.message || entry.message.role !== "user") continue;
|
|
448
|
+
const msgContent = entry.message.content;
|
|
449
|
+
if (typeof msgContent === "string") {
|
|
450
|
+
const trimmed = msgContent.trim();
|
|
451
|
+
if (trimmed && !trimmed.startsWith("<") && !trimmed.startsWith("[") && !trimmed.startsWith("/")) {
|
|
452
|
+
count++;
|
|
453
|
+
}
|
|
454
|
+
} else if (Array.isArray(msgContent)) {
|
|
455
|
+
const textBlock = msgContent.find(
|
|
456
|
+
(b) => b.type === "text" && b.text
|
|
457
|
+
);
|
|
458
|
+
if (textBlock?.text) {
|
|
459
|
+
const text = textBlock.text.trim();
|
|
460
|
+
if (text && !text.startsWith("<") && !text.startsWith("[") && !text.startsWith("/")) {
|
|
461
|
+
count++;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
} catch {
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return count;
|
|
469
|
+
}
|
|
470
|
+
function parseSessionFile(content) {
|
|
471
|
+
const messages = [];
|
|
472
|
+
const lines = content.split("\n").filter((line) => line.trim());
|
|
473
|
+
const metaUuids = /* @__PURE__ */ new Set();
|
|
474
|
+
for (const line of lines) {
|
|
475
|
+
try {
|
|
476
|
+
const entry = JSON.parse(line);
|
|
477
|
+
if (entry.isMeta || entry.parentUuid && metaUuids.has(entry.parentUuid)) {
|
|
478
|
+
if (entry.uuid) metaUuids.add(entry.uuid);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
if ((entry.type === "user" || entry.type === "assistant") && entry.message) {
|
|
482
|
+
const msgContent = entry.message.content;
|
|
483
|
+
messages.push({
|
|
484
|
+
role: entry.message.role,
|
|
485
|
+
content: typeof msgContent === "string" ? [{ type: "text", text: msgContent }] : msgContent,
|
|
486
|
+
timestamp: entry.timestamp
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
} catch {
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
for (let i = 0; i < messages.length; i++) {
|
|
493
|
+
const msg = messages[i];
|
|
494
|
+
if (msg.role !== "user") continue;
|
|
495
|
+
const blocks = msg.content;
|
|
496
|
+
if (!Array.isArray(blocks)) continue;
|
|
497
|
+
for (const block of blocks) {
|
|
498
|
+
if (block.type !== "tool_result" || !Array.isArray(block.content)) continue;
|
|
499
|
+
const imgBlock = block.content.find(
|
|
500
|
+
(b) => b?.type === "image" && b?.source?.type === "base64" && b?.source?.data
|
|
501
|
+
);
|
|
502
|
+
if (!imgBlock) continue;
|
|
503
|
+
const dataUri = `data:${imgBlock.source.media_type};base64,${imgBlock.source.data}`;
|
|
504
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
505
|
+
if (messages[j].role !== "assistant") continue;
|
|
506
|
+
const aBlocks = messages[j].content;
|
|
507
|
+
if (!Array.isArray(aBlocks)) break;
|
|
508
|
+
const toolUse = aBlocks.find(
|
|
509
|
+
(b) => b.type === "tool_use" && b.id === block.tool_use_id
|
|
510
|
+
);
|
|
511
|
+
if (toolUse) toolUse.imageUri = dataUri;
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
const merged = [];
|
|
517
|
+
for (const msg of messages) {
|
|
518
|
+
if (msg.role === "user") {
|
|
519
|
+
const blocks = msg.content;
|
|
520
|
+
const hasText = blocks.some((b) => b.type === "text" && b.text?.trim());
|
|
521
|
+
if (!hasText) continue;
|
|
522
|
+
}
|
|
523
|
+
const prev = merged[merged.length - 1];
|
|
524
|
+
if (msg.role === "assistant" && prev?.role === "assistant") {
|
|
525
|
+
prev.content = [...prev.content, ...msg.content];
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
merged.push({ ...msg, content: [...msg.content] });
|
|
529
|
+
}
|
|
530
|
+
return merged;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// src/lib/context.ts
|
|
534
|
+
import { execa } from "execa";
|
|
535
|
+
|
|
536
|
+
// src/utils/logger.ts
|
|
537
|
+
import pino from "pino";
|
|
538
|
+
var level = process.env.LOG_LEVEL ?? "info";
|
|
539
|
+
var format = process.env.PUNK_LOG_FORMAT ?? (process.stdout.isTTY ? "pretty" : "json");
|
|
540
|
+
var transport = format === "pretty" ? pino.transport({
|
|
541
|
+
target: "pino-pretty",
|
|
542
|
+
options: { colorize: true }
|
|
543
|
+
}) : void 0;
|
|
544
|
+
var logger = pino({ level }, transport);
|
|
545
|
+
function createChildLogger(bindings) {
|
|
546
|
+
return logger.child(bindings);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// src/lib/context.ts
|
|
550
|
+
var log = createChildLogger({ component: "context" });
|
|
551
|
+
async function getContext(sessionId, cwd) {
|
|
552
|
+
let stdout;
|
|
553
|
+
try {
|
|
554
|
+
const result = await execa("claude", [
|
|
555
|
+
"-p",
|
|
556
|
+
"--output-format",
|
|
557
|
+
"json",
|
|
558
|
+
"--verbose",
|
|
559
|
+
"--resume",
|
|
560
|
+
sessionId,
|
|
561
|
+
"/context"
|
|
562
|
+
], {
|
|
563
|
+
cwd: cwd || process.cwd(),
|
|
564
|
+
timeout: 3e4,
|
|
565
|
+
stdin: "ignore"
|
|
566
|
+
});
|
|
567
|
+
stdout = result.stdout;
|
|
568
|
+
if (result.stderr) {
|
|
569
|
+
log.warn({ stderr: result.stderr.trim() }, "Command stderr");
|
|
570
|
+
}
|
|
571
|
+
} catch (err) {
|
|
572
|
+
const execErr = err;
|
|
573
|
+
log.error({
|
|
574
|
+
exitCode: execErr.exitCode ?? null,
|
|
575
|
+
stderr: execErr.stderr?.trim(),
|
|
576
|
+
stdout: execErr.stdout?.slice(0, 500)
|
|
577
|
+
}, "Command failed");
|
|
578
|
+
throw err;
|
|
579
|
+
}
|
|
580
|
+
log.debug({ chars: stdout.length }, "Raw stdout");
|
|
581
|
+
const markdown = extractMarkdown(stdout);
|
|
582
|
+
log.debug({ chars: markdown.length }, "Parsed markdown");
|
|
583
|
+
return parseContextMarkdown(markdown);
|
|
584
|
+
}
|
|
585
|
+
function extractMarkdown(stdout) {
|
|
586
|
+
const parsed = JSON.parse(stdout);
|
|
587
|
+
const block = parsed[1];
|
|
588
|
+
const content = block?.message?.content ?? "";
|
|
589
|
+
const match = content.match(/<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/);
|
|
590
|
+
return match ? match[1] : content;
|
|
591
|
+
}
|
|
592
|
+
function parseTokenValue(raw) {
|
|
593
|
+
const trimmed = raw.trim().replace(/,/g, "");
|
|
594
|
+
const kMatch = trimmed.match(/^([\d.]+)k$/i);
|
|
595
|
+
if (kMatch) {
|
|
596
|
+
return Math.round(parseFloat(kMatch[1]) * 1e3);
|
|
597
|
+
}
|
|
598
|
+
return parseInt(trimmed, 10) || 0;
|
|
599
|
+
}
|
|
600
|
+
function parseContextMarkdown(markdown) {
|
|
601
|
+
const data = {
|
|
602
|
+
model: "",
|
|
603
|
+
totalTokens: 0,
|
|
604
|
+
contextWindow: 0,
|
|
605
|
+
usedPercentage: 0,
|
|
606
|
+
categories: [],
|
|
607
|
+
mcpTools: [],
|
|
608
|
+
memoryFiles: [],
|
|
609
|
+
skills: [],
|
|
610
|
+
rawMarkdown: markdown
|
|
611
|
+
};
|
|
612
|
+
const modelMatch = markdown.match(/\*\*Model:\*\*\s*(.+)/);
|
|
613
|
+
if (modelMatch) {
|
|
614
|
+
data.model = modelMatch[1].trim();
|
|
615
|
+
}
|
|
616
|
+
const tokenMatch = markdown.match(/\*\*Tokens:\*\*\s*([\d.,]+k?)\s*\/\s*([\d.,]+k?)\s*\((\d+)%\)/i);
|
|
617
|
+
if (tokenMatch) {
|
|
618
|
+
data.totalTokens = parseTokenValue(tokenMatch[1]);
|
|
619
|
+
data.contextWindow = parseTokenValue(tokenMatch[2]);
|
|
620
|
+
data.usedPercentage = parseInt(tokenMatch[3], 10);
|
|
621
|
+
}
|
|
622
|
+
data.categories = parseTable(markdown, "Estimated usage by category", ["category", "tokens", "percentage"]);
|
|
623
|
+
data.mcpTools = parseTable(markdown, "MCP Tools", ["tool", "server", "tokens"]);
|
|
624
|
+
data.memoryFiles = parseTable(markdown, "Memory Files", ["type", "path", "tokens"]);
|
|
625
|
+
data.skills = parseTable(markdown, "Skills", ["skill", "source", "tokens"]);
|
|
626
|
+
return data;
|
|
627
|
+
}
|
|
628
|
+
function parseTable(markdown, sectionHeader, keys) {
|
|
629
|
+
const headerPattern = new RegExp(`#{2,3}\\s*${escapeRegex(sectionHeader)}`, "i");
|
|
630
|
+
const headerMatch = markdown.match(headerPattern);
|
|
631
|
+
if (!headerMatch || headerMatch.index === void 0) return [];
|
|
632
|
+
const afterHeader = markdown.slice(headerMatch.index + headerMatch[0].length);
|
|
633
|
+
const nextSection = afterHeader.search(/\n#{2,3}\s/);
|
|
634
|
+
const sectionText = nextSection !== -1 ? afterHeader.slice(0, nextSection) : afterHeader;
|
|
635
|
+
const lines = sectionText.split("\n").filter((line) => line.trim().startsWith("|"));
|
|
636
|
+
if (lines.length < 3) return [];
|
|
637
|
+
const dataRows = lines.slice(2);
|
|
638
|
+
return dataRows.map((row) => {
|
|
639
|
+
const cells = row.split("|").slice(1, -1).map((c) => c.trim());
|
|
640
|
+
const obj = {};
|
|
641
|
+
keys.forEach((key, i) => {
|
|
642
|
+
const cell = cells[i] ?? "";
|
|
643
|
+
if (key === "tokens") {
|
|
644
|
+
obj[key] = parseTokenValue(cell);
|
|
645
|
+
} else if (key === "percentage") {
|
|
646
|
+
obj[key] = parseInt(cell.replace("%", ""), 10) || 0;
|
|
647
|
+
} else {
|
|
648
|
+
obj[key] = cell;
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
return obj;
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
function escapeRegex(str) {
|
|
655
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// src/lib/auth.ts
|
|
659
|
+
import fs2 from "fs";
|
|
660
|
+
import path2 from "path";
|
|
661
|
+
import os2 from "os";
|
|
662
|
+
var FIREBASE_API_KEY = "AIzaSyDI5_jEY2s4UDB04av_p3RNkgZu3G7Sl18";
|
|
663
|
+
var AUTH_FILE = path2.join(os2.homedir(), ".punk", "auth.json");
|
|
664
|
+
function loadAuth() {
|
|
665
|
+
try {
|
|
666
|
+
return JSON.parse(fs2.readFileSync(AUTH_FILE, "utf-8"));
|
|
667
|
+
} catch {
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
function saveAuth(auth) {
|
|
672
|
+
const dir = path2.dirname(AUTH_FILE);
|
|
673
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
674
|
+
fs2.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2) + "\n", "utf-8");
|
|
675
|
+
}
|
|
676
|
+
function clearAuth() {
|
|
677
|
+
try {
|
|
678
|
+
fs2.unlinkSync(AUTH_FILE);
|
|
679
|
+
} catch {
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
async function signIn(email, password) {
|
|
683
|
+
const res = await fetch(
|
|
684
|
+
`https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${FIREBASE_API_KEY}`,
|
|
685
|
+
{
|
|
686
|
+
method: "POST",
|
|
687
|
+
headers: { "Content-Type": "application/json" },
|
|
688
|
+
body: JSON.stringify({ email, password, returnSecureToken: true })
|
|
689
|
+
}
|
|
690
|
+
);
|
|
691
|
+
if (!res.ok) {
|
|
692
|
+
const body = await res.json().catch(() => ({}));
|
|
693
|
+
const code = body?.error?.message ?? `HTTP ${res.status}`;
|
|
694
|
+
throw new Error(`Login failed: ${code}`);
|
|
695
|
+
}
|
|
696
|
+
const data = await res.json();
|
|
697
|
+
const auth = {
|
|
698
|
+
idToken: data.idToken,
|
|
699
|
+
refreshToken: data.refreshToken,
|
|
700
|
+
expiresAt: Date.now() + parseInt(data.expiresIn, 10) * 1e3,
|
|
701
|
+
email: data.email,
|
|
702
|
+
uid: data.localId
|
|
703
|
+
};
|
|
704
|
+
saveAuth(auth);
|
|
705
|
+
return auth;
|
|
706
|
+
}
|
|
707
|
+
async function refreshIdToken() {
|
|
708
|
+
const auth = loadAuth();
|
|
709
|
+
if (!auth) {
|
|
710
|
+
throw new Error("Not logged in. Run `punk login` first.");
|
|
711
|
+
}
|
|
712
|
+
if (auth.expiresAt - Date.now() > 5 * 60 * 1e3) {
|
|
713
|
+
return auth.idToken;
|
|
714
|
+
}
|
|
715
|
+
const res = await fetch(
|
|
716
|
+
`https://securetoken.googleapis.com/v1/token?key=${FIREBASE_API_KEY}`,
|
|
717
|
+
{
|
|
718
|
+
method: "POST",
|
|
719
|
+
headers: { "Content-Type": "application/json" },
|
|
720
|
+
body: JSON.stringify({
|
|
721
|
+
grant_type: "refresh_token",
|
|
722
|
+
refresh_token: auth.refreshToken
|
|
723
|
+
})
|
|
724
|
+
}
|
|
725
|
+
);
|
|
726
|
+
if (!res.ok) {
|
|
727
|
+
const body = await res.json().catch(() => ({}));
|
|
728
|
+
const code = body?.error?.message ?? `HTTP ${res.status}`;
|
|
729
|
+
throw new Error(`Token refresh failed: ${code}. Run \`punk login\` again.`);
|
|
730
|
+
}
|
|
731
|
+
const data = await res.json();
|
|
732
|
+
const updated = {
|
|
733
|
+
...auth,
|
|
734
|
+
idToken: data.id_token,
|
|
735
|
+
refreshToken: data.refresh_token,
|
|
736
|
+
expiresAt: Date.now() + parseInt(data.expires_in, 10) * 1e3
|
|
737
|
+
};
|
|
738
|
+
saveAuth(updated);
|
|
739
|
+
return updated.idToken;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// src/commands/connect.ts
|
|
743
|
+
async function connect(server, options) {
|
|
744
|
+
const deviceId = options.deviceId || getOrCreateDeviceId();
|
|
745
|
+
const url = buildUrl(server);
|
|
746
|
+
let idToken;
|
|
747
|
+
if (options.token) {
|
|
748
|
+
idToken = options.token;
|
|
749
|
+
} else {
|
|
750
|
+
try {
|
|
751
|
+
idToken = await refreshIdToken();
|
|
752
|
+
} catch (err) {
|
|
753
|
+
logger.error({ err }, "Auth failed");
|
|
754
|
+
process.exit(1);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
logger.info("Connecting...");
|
|
758
|
+
const socket = io(url, {
|
|
759
|
+
path: "/socket.io",
|
|
760
|
+
transports: ["websocket"],
|
|
761
|
+
auth: { token: idToken },
|
|
762
|
+
reconnection: true,
|
|
763
|
+
reconnectionAttempts: Infinity,
|
|
764
|
+
reconnectionDelay: 1e3,
|
|
765
|
+
reconnectionDelayMax: 5e3
|
|
766
|
+
});
|
|
767
|
+
const activeSessions = /* @__PURE__ */ new Map();
|
|
768
|
+
socket.on("connect", () => {
|
|
769
|
+
logger.info("Connected");
|
|
770
|
+
const deviceInfo = collectDeviceInfo(deviceId);
|
|
771
|
+
socket.emit("register", deviceInfo, (response) => {
|
|
772
|
+
if (response.success) {
|
|
773
|
+
logger.info({ deviceId }, "Registered");
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
socket.on("prompt", (msg) => {
|
|
778
|
+
if (msg.type === "prompt") {
|
|
779
|
+
handlePrompt(socket, msg, activeSessions);
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
socket.on("load-session", (msg) => {
|
|
783
|
+
if (msg.type === "load-session") {
|
|
784
|
+
handleLoadSession(socket, msg);
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
socket.on("list-sessions", async (msg) => {
|
|
788
|
+
if (msg.type === "list-sessions") {
|
|
789
|
+
handleListSessions(socket, msg);
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
socket.on("get-context", (msg) => {
|
|
793
|
+
if (msg.type === "get-context") {
|
|
794
|
+
handleGetContext(socket, msg);
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
socket.on("cancel", (msg) => {
|
|
798
|
+
handleCancel(msg.id, activeSessions);
|
|
799
|
+
});
|
|
800
|
+
socket.on("permission-response", (msg) => {
|
|
801
|
+
const session = activeSessions.get(msg.requestId);
|
|
802
|
+
if (session) {
|
|
803
|
+
session.resolvePermission(msg.toolUseId, msg.allow, msg.answers, msg.feedback);
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
socket.on("disconnect", (reason) => {
|
|
807
|
+
logger.info({ reason }, "Disconnected");
|
|
808
|
+
});
|
|
809
|
+
socket.io.on("reconnect_attempt", () => {
|
|
810
|
+
logger.info("Reconnecting...");
|
|
811
|
+
refreshIdToken().then((token) => {
|
|
812
|
+
socket.auth = { token };
|
|
813
|
+
}).catch(() => {
|
|
814
|
+
});
|
|
815
|
+
});
|
|
816
|
+
socket.on("reconnect", (attemptNumber) => {
|
|
817
|
+
logger.info({ attemptNumber }, "Reconnected");
|
|
818
|
+
socket.emit("register", collectDeviceInfo(deviceId));
|
|
819
|
+
});
|
|
820
|
+
socket.on("connect_error", (err) => {
|
|
821
|
+
logger.error({ err }, "Connection error");
|
|
822
|
+
});
|
|
823
|
+
const refreshInterval = setInterval(async () => {
|
|
824
|
+
try {
|
|
825
|
+
const token = await refreshIdToken();
|
|
826
|
+
socket.emit("re-auth", { token });
|
|
827
|
+
} catch {
|
|
828
|
+
}
|
|
829
|
+
}, 50 * 60 * 1e3);
|
|
830
|
+
const cleanup = () => {
|
|
831
|
+
clearInterval(refreshInterval);
|
|
832
|
+
for (const session of activeSessions.values()) {
|
|
833
|
+
session.abort();
|
|
834
|
+
}
|
|
835
|
+
socket.disconnect();
|
|
836
|
+
};
|
|
837
|
+
process.on("SIGINT", () => {
|
|
838
|
+
logger.info("Shutting down...");
|
|
839
|
+
cleanup();
|
|
840
|
+
process.exit(0);
|
|
841
|
+
});
|
|
842
|
+
await new Promise(() => {
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
function buildUrl(server) {
|
|
846
|
+
const url = new URL(server);
|
|
847
|
+
if (!url.pathname.endsWith("/device")) {
|
|
848
|
+
url.pathname = url.pathname.replace(/\/$/, "") + "/device";
|
|
849
|
+
}
|
|
850
|
+
return url.origin + url.pathname;
|
|
851
|
+
}
|
|
852
|
+
function send(socket, event, msg) {
|
|
853
|
+
socket.emit(event, msg);
|
|
854
|
+
}
|
|
855
|
+
function handlePrompt(socket, msg, activeSessions) {
|
|
856
|
+
const { id, prompt: prompt2, sessionId, cwd, images, options } = msg;
|
|
857
|
+
const log2 = createChildLogger({ sessionId: id });
|
|
858
|
+
log2.info({ prompt: prompt2.slice(0, 80) }, "Session started");
|
|
859
|
+
const handle = runClaude(
|
|
860
|
+
{ prompt: prompt2, sessionId, cwd, images, options },
|
|
861
|
+
{
|
|
862
|
+
onText: (text) => {
|
|
863
|
+
send(socket, "response", { type: "text", text, requestId: id });
|
|
864
|
+
},
|
|
865
|
+
onThinking: (thinking) => {
|
|
866
|
+
send(socket, "response", { type: "thinking", thinking, requestId: id });
|
|
867
|
+
},
|
|
868
|
+
onToolUse: (toolId, name, input, parentToolUseId) => {
|
|
869
|
+
send(socket, "response", { type: "tool_use", id: toolId, name, input, requestId: id, parent_tool_use_id: parentToolUseId ?? null });
|
|
870
|
+
},
|
|
871
|
+
onToolResult: (toolUseId, content, isError) => {
|
|
872
|
+
send(socket, "response", { type: "tool_result", tool_use_id: toolUseId, content, is_error: isError, requestId: id });
|
|
873
|
+
},
|
|
874
|
+
onResult: (sid) => {
|
|
875
|
+
send(socket, "response", { type: "result", session_id: sid, requestId: id });
|
|
876
|
+
activeSessions.delete(id);
|
|
877
|
+
log2.info("Session done");
|
|
878
|
+
},
|
|
879
|
+
onError: (message) => {
|
|
880
|
+
send(socket, "response", { type: "error", message, requestId: id });
|
|
881
|
+
activeSessions.delete(id);
|
|
882
|
+
log2.error({ error: message }, "Session error");
|
|
883
|
+
},
|
|
884
|
+
onPermissionRequest: (req) => {
|
|
885
|
+
log2.info({ toolName: req.toolName }, "Permission blocked");
|
|
886
|
+
socket.emit("permission-request", {
|
|
887
|
+
requestId: id,
|
|
888
|
+
toolUseId: req.toolUseId,
|
|
889
|
+
toolName: req.toolName,
|
|
890
|
+
input: req.input,
|
|
891
|
+
reason: req.reason,
|
|
892
|
+
blockedPath: req.blockedPath
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
);
|
|
897
|
+
activeSessions.set(id, handle);
|
|
898
|
+
}
|
|
899
|
+
function handleCancel(id, activeSessions) {
|
|
900
|
+
const session = activeSessions.get(id);
|
|
901
|
+
if (session) {
|
|
902
|
+
session.abort();
|
|
903
|
+
activeSessions.delete(id);
|
|
904
|
+
logger.info({ sessionId: id }, "Session cancelled");
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
async function handleListSessions(socket, msg) {
|
|
908
|
+
const { id } = msg;
|
|
909
|
+
logger.info("Listing sessions...");
|
|
910
|
+
const sessions = await listSessions();
|
|
911
|
+
send(socket, "response", { type: "sessions_list", sessions, requestId: id });
|
|
912
|
+
logger.info({ count: sessions.length }, "Listed sessions");
|
|
913
|
+
}
|
|
914
|
+
async function handleLoadSession(socket, msg) {
|
|
915
|
+
const { id, sessionId } = msg;
|
|
916
|
+
const log2 = createChildLogger({ sessionId });
|
|
917
|
+
log2.info("Loading session...");
|
|
918
|
+
const messages = await loadSession(sessionId);
|
|
919
|
+
if (messages) {
|
|
920
|
+
send(socket, "response", { type: "history", messages, requestId: id });
|
|
921
|
+
log2.info({ count: messages.length }, "Session loaded");
|
|
922
|
+
} else {
|
|
923
|
+
send(socket, "response", { type: "session_not_found", session_id: sessionId, requestId: id });
|
|
924
|
+
log2.warn("Session not found");
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
async function handleGetContext(socket, msg) {
|
|
928
|
+
const { id, sessionId, cwd } = msg;
|
|
929
|
+
const log2 = createChildLogger({ sessionId });
|
|
930
|
+
log2.info("Getting context...");
|
|
931
|
+
try {
|
|
932
|
+
const data = await getContext(sessionId, cwd);
|
|
933
|
+
send(socket, "response", { type: "context", data, requestId: id });
|
|
934
|
+
log2.info({ totalTokens: data.totalTokens }, "Context retrieved");
|
|
935
|
+
} catch (err) {
|
|
936
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
937
|
+
if (message.includes("not found") || message.includes("No such session")) {
|
|
938
|
+
send(socket, "response", { type: "session_not_found", session_id: sessionId, requestId: id });
|
|
939
|
+
log2.warn("Session not found");
|
|
940
|
+
} else {
|
|
941
|
+
send(socket, "response", { type: "error", message, requestId: id });
|
|
942
|
+
log2.error({ err }, "Context error");
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// src/commands/login.ts
|
|
948
|
+
import readline from "readline";
|
|
949
|
+
function prompt(question, hidden = false) {
|
|
950
|
+
if (!hidden) {
|
|
951
|
+
const rl = readline.createInterface({
|
|
952
|
+
input: process.stdin,
|
|
953
|
+
output: process.stdout
|
|
954
|
+
});
|
|
955
|
+
return new Promise((resolve) => {
|
|
956
|
+
rl.question(question, (answer) => {
|
|
957
|
+
rl.close();
|
|
958
|
+
resolve(answer.trim());
|
|
959
|
+
});
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
return new Promise((resolve) => {
|
|
963
|
+
process.stdout.write(question);
|
|
964
|
+
const stdin = process.stdin;
|
|
965
|
+
const wasRaw = stdin.isRaw;
|
|
966
|
+
stdin.setRawMode(true);
|
|
967
|
+
stdin.resume();
|
|
968
|
+
let input = "";
|
|
969
|
+
const onData = (ch) => {
|
|
970
|
+
const c = ch.toString("utf8");
|
|
971
|
+
if (c === "\n" || c === "\r") {
|
|
972
|
+
stdin.removeListener("data", onData);
|
|
973
|
+
stdin.setRawMode(wasRaw ?? false);
|
|
974
|
+
stdin.pause();
|
|
975
|
+
process.stdout.write("\n");
|
|
976
|
+
resolve(input);
|
|
977
|
+
} else if (c === "") {
|
|
978
|
+
process.exit(0);
|
|
979
|
+
} else if (c === "\x7F" || c === "\b") {
|
|
980
|
+
if (input.length > 0) {
|
|
981
|
+
input = input.slice(0, -1);
|
|
982
|
+
process.stdout.write("\b \b");
|
|
983
|
+
}
|
|
984
|
+
} else {
|
|
985
|
+
input += c;
|
|
986
|
+
process.stdout.write("*");
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
stdin.on("data", onData);
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
async function login() {
|
|
993
|
+
const email = await prompt("Email: ");
|
|
994
|
+
const password = await prompt("Password: ", true);
|
|
995
|
+
if (!email || !password) {
|
|
996
|
+
logger.error("Email and password are required.");
|
|
997
|
+
process.exit(1);
|
|
998
|
+
}
|
|
999
|
+
try {
|
|
1000
|
+
const auth = await signIn(email, password);
|
|
1001
|
+
logger.info({ success: true, email: auth.email }, "Logged in");
|
|
1002
|
+
} catch (err) {
|
|
1003
|
+
logger.error({ err }, "Login failed");
|
|
1004
|
+
process.exit(1);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
function logout() {
|
|
1008
|
+
clearAuth();
|
|
1009
|
+
logger.info({ success: true }, "Logged out");
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// src/commands/index.ts
|
|
1013
|
+
function registerCommands(program2) {
|
|
1014
|
+
program2.command("connect <server>").description("Connect to backend server").option("-t, --token <token>", "Authentication token").option("-d, --device-id <deviceId>", "Device identifier (defaults to hostname)").action(connect);
|
|
1015
|
+
program2.command("login").description("Log in with your email and password").action(login);
|
|
1016
|
+
program2.command("logout").description("Log out and clear stored credentials").action(logout);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// src/cli.ts
|
|
1020
|
+
program.name("punk").version(version).description("Punk CLI");
|
|
1021
|
+
registerCommands(program);
|
|
1022
|
+
async function main() {
|
|
1023
|
+
try {
|
|
1024
|
+
await program.parseAsync();
|
|
1025
|
+
} catch (err) {
|
|
1026
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1027
|
+
process.exit(1);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@punkcode/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Control Claude Code from your phone",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"punk": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsup",
|
|
14
|
+
"dev": "tsup --watch",
|
|
15
|
+
"dev:connect": "npx tsx src/cli.ts connect",
|
|
16
|
+
"dev:login": "npx tsx src/cli.ts login",
|
|
17
|
+
"dev:logout": "npx tsx src/cli.ts logout",
|
|
18
|
+
"test": "vitest",
|
|
19
|
+
"test:run": "vitest run",
|
|
20
|
+
"lint": "eslint src",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"prepublishOnly": "npm run lint && npm run typecheck && npm run build"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"cli",
|
|
29
|
+
"punk",
|
|
30
|
+
"punkcode",
|
|
31
|
+
"claude",
|
|
32
|
+
"ai",
|
|
33
|
+
"mobile",
|
|
34
|
+
"remote"
|
|
35
|
+
],
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"author": "punkcode",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/makeapop/punk-cli.git"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/makeapop/punk-cli#readme",
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/makeapop/punk-cli/issues"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@eslint/js": "^9.0.0",
|
|
48
|
+
"@types/node": "^25.2.0",
|
|
49
|
+
"eslint": "^9.0.0",
|
|
50
|
+
"tsup": "^8.0.0",
|
|
51
|
+
"typescript": "^5.4.0",
|
|
52
|
+
"typescript-eslint": "^8.0.0",
|
|
53
|
+
"vitest": "^4.0.18"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.37",
|
|
57
|
+
"@anthropic-ai/sdk": "^0.72.1",
|
|
58
|
+
"commander": "^14.0.3",
|
|
59
|
+
"execa": "^9.6.1",
|
|
60
|
+
"pino": "^10.3.1",
|
|
61
|
+
"pino-pretty": "^13.1.3",
|
|
62
|
+
"socket.io-client": "^4.8.3",
|
|
63
|
+
"zod": "^4.3.6"
|
|
64
|
+
}
|
|
65
|
+
}
|