@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/web/assets/api-client-B_eCZViO.js +1 -0
  3. package/dist/web/assets/chat-tab-FOn2nq1x.js +6 -0
  4. package/dist/web/assets/{code-editor-BgiyQO-M.js → code-editor-R0uEZQ-h.js} +1 -1
  5. package/dist/web/assets/{diff-viewer-8_asmBRZ.js → diff-viewer-DDQ2Z0sz.js} +1 -1
  6. package/dist/web/assets/{git-graph-BiyTIbCz.js → git-graph-ugBsFNaz.js} +1 -1
  7. package/dist/web/assets/{git-status-panel-BifyO31N.js → git-status-panel-UMKtdAxp.js} +1 -1
  8. package/dist/web/assets/index-CGDMk8DE.css +2 -0
  9. package/dist/web/assets/index-Dmu22zQo.js +12 -0
  10. package/dist/web/assets/project-list-D38uQSpC.js +1 -0
  11. package/dist/web/assets/{settings-tab-Cn5Ja0_J.js → settings-tab-BpyCSbii.js} +1 -1
  12. package/dist/web/index.html +3 -3
  13. package/dist/web/sw.js +1 -1
  14. package/package.json +4 -4
  15. package/src/cli/commands/report.ts +1 -1
  16. package/src/cli/commands/status.ts +31 -22
  17. package/src/cli/commands/stop.ts +37 -29
  18. package/src/index.ts +2 -1
  19. package/src/server/index.ts +100 -66
  20. package/src/server/routes/chat.ts +13 -18
  21. package/src/server/routes/projects.ts +12 -0
  22. package/src/server/routes/static.ts +2 -2
  23. package/src/services/claude-usage.service.ts +93 -74
  24. package/src/services/project.service.ts +43 -0
  25. package/src/types/chat.ts +0 -2
  26. package/src/version.ts +7 -0
  27. package/src/web/components/chat/chat-tab.tsx +24 -7
  28. package/src/web/components/chat/usage-badge.tsx +23 -23
  29. package/src/web/components/layout/mobile-drawer.tsx +19 -1
  30. package/src/web/components/layout/sidebar.tsx +15 -4
  31. package/src/web/components/projects/project-list.tsx +153 -4
  32. package/src/web/hooks/use-chat.ts +30 -41
  33. package/src/web/hooks/use-usage.ts +65 -0
  34. package/src/web/lib/api-client.ts +9 -0
  35. package/src/web/lib/report-bug.ts +33 -0
  36. package/dist/web/assets/api-client-BgVufYKf.js +0 -1
  37. package/dist/web/assets/chat-tab-C4ovA2w4.js +0 -6
  38. package/dist/web/assets/index-DILaVO6p.css +0 -2
  39. package/dist/web/assets/index-DasstYgw.js +0 -11
  40. package/dist/web/assets/project-list-C7L3hZct.js +0 -1
@@ -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: "0.2.1",
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 in parent (shows progress to user)
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 ?? "", options.share ? "share" : "",
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
- // Poll for status.json (child writes it when ready)
154
- const startTime = Date.now();
155
- let status: { pid: number; port: number; host: string; shareUrl?: string } | null = null;
156
- while (Date.now() - startTime < 30_000) {
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
- if (status) {
167
- console.log(`\n PPM daemon started (PID: ${status.pid})\n`);
168
- console.log(` ➜ Local: http://localhost:${status.port}/`);
169
- if (status.shareUrl) {
170
- console.log(` ➜ Share: ${status.shareUrl}`);
171
- if (!configService.get("auth").enabled) {
172
- console.log(`\n ⚠ Warning: auth is disabled your IDE is publicly accessible!`);
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
- } else {
180
- console.log(`\n PPM daemon started (PID: ${child.pid}) but status not confirmed.`);
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 === "chat") chatWebSocket.open(ws);
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 === "chat") chatWebSocket.message(ws, msg);
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 v0.2.1 ready\n`);
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
- // Start tunnel if --share was passed (eagerly import so cleanup doesn't race)
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 { fetchClaudeUsage } from "../../services/claude-usage.service.ts";
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 — get current usage/rate-limit info via ccburn */
26
+ /** GET /chat/usage — await fresh data from ccburn (async, non-blocking to event loop) */
27
27
  chatRoutes.get("/usage", async (c) => {
28
- try {
29
- const usage = await fetchClaudeUsage();
30
- return c.json(ok({
31
- fiveHour: usage.session?.utilization,
32
- sevenDay: usage.weekly?.utilization,
33
- fiveHourResetsAt: usage.session?.resetsAt,
34
- sevenDayResetsAt: usage.weekly?.resetsAt,
35
- // Extra detail for popup
36
- session: usage.session,
37
- weekly: usage.weekly,
38
- weeklyOpus: usage.weeklyOpus,
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 { execFileSync } from "node:child_process";
2
- import { existsSync } from "node:fs";
3
- import { resolve, dirname } from "node:path";
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
- /** Cache to avoid spawning ccburn too often */
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 resolved path */
28
- let ccburnBin: string | undefined;
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
- * Resolve ccburn binary. Checks:
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 getCcburnPath(): string {
37
- if (ccburnBin) return ccburnBin;
38
- const candidates = [
39
- resolve(process.cwd(), "node_modules/.bin/ccburn"),
40
- resolve(dirname(process.argv[1] ?? ""), "../node_modules/.bin/ccburn"),
41
- resolve(dirname(process.argv[1] ?? ""), "node_modules/.bin/ccburn"),
42
- ];
43
- for (const p of candidates) {
44
- if (existsSync(p)) {
45
- ccburnBin = p;
46
- return p;
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
- throw new Error("ccburn not found — run: bun install");
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 current usage/rate-limit info via ccburn (bundled dependency).
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
- export async function fetchClaudeUsage(): Promise<ClaudeUsage> {
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 bin = getCcburnPath();
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;