@hienlh/ppm 0.2.1 → 0.2.4
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/CHANGELOG.md +63 -0
- package/dist/web/assets/api-client-B_eCZViO.js +1 -0
- package/dist/web/assets/chat-tab-FOn2nq1x.js +6 -0
- package/dist/web/assets/{code-editor-BgiyQO-M.js → code-editor-R0uEZQ-h.js} +1 -1
- package/dist/web/assets/{diff-viewer-8_asmBRZ.js → diff-viewer-DDQ2Z0sz.js} +1 -1
- package/dist/web/assets/{git-graph-BiyTIbCz.js → git-graph-ugBsFNaz.js} +1 -1
- package/dist/web/assets/{git-status-panel-BifyO31N.js → git-status-panel-UMKtdAxp.js} +1 -1
- package/dist/web/assets/index-CGDMk8DE.css +2 -0
- package/dist/web/assets/index-Dmu22zQo.js +12 -0
- package/dist/web/assets/project-list-D38uQSpC.js +1 -0
- package/dist/web/assets/{settings-tab-Cn5Ja0_J.js → settings-tab-BpyCSbii.js} +1 -1
- package/dist/web/index.html +3 -3
- package/dist/web/sw.js +1 -1
- package/package.json +4 -4
- package/src/cli/commands/report.ts +1 -1
- package/src/cli/commands/status.ts +31 -22
- package/src/cli/commands/stop.ts +37 -29
- package/src/index.ts +2 -1
- package/src/server/index.ts +100 -66
- package/src/server/routes/chat.ts +13 -18
- package/src/server/routes/projects.ts +12 -0
- package/src/server/routes/static.ts +2 -2
- package/src/services/claude-usage.service.ts +93 -74
- package/src/services/project.service.ts +43 -0
- package/src/types/chat.ts +0 -2
- package/src/version.ts +7 -0
- package/src/web/components/chat/chat-tab.tsx +24 -7
- package/src/web/components/chat/usage-badge.tsx +23 -23
- package/src/web/components/layout/mobile-drawer.tsx +19 -1
- package/src/web/components/layout/sidebar.tsx +15 -4
- package/src/web/components/projects/project-list.tsx +153 -4
- package/src/web/hooks/use-chat.ts +30 -41
- package/src/web/hooks/use-usage.ts +65 -0
- package/src/web/lib/api-client.ts +9 -0
- package/src/web/lib/report-bug.ts +33 -0
- package/dist/web/assets/api-client-BgVufYKf.js +0 -1
- package/dist/web/assets/chat-tab-C4ovA2w4.js +0 -6
- package/dist/web/assets/index-DILaVO6p.css +0 -2
- package/dist/web/assets/index-DasstYgw.js +0 -11
- package/dist/web/assets/project-list-C7L3hZct.js +0 -1
package/src/server/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { cors } from "hono/cors";
|
|
3
3
|
import { configService } from "../services/config.service.ts";
|
|
4
|
+
import { VERSION } from "../version.ts";
|
|
4
5
|
import { authMiddleware } from "./middleware/auth.ts";
|
|
5
6
|
import { projectRoutes } from "./routes/projects.ts";
|
|
6
7
|
import { settingsRoutes } from "./routes/settings.ts";
|
|
@@ -61,7 +62,7 @@ app.use("*", cors());
|
|
|
61
62
|
// Public endpoints (before auth)
|
|
62
63
|
app.get("/api/health", (c) => c.json(ok({ status: "running" })));
|
|
63
64
|
app.get("/api/info", (c) => c.json(ok({
|
|
64
|
-
version:
|
|
65
|
+
version: VERSION,
|
|
65
66
|
device_name: configService.get("device_name") || null,
|
|
66
67
|
})));
|
|
67
68
|
|
|
@@ -117,6 +118,24 @@ export async function startServer(options: {
|
|
|
117
118
|
// Setup log file (both foreground and daemon modes)
|
|
118
119
|
await setupLogFile();
|
|
119
120
|
|
|
121
|
+
// Check if port is already in use before starting
|
|
122
|
+
const portInUse = await new Promise<boolean>((resolve) => {
|
|
123
|
+
const net = require("node:net") as typeof import("node:net");
|
|
124
|
+
const tester = net.createServer()
|
|
125
|
+
.once("error", (err: NodeJS.ErrnoException) => {
|
|
126
|
+
resolve(err.code === "EADDRINUSE");
|
|
127
|
+
})
|
|
128
|
+
.once("listening", () => {
|
|
129
|
+
tester.close(() => resolve(false));
|
|
130
|
+
})
|
|
131
|
+
.listen(port, host);
|
|
132
|
+
});
|
|
133
|
+
if (portInUse) {
|
|
134
|
+
console.error(`\n ✗ Port ${port} is already in use.`);
|
|
135
|
+
console.error(` Run 'ppm stop' first or use a different port with --port.\n`);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
|
|
120
139
|
const isDaemon = !options.foreground;
|
|
121
140
|
|
|
122
141
|
if (isDaemon) {
|
|
@@ -129,55 +148,87 @@ export async function startServer(options: {
|
|
|
129
148
|
const pidFile = resolve(ppmDir, "ppm.pid");
|
|
130
149
|
const statusFile = resolve(ppmDir, "status.json");
|
|
131
150
|
|
|
132
|
-
// If --share, download cloudflared
|
|
151
|
+
// If --share, download cloudflared and start tunnel as independent process
|
|
152
|
+
let shareUrl: string | undefined;
|
|
153
|
+
let tunnelPid: number | undefined;
|
|
133
154
|
if (options.share) {
|
|
134
155
|
const { ensureCloudflared } = await import("../services/cloudflared.service.ts");
|
|
135
|
-
await ensureCloudflared();
|
|
156
|
+
const bin = await ensureCloudflared();
|
|
157
|
+
|
|
158
|
+
// Check if tunnel already running (reuse from previous server crash)
|
|
159
|
+
if (existsSync(statusFile)) {
|
|
160
|
+
try {
|
|
161
|
+
const prev = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
162
|
+
if (prev.tunnelPid && prev.shareUrl) {
|
|
163
|
+
try {
|
|
164
|
+
process.kill(prev.tunnelPid, 0); // Check alive
|
|
165
|
+
console.log(` Reusing existing tunnel (PID: ${prev.tunnelPid})`);
|
|
166
|
+
shareUrl = prev.shareUrl;
|
|
167
|
+
tunnelPid = prev.tunnelPid;
|
|
168
|
+
} catch { /* tunnel dead, spawn new one */ }
|
|
169
|
+
}
|
|
170
|
+
} catch {}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Spawn new tunnel if no existing one
|
|
174
|
+
if (!shareUrl) {
|
|
175
|
+
console.log(" Starting share tunnel...");
|
|
176
|
+
const { openSync: openFd } = await import("node:fs");
|
|
177
|
+
const tunnelLog = resolve(ppmDir, "tunnel.log");
|
|
178
|
+
const tfd = openFd(tunnelLog, "a");
|
|
179
|
+
const tunnelProc = Bun.spawn({
|
|
180
|
+
cmd: [bin, "tunnel", "--url", `http://localhost:${port}`],
|
|
181
|
+
stdio: ["ignore", "ignore", tfd],
|
|
182
|
+
env: process.env,
|
|
183
|
+
});
|
|
184
|
+
tunnelProc.unref();
|
|
185
|
+
tunnelPid = tunnelProc.pid;
|
|
186
|
+
|
|
187
|
+
// Parse URL from tunnel.log (poll stderr output)
|
|
188
|
+
const urlRegex = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
189
|
+
const pollStart = Date.now();
|
|
190
|
+
while (Date.now() - pollStart < 30_000) {
|
|
191
|
+
await Bun.sleep(500);
|
|
192
|
+
try {
|
|
193
|
+
const logContent = readFileSync(tunnelLog, "utf-8");
|
|
194
|
+
const match = logContent.match(urlRegex);
|
|
195
|
+
if (match) { shareUrl = match[0]; break; }
|
|
196
|
+
} catch {}
|
|
197
|
+
}
|
|
198
|
+
if (!shareUrl) console.warn(" ⚠ Tunnel started but URL not detected.");
|
|
199
|
+
}
|
|
136
200
|
}
|
|
137
201
|
|
|
138
|
-
// Spawn child process with log file
|
|
202
|
+
// Spawn server child process with log file
|
|
139
203
|
const { openSync } = await import("node:fs");
|
|
140
204
|
const logFile = resolve(ppmDir, "ppm.log");
|
|
141
205
|
const logFd = openSync(logFile, "a");
|
|
142
206
|
const child = Bun.spawn({
|
|
143
207
|
cmd: [
|
|
144
208
|
process.execPath, "run", import.meta.dir + "/index.ts", "__serve__",
|
|
145
|
-
String(port), host, options.config ?? "",
|
|
209
|
+
String(port), host, options.config ?? "",
|
|
146
210
|
],
|
|
147
211
|
stdio: ["ignore", logFd, logFd],
|
|
148
212
|
env: process.env,
|
|
149
213
|
});
|
|
150
214
|
child.unref();
|
|
151
|
-
writeFileSync(pidFile, String(child.pid));
|
|
152
215
|
|
|
153
|
-
//
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (existsSync(statusFile)) {
|
|
158
|
-
try {
|
|
159
|
-
status = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
160
|
-
break;
|
|
161
|
-
} catch { /* file not fully written yet */ }
|
|
162
|
-
}
|
|
163
|
-
await Bun.sleep(200);
|
|
164
|
-
}
|
|
216
|
+
// Write status file with both PIDs
|
|
217
|
+
const status = { pid: child.pid, port, host, shareUrl, tunnelPid };
|
|
218
|
+
writeFileSync(statusFile, JSON.stringify(status));
|
|
219
|
+
writeFileSync(pidFile, String(child.pid));
|
|
165
220
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
console.log(` Enable auth in ~/.ppm/config.yaml or restart without --share.`);
|
|
174
|
-
}
|
|
175
|
-
const qr = await import("qrcode-terminal");
|
|
176
|
-
console.log();
|
|
177
|
-
qr.generate(status.shareUrl, { small: true });
|
|
221
|
+
console.log(`\n PPM v${VERSION} daemon started (PID: ${child.pid})\n`);
|
|
222
|
+
console.log(` ➜ Local: http://localhost:${port}/`);
|
|
223
|
+
if (shareUrl) {
|
|
224
|
+
console.log(` ➜ Share: ${shareUrl}`);
|
|
225
|
+
if (!configService.get("auth").enabled) {
|
|
226
|
+
console.log(`\n ⚠ Warning: auth is disabled — your IDE is publicly accessible!`);
|
|
227
|
+
console.log(` Enable auth in ~/.ppm/config.yaml or restart without --share.`);
|
|
178
228
|
}
|
|
179
|
-
|
|
180
|
-
console.log(
|
|
229
|
+
const qr = await import("qrcode-terminal");
|
|
230
|
+
console.log();
|
|
231
|
+
qr.generate(shareUrl, { small: true });
|
|
181
232
|
}
|
|
182
233
|
|
|
183
234
|
process.exit(0);
|
|
@@ -221,21 +272,27 @@ export async function startServer(options: {
|
|
|
221
272
|
idleTimeout: 960,
|
|
222
273
|
sendPong: true,
|
|
223
274
|
open(ws: any) {
|
|
224
|
-
if (ws.data?.type === "
|
|
275
|
+
if (ws.data?.type === "health") {
|
|
276
|
+
ws.send(JSON.stringify({ type: "health", status: "ok" }));
|
|
277
|
+
} else if (ws.data?.type === "chat") chatWebSocket.open(ws);
|
|
225
278
|
else terminalWebSocket.open(ws);
|
|
226
279
|
},
|
|
227
280
|
message(ws: any, msg: any) {
|
|
228
|
-
if (ws.data?.type === "
|
|
281
|
+
if (ws.data?.type === "health") {
|
|
282
|
+
// Respond to ping with pong
|
|
283
|
+
ws.send(JSON.stringify({ type: "health", status: "ok" }));
|
|
284
|
+
} else if (ws.data?.type === "chat") chatWebSocket.message(ws, msg);
|
|
229
285
|
else terminalWebSocket.message(ws, msg);
|
|
230
286
|
},
|
|
231
287
|
close(ws: any) {
|
|
288
|
+
if (ws.data?.type === "health") return;
|
|
232
289
|
if (ws.data?.type === "chat") chatWebSocket.close(ws);
|
|
233
290
|
else terminalWebSocket.close(ws);
|
|
234
291
|
},
|
|
235
292
|
} as Parameters<typeof Bun.serve>[0] extends { websocket?: infer W } ? W : never,
|
|
236
293
|
});
|
|
237
294
|
|
|
238
|
-
console.log(`\n PPM
|
|
295
|
+
console.log(`\n PPM v${VERSION} ready\n`);
|
|
239
296
|
console.log(` ➜ Local: http://localhost:${server.port}/`);
|
|
240
297
|
|
|
241
298
|
const { networkInterfaces } = await import("node:os");
|
|
@@ -281,16 +338,9 @@ if (process.argv.includes("__serve__")) {
|
|
|
281
338
|
const port = parseInt(process.argv[idx + 1] ?? "8080", 10);
|
|
282
339
|
const host = process.argv[idx + 2] ?? "0.0.0.0";
|
|
283
340
|
const configPath = process.argv[idx + 3] || undefined;
|
|
284
|
-
const shareFlag = process.argv[idx + 4] === "share";
|
|
285
341
|
|
|
286
342
|
configService.load(configPath);
|
|
287
|
-
|
|
288
|
-
const { resolve } = await import("node:path");
|
|
289
|
-
const { homedir } = await import("node:os");
|
|
290
|
-
const { writeFileSync, unlinkSync } = await import("node:fs");
|
|
291
|
-
|
|
292
|
-
const statusFile = resolve(homedir(), ".ppm", "status.json");
|
|
293
|
-
const pidFile = resolve(homedir(), ".ppm", "ppm.pid");
|
|
343
|
+
await setupLogFile();
|
|
294
344
|
|
|
295
345
|
Bun.serve({
|
|
296
346
|
port,
|
|
@@ -298,6 +348,12 @@ if (process.argv.includes("__serve__")) {
|
|
|
298
348
|
fetch(req, server) {
|
|
299
349
|
const url = new URL(req.url);
|
|
300
350
|
|
|
351
|
+
if (url.pathname === "/ws/health") {
|
|
352
|
+
const upgraded = server.upgrade(req, { data: { type: "health" } });
|
|
353
|
+
if (upgraded) return undefined;
|
|
354
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
355
|
+
}
|
|
356
|
+
|
|
301
357
|
if (url.pathname.startsWith("/ws/project/")) {
|
|
302
358
|
const parts = url.pathname.split("/");
|
|
303
359
|
const projectName = parts[3] ?? "";
|
|
@@ -342,27 +398,5 @@ if (process.argv.includes("__serve__")) {
|
|
|
342
398
|
} as Parameters<typeof Bun.serve>[0] extends { websocket?: infer W } ? W : never,
|
|
343
399
|
});
|
|
344
400
|
|
|
345
|
-
|
|
346
|
-
let shareUrl: string | undefined;
|
|
347
|
-
const tunnel = shareFlag
|
|
348
|
-
? (await import("../services/tunnel.service.ts")).tunnelService
|
|
349
|
-
: null;
|
|
350
|
-
if (tunnel) {
|
|
351
|
-
try {
|
|
352
|
-
shareUrl = await tunnel.startTunnel(port);
|
|
353
|
-
} catch { /* non-fatal: server runs without share URL */ }
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// Write status file for parent to read
|
|
357
|
-
writeFileSync(statusFile, JSON.stringify({ pid: process.pid, port, host, shareUrl }));
|
|
358
|
-
|
|
359
|
-
// Cleanup on exit
|
|
360
|
-
const cleanup = () => {
|
|
361
|
-
try { unlinkSync(statusFile); } catch {}
|
|
362
|
-
try { unlinkSync(pidFile); } catch {}
|
|
363
|
-
tunnel?.stopTunnel();
|
|
364
|
-
process.exit(0);
|
|
365
|
-
};
|
|
366
|
-
process.on("SIGINT", cleanup);
|
|
367
|
-
process.on("SIGTERM", cleanup);
|
|
401
|
+
console.log(`Server child ready on port ${port}`);
|
|
368
402
|
}
|
|
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
|
|
|
5
5
|
import { chatService } from "../../services/chat.service.ts";
|
|
6
6
|
import { providerRegistry } from "../../providers/registry.ts";
|
|
7
7
|
import { listSlashItems } from "../../services/slash-items.service.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { waitForFreshUsage } from "../../services/claude-usage.service.ts";
|
|
9
9
|
import { ok, err } from "../../types/api.ts";
|
|
10
10
|
|
|
11
11
|
type Env = { Variables: { projectPath: string; projectName: string } };
|
|
@@ -23,24 +23,19 @@ chatRoutes.get("/slash-items", (c) => {
|
|
|
23
23
|
}
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
/** GET /chat/usage —
|
|
26
|
+
/** GET /chat/usage — await fresh data from ccburn (async, non-blocking to event loop) */
|
|
27
27
|
chatRoutes.get("/usage", async (c) => {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
weeklySonnet: usage.weeklySonnet,
|
|
40
|
-
}));
|
|
41
|
-
} catch (e) {
|
|
42
|
-
return c.json(err((e as Error).message), 500);
|
|
43
|
-
}
|
|
28
|
+
const usage = await waitForFreshUsage();
|
|
29
|
+
return c.json(ok({
|
|
30
|
+
fiveHour: usage.session?.utilization,
|
|
31
|
+
sevenDay: usage.weekly?.utilization,
|
|
32
|
+
fiveHourResetsAt: usage.session?.resetsAt,
|
|
33
|
+
sevenDayResetsAt: usage.weekly?.resetsAt,
|
|
34
|
+
session: usage.session,
|
|
35
|
+
weekly: usage.weekly,
|
|
36
|
+
weeklyOpus: usage.weeklyOpus,
|
|
37
|
+
weeklySonnet: usage.weeklySonnet,
|
|
38
|
+
}));
|
|
44
39
|
});
|
|
45
40
|
|
|
46
41
|
/** GET /chat/providers — list available AI providers */
|
|
@@ -45,6 +45,18 @@ projectRoutes.get("/suggest-dirs", (c) => {
|
|
|
45
45
|
}
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
/** PATCH /api/projects/:name — update a project's name/path */
|
|
49
|
+
projectRoutes.patch("/:name", async (c) => {
|
|
50
|
+
try {
|
|
51
|
+
const name = c.req.param("name");
|
|
52
|
+
const body = await c.req.json<{ name?: string; path?: string }>();
|
|
53
|
+
const updated = projectService.update(name, body);
|
|
54
|
+
return c.json(ok(updated));
|
|
55
|
+
} catch (e) {
|
|
56
|
+
return c.json(err((e as Error).message), 400);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
48
60
|
/** DELETE /api/projects/:name — remove a project by name */
|
|
49
61
|
projectRoutes.delete("/:name", (c) => {
|
|
50
62
|
try {
|
|
@@ -17,10 +17,10 @@ staticRoutes.use(
|
|
|
17
17
|
);
|
|
18
18
|
|
|
19
19
|
/** SPA fallback — serve index.html for all unmatched routes */
|
|
20
|
-
staticRoutes.get("*", (c) => {
|
|
20
|
+
staticRoutes.get("*", async (c) => {
|
|
21
21
|
const indexPath = resolve(DIST_DIR, "index.html");
|
|
22
22
|
if (existsSync(indexPath)) {
|
|
23
|
-
return c.html(Bun.file(indexPath).text());
|
|
23
|
+
return c.html(await Bun.file(indexPath).text());
|
|
24
24
|
}
|
|
25
25
|
return c.text("Frontend not built. Run: bun run build:web", 404);
|
|
26
26
|
});
|
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
|
|
5
5
|
export interface LimitBucket {
|
|
6
6
|
utilization: number;
|
|
7
|
-
budgetPace: number;
|
|
8
7
|
resetsAt: string;
|
|
9
8
|
resetsInMinutes: number | null;
|
|
10
9
|
resetsInHours: number | null;
|
|
11
10
|
windowHours: number;
|
|
12
|
-
status: string;
|
|
13
11
|
}
|
|
14
12
|
|
|
15
13
|
export interface ClaudeUsage {
|
|
@@ -20,94 +18,115 @@ export interface ClaudeUsage {
|
|
|
20
18
|
weeklySonnet?: LimitBucket;
|
|
21
19
|
}
|
|
22
20
|
|
|
23
|
-
|
|
21
|
+
const API_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
22
|
+
const API_BETA = "oauth-2025-04-20";
|
|
23
|
+
const USER_AGENT = "claude-code/1.0";
|
|
24
|
+
const CACHE_TTL = 30_000; // 30s
|
|
25
|
+
const FETCH_TIMEOUT = 10_000; // 10s
|
|
26
|
+
|
|
27
|
+
/** Cached data + timestamp */
|
|
24
28
|
let cache: { data: ClaudeUsage; timestamp: number } | null = null;
|
|
25
|
-
const CACHE_TTL = 30_000; // 30 seconds
|
|
26
29
|
|
|
27
|
-
/** Cached
|
|
28
|
-
let
|
|
30
|
+
/** Cached OAuth token (read once from Keychain/file) */
|
|
31
|
+
let tokenCache: { token: string; timestamp: number } | null = null;
|
|
32
|
+
const TOKEN_TTL = 300_000; // re-read token every 5min
|
|
29
33
|
|
|
30
34
|
/**
|
|
31
|
-
*
|
|
32
|
-
* 1. node_modules/.bin/ccburn (relative to cwd — works for dev & prod)
|
|
33
|
-
* 2. Sibling to compiled binary (dist/node_modules/.bin/ccburn)
|
|
34
|
-
* 3. import.meta.dir based resolution
|
|
35
|
+
* Read OAuth access token from macOS Keychain, fallback to credentials file.
|
|
35
36
|
*/
|
|
36
|
-
function
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
37
|
+
function getAccessToken(): string {
|
|
38
|
+
if (tokenCache && Date.now() - tokenCache.timestamp < TOKEN_TTL) {
|
|
39
|
+
return tokenCache.token;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let creds: Record<string, any> | null = null;
|
|
43
|
+
|
|
44
|
+
// macOS Keychain
|
|
45
|
+
if (process.platform === "darwin") {
|
|
46
|
+
try {
|
|
47
|
+
const proc = Bun.spawnSync(["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"]);
|
|
48
|
+
if (proc.exitCode === 0) {
|
|
49
|
+
creds = JSON.parse(proc.stdout.toString().trim());
|
|
50
|
+
}
|
|
51
|
+
} catch { /* fallback to file */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Fallback: ~/.claude/.credentials.json
|
|
55
|
+
if (!creds) {
|
|
56
|
+
const credPath = resolve(homedir(), ".claude", ".credentials.json");
|
|
57
|
+
if (existsSync(credPath)) {
|
|
58
|
+
creds = JSON.parse(readFileSync(credPath, "utf-8"));
|
|
47
59
|
}
|
|
48
60
|
}
|
|
49
|
-
|
|
61
|
+
|
|
62
|
+
const token = creds?.claudeAiOauth?.accessToken;
|
|
63
|
+
if (!token) throw new Error("No Claude OAuth token found");
|
|
64
|
+
|
|
65
|
+
tokenCache = { token, timestamp: Date.now() };
|
|
66
|
+
return token;
|
|
50
67
|
}
|
|
51
68
|
|
|
52
69
|
/**
|
|
53
|
-
* Fetch
|
|
54
|
-
* ccburn handles credential retrieval (Keychain on macOS, etc.)
|
|
55
|
-
* and calls Anthropic's internal usage endpoint.
|
|
70
|
+
* Fetch usage from Anthropic OAuth API — native async, zero process spawn.
|
|
56
71
|
*/
|
|
57
|
-
|
|
72
|
+
async function fetchUsageFromApi(): Promise<ClaudeUsage> {
|
|
73
|
+
const token = getAccessToken();
|
|
74
|
+
const res = await fetch(API_URL, {
|
|
75
|
+
headers: {
|
|
76
|
+
Accept: "application/json",
|
|
77
|
+
Authorization: `Bearer ${token}`,
|
|
78
|
+
"anthropic-beta": API_BETA,
|
|
79
|
+
"User-Agent": USER_AGENT,
|
|
80
|
+
},
|
|
81
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
throw new Error(`Usage API returned ${res.status}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const raw = (await res.json()) as Record<string, any>;
|
|
89
|
+
const now = new Date().toISOString();
|
|
90
|
+
const data: ClaudeUsage = { timestamp: now };
|
|
91
|
+
|
|
92
|
+
if (raw.five_hour) data.session = parseApiBucket(raw.five_hour, 5);
|
|
93
|
+
if (raw.seven_day) data.weekly = parseApiBucket(raw.seven_day, 168);
|
|
94
|
+
if (raw.seven_day_opus) data.weeklyOpus = parseApiBucket(raw.seven_day_opus, 168);
|
|
95
|
+
if (raw.seven_day_sonnet) data.weeklySonnet = parseApiBucket(raw.seven_day_sonnet, 168);
|
|
96
|
+
|
|
97
|
+
return data;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Parse an API bucket (utilization is 0-100 from API, normalize to 0-1) */
|
|
101
|
+
function parseApiBucket(raw: Record<string, any>, windowHours: number): LimitBucket {
|
|
102
|
+
const utilization = (raw.utilization ?? 0) / 100;
|
|
103
|
+
const resetsAt = raw.resets_at ?? "";
|
|
104
|
+
const diff = resetsAt ? new Date(resetsAt).getTime() - Date.now() : 0;
|
|
105
|
+
const totalMins = diff > 0 ? Math.ceil(diff / 60_000) : 0;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
utilization,
|
|
109
|
+
resetsAt,
|
|
110
|
+
resetsInMinutes: windowHours <= 5 ? totalMins : null,
|
|
111
|
+
resetsInHours: windowHours > 5 ? Math.round((totalMins / 60) * 100) / 100 : null,
|
|
112
|
+
windowHours,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get cached usage or fetch fresh data.
|
|
118
|
+
* Fully async, never blocks event loop — just a native fetch().
|
|
119
|
+
*/
|
|
120
|
+
export async function waitForFreshUsage(): Promise<ClaudeUsage> {
|
|
58
121
|
if (cache && Date.now() - cache.timestamp < CACHE_TTL) {
|
|
59
122
|
return cache.data;
|
|
60
123
|
}
|
|
61
124
|
|
|
62
125
|
try {
|
|
63
|
-
const
|
|
64
|
-
const raw = execFileSync(bin, ["--json"], {
|
|
65
|
-
encoding: "utf-8",
|
|
66
|
-
timeout: 10_000,
|
|
67
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
68
|
-
}).trim();
|
|
69
|
-
|
|
70
|
-
// Extract JSON (skip any warnings before it)
|
|
71
|
-
const jsonStart = raw.indexOf("{");
|
|
72
|
-
const jsonStr = jsonStart > 0 ? raw.slice(jsonStart) : raw;
|
|
73
|
-
if (!jsonStr) return cache?.data ?? {};
|
|
74
|
-
|
|
75
|
-
const json = JSON.parse(jsonStr) as Record<string, unknown>;
|
|
76
|
-
const limits = json.limits as Record<string, unknown> | undefined;
|
|
77
|
-
if (!limits) return cache?.data ?? {};
|
|
78
|
-
|
|
79
|
-
const data: ClaudeUsage = {
|
|
80
|
-
timestamp: json.timestamp as string | undefined,
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
if (limits.session && typeof limits.session === "object") {
|
|
84
|
-
data.session = parseBucket(limits.session as Record<string, unknown>);
|
|
85
|
-
}
|
|
86
|
-
if (limits.weekly && typeof limits.weekly === "object") {
|
|
87
|
-
data.weekly = parseBucket(limits.weekly as Record<string, unknown>);
|
|
88
|
-
}
|
|
89
|
-
if (limits.weekly_opus && typeof limits.weekly_opus === "object") {
|
|
90
|
-
data.weeklyOpus = parseBucket(limits.weekly_opus as Record<string, unknown>);
|
|
91
|
-
}
|
|
92
|
-
if (limits.weekly_sonnet && typeof limits.weekly_sonnet === "object") {
|
|
93
|
-
data.weeklySonnet = parseBucket(limits.weekly_sonnet as Record<string, unknown>);
|
|
94
|
-
}
|
|
95
|
-
|
|
126
|
+
const data = await fetchUsageFromApi();
|
|
96
127
|
cache = { data, timestamp: Date.now() };
|
|
97
128
|
return data;
|
|
98
129
|
} catch {
|
|
99
130
|
return cache?.data ?? {};
|
|
100
131
|
}
|
|
101
132
|
}
|
|
102
|
-
|
|
103
|
-
function parseBucket(raw: Record<string, unknown>): LimitBucket {
|
|
104
|
-
return {
|
|
105
|
-
utilization: (raw.utilization as number) ?? 0,
|
|
106
|
-
budgetPace: (raw.budget_pace as number) ?? 0,
|
|
107
|
-
resetsAt: (raw.resets_at as string) ?? "",
|
|
108
|
-
resetsInMinutes: (raw.resets_in_minutes as number) ?? null,
|
|
109
|
-
resetsInHours: (raw.resets_in_hours as number) ?? null,
|
|
110
|
-
windowHours: (raw.window_hours as number) ?? 0,
|
|
111
|
-
status: (raw.status as string) ?? "",
|
|
112
|
-
};
|
|
113
|
-
}
|
|
@@ -40,6 +40,49 @@ class ProjectService {
|
|
|
40
40
|
return entry;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** Update a project's name and/or path */
|
|
44
|
+
update(
|
|
45
|
+
currentName: string,
|
|
46
|
+
updates: { name?: string; path?: string },
|
|
47
|
+
): ProjectConfig {
|
|
48
|
+
const projects = configService.get("projects");
|
|
49
|
+
const idx = projects.findIndex((p) => p.name === currentName);
|
|
50
|
+
if (idx === -1) {
|
|
51
|
+
throw new Error(`Project not found: ${currentName}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const current = projects[idx]!;
|
|
55
|
+
const newName = updates.name?.trim() || current.name;
|
|
56
|
+
const newPath = updates.path ? resolve(updates.path) : current.path;
|
|
57
|
+
|
|
58
|
+
// Validate new path exists
|
|
59
|
+
if (updates.path && !existsSync(newPath)) {
|
|
60
|
+
throw new Error(`Path does not exist: ${newPath}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Check name uniqueness (skip self)
|
|
64
|
+
if (
|
|
65
|
+
newName !== currentName &&
|
|
66
|
+
projects.some((p) => p.name === newName)
|
|
67
|
+
) {
|
|
68
|
+
throw new Error(`Project "${newName}" already exists`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Check path uniqueness (skip self)
|
|
72
|
+
if (
|
|
73
|
+
newPath !== current.path &&
|
|
74
|
+
projects.some((p, i) => i !== idx && resolve(p.path) === newPath)
|
|
75
|
+
) {
|
|
76
|
+
throw new Error(`Path "${newPath}" already registered`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const updated: ProjectConfig = { path: newPath, name: newName };
|
|
80
|
+
projects[idx] = updated;
|
|
81
|
+
configService.set("projects", projects);
|
|
82
|
+
configService.save();
|
|
83
|
+
return updated;
|
|
84
|
+
}
|
|
85
|
+
|
|
43
86
|
/** Remove a project by name or path */
|
|
44
87
|
remove(nameOrPath: string): void {
|
|
45
88
|
const projects = configService.get("projects");
|
package/src/types/chat.ts
CHANGED
|
@@ -41,12 +41,10 @@ export interface SessionInfo {
|
|
|
41
41
|
|
|
42
42
|
export interface LimitBucket {
|
|
43
43
|
utilization: number;
|
|
44
|
-
budgetPace: number;
|
|
45
44
|
resetsAt: string;
|
|
46
45
|
resetsInMinutes: number | null;
|
|
47
46
|
resetsInHours: number | null;
|
|
48
47
|
windowHours: number;
|
|
49
|
-
status: string;
|
|
50
48
|
}
|
|
51
49
|
|
|
52
50
|
export interface UsageInfo {
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
const pkg = JSON.parse(readFileSync(resolve(import.meta.dir, "../package.json"), "utf-8"));
|
|
5
|
+
|
|
6
|
+
/** App version from package.json — single source of truth */
|
|
7
|
+
export const VERSION: string = pkg.version;
|