@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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -0
  3. package/dist/cli.js +1030 -0
  4. 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
+ }