@hienlh/ppm 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 (159) hide show
  1. package/.claude/agent-memory/tester/MEMORY.md +3 -0
  2. package/.claude/agent-memory/tester/project-ppm-test-conventions.md +32 -0
  3. package/.env.example +1 -0
  4. package/.github/workflows/release.yml +46 -0
  5. package/README.md +349 -0
  6. package/bun.lock +1217 -0
  7. package/components.json +21 -0
  8. package/docs/code-standards.md +574 -0
  9. package/docs/codebase-summary.md +294 -0
  10. package/docs/deployment-guide.md +631 -0
  11. package/docs/design-guidelines.md +661 -0
  12. package/docs/project-overview-pdr.md +142 -0
  13. package/docs/project-roadmap.md +400 -0
  14. package/docs/system-architecture.md +459 -0
  15. package/package.json +68 -0
  16. package/plans/260314-2009-ppm-implementation/phase-01-project-skeleton.md +81 -0
  17. package/plans/260314-2009-ppm-implementation/phase-02-backend-core.md +148 -0
  18. package/plans/260314-2009-ppm-implementation/phase-03-frontend-shell.md +256 -0
  19. package/plans/260314-2009-ppm-implementation/phase-04-file-explorer-editor.md +120 -0
  20. package/plans/260314-2009-ppm-implementation/phase-05-web-terminal.md +174 -0
  21. package/plans/260314-2009-ppm-implementation/phase-06-git-integration.md +244 -0
  22. package/plans/260314-2009-ppm-implementation/phase-07-ai-chat.md +242 -0
  23. package/plans/260314-2009-ppm-implementation/phase-08-cli-commands.md +143 -0
  24. package/plans/260314-2009-ppm-implementation/phase-09-pwa-build-deploy.md +209 -0
  25. package/plans/260314-2009-ppm-implementation/phase-10-testing.md +311 -0
  26. package/plans/260314-2009-ppm-implementation/plan.md +202 -0
  27. package/plans/260315-0356-project-scoped-api-refactor/phase-01-backend-project-router.md +145 -0
  28. package/plans/260315-0356-project-scoped-api-refactor/phase-02-frontend-api-migration.md +107 -0
  29. package/plans/260315-0356-project-scoped-api-refactor/phase-03-per-project-tabs.md +100 -0
  30. package/plans/260315-0356-project-scoped-api-refactor/phase-04-websocket-migration.md +66 -0
  31. package/plans/260315-0356-project-scoped-api-refactor/plan.md +87 -0
  32. package/plans/reports/brainstorm-260314-1938-final-techstack.md +342 -0
  33. package/plans/reports/docs-manager-260315-1314-documentation-creation.md +386 -0
  34. package/plans/reports/fullstack-developer-260314-2252-phase-02-backend-core.md +57 -0
  35. package/plans/reports/fullstack-developer-260314-2253-phase-03-frontend-shell.md +70 -0
  36. package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-api-terminal-ws.md +49 -0
  37. package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-explorer-editor-terminal.md +52 -0
  38. package/plans/reports/fullstack-developer-260314-2307-ai-chat-phase7.md +58 -0
  39. package/plans/reports/fullstack-developer-260314-2307-phase-06-git-integration.md +33 -0
  40. package/plans/reports/research-260314-1911-ppm-tech-stack.md +318 -0
  41. package/plans/reports/research-260314-1930-claude-code-integration.md +293 -0
  42. package/plans/reports/researcher-260314-2232-node-pty-bun-crash-analysis.md +305 -0
  43. package/plans/reports/researcher-260314-2232-ui-style.md +942 -0
  44. package/plans/reports/researcher-260315-0300-opcode-claude-interaction.md +745 -0
  45. package/plans/reports/researcher-260315-0303-opcode-deep-analysis.md +742 -0
  46. package/plans/reports/researcher-260315-0305-claude-agent-sdk-github-research.md +423 -0
  47. package/plans/reports/tester-260314-2053-initial-test-suite.md +81 -0
  48. package/ppm.example.yaml +14 -0
  49. package/repomix-output.xml +23745 -0
  50. package/scripts/build.ts +13 -0
  51. package/src/cli/commands/chat-cmd.ts +259 -0
  52. package/src/cli/commands/config-cmd.ts +121 -0
  53. package/src/cli/commands/git-cmd.ts +315 -0
  54. package/src/cli/commands/init.ts +57 -0
  55. package/src/cli/commands/open.ts +19 -0
  56. package/src/cli/commands/projects.ts +100 -0
  57. package/src/cli/commands/start.ts +3 -0
  58. package/src/cli/commands/stop.ts +33 -0
  59. package/src/cli/utils/project-resolver.ts +27 -0
  60. package/src/index.ts +59 -0
  61. package/src/providers/claude-agent-sdk.ts +499 -0
  62. package/src/providers/claude-binary-finder.ts +256 -0
  63. package/src/providers/claude-code-cli.ts +413 -0
  64. package/src/providers/claude-process-registry.ts +106 -0
  65. package/src/providers/mock-provider.ts +171 -0
  66. package/src/providers/provider.interface.ts +10 -0
  67. package/src/providers/registry.ts +45 -0
  68. package/src/server/helpers/resolve-project.ts +22 -0
  69. package/src/server/index.ts +181 -0
  70. package/src/server/middleware/auth.ts +30 -0
  71. package/src/server/routes/chat.ts +153 -0
  72. package/src/server/routes/files.ts +168 -0
  73. package/src/server/routes/git.ts +261 -0
  74. package/src/server/routes/project-scoped.ts +27 -0
  75. package/src/server/routes/projects.ts +57 -0
  76. package/src/server/routes/static.ts +26 -0
  77. package/src/server/ws/chat.ts +130 -0
  78. package/src/server/ws/terminal.ts +89 -0
  79. package/src/services/chat.service.ts +110 -0
  80. package/src/services/claude-usage.service.ts +113 -0
  81. package/src/services/config.service.ts +90 -0
  82. package/src/services/file.service.ts +261 -0
  83. package/src/services/git-dirs.service.ts +112 -0
  84. package/src/services/git.service.ts +372 -0
  85. package/src/services/project.service.ts +107 -0
  86. package/src/services/slash-items.service.ts +184 -0
  87. package/src/services/terminal.service.ts +212 -0
  88. package/src/types/api.ts +37 -0
  89. package/src/types/chat.ts +92 -0
  90. package/src/types/config.ts +41 -0
  91. package/src/types/git.ts +50 -0
  92. package/src/types/project.ts +18 -0
  93. package/src/types/terminal.ts +20 -0
  94. package/src/web/app.tsx +168 -0
  95. package/src/web/components/auth/login-screen.tsx +88 -0
  96. package/src/web/components/chat/attachment-chips.tsx +55 -0
  97. package/src/web/components/chat/chat-placeholder.tsx +10 -0
  98. package/src/web/components/chat/chat-tab.tsx +301 -0
  99. package/src/web/components/chat/file-picker.tsx +126 -0
  100. package/src/web/components/chat/message-input.tsx +420 -0
  101. package/src/web/components/chat/message-list.tsx +838 -0
  102. package/src/web/components/chat/session-picker.tsx +139 -0
  103. package/src/web/components/chat/slash-command-picker.tsx +135 -0
  104. package/src/web/components/chat/usage-badge.tsx +186 -0
  105. package/src/web/components/editor/code-editor.tsx +329 -0
  106. package/src/web/components/editor/diff-viewer.tsx +276 -0
  107. package/src/web/components/editor/editor-placeholder.tsx +10 -0
  108. package/src/web/components/explorer/file-actions.tsx +191 -0
  109. package/src/web/components/explorer/file-tree.tsx +298 -0
  110. package/src/web/components/git/git-graph.tsx +727 -0
  111. package/src/web/components/git/git-placeholder.tsx +55 -0
  112. package/src/web/components/git/git-status-panel.tsx +850 -0
  113. package/src/web/components/layout/mobile-drawer.tsx +137 -0
  114. package/src/web/components/layout/mobile-nav.tsx +103 -0
  115. package/src/web/components/layout/sidebar.tsx +90 -0
  116. package/src/web/components/layout/tab-bar.tsx +152 -0
  117. package/src/web/components/layout/tab-content.tsx +85 -0
  118. package/src/web/components/projects/dir-suggest.tsx +152 -0
  119. package/src/web/components/projects/project-list.tsx +187 -0
  120. package/src/web/components/settings/settings-tab.tsx +57 -0
  121. package/src/web/components/terminal/terminal-placeholder.tsx +10 -0
  122. package/src/web/components/terminal/terminal-tab.tsx +133 -0
  123. package/src/web/components/ui/button.tsx +64 -0
  124. package/src/web/components/ui/context-menu.tsx +250 -0
  125. package/src/web/components/ui/dialog.tsx +156 -0
  126. package/src/web/components/ui/dropdown-menu.tsx +257 -0
  127. package/src/web/components/ui/input.tsx +21 -0
  128. package/src/web/components/ui/scroll-area.tsx +56 -0
  129. package/src/web/components/ui/separator.tsx +26 -0
  130. package/src/web/components/ui/sonner.tsx +40 -0
  131. package/src/web/components/ui/tabs.tsx +91 -0
  132. package/src/web/components/ui/tooltip.tsx +57 -0
  133. package/src/web/hooks/use-chat.ts +420 -0
  134. package/src/web/hooks/use-terminal.ts +182 -0
  135. package/src/web/hooks/use-url-sync.ts +66 -0
  136. package/src/web/hooks/use-websocket.ts +48 -0
  137. package/src/web/index.html +16 -0
  138. package/src/web/lib/api-client.ts +90 -0
  139. package/src/web/lib/file-support.ts +68 -0
  140. package/src/web/lib/utils.ts +6 -0
  141. package/src/web/lib/ws-client.ts +100 -0
  142. package/src/web/main.tsx +10 -0
  143. package/src/web/public/icon-192.svg +5 -0
  144. package/src/web/public/icon-512.svg +5 -0
  145. package/src/web/stores/file-store.ts +81 -0
  146. package/src/web/stores/project-store.ts +50 -0
  147. package/src/web/stores/settings-store.ts +65 -0
  148. package/src/web/stores/tab-store.ts +187 -0
  149. package/src/web/styles/globals.css +227 -0
  150. package/src/web/vite-env.d.ts +1 -0
  151. package/tests/integration/api/chat-routes.test.ts +95 -0
  152. package/tests/integration/claude-agent-sdk-integration.test.ts +228 -0
  153. package/tests/integration/ws/chat-websocket.test.ts +312 -0
  154. package/tests/test-setup.ts +5 -0
  155. package/tests/unit/providers/claude-agent-sdk.test.ts +339 -0
  156. package/tests/unit/providers/mock-provider.test.ts +143 -0
  157. package/tests/unit/services/chat-service.test.ts +100 -0
  158. package/tsconfig.json +32 -0
  159. package/vite.config.ts +62 -0
@@ -0,0 +1,130 @@
1
+ import { chatService } from "../../services/chat.service.ts";
2
+ import { providerRegistry } from "../../providers/registry.ts";
3
+ import { resolveProjectPath } from "../helpers/resolve-project.ts";
4
+ import type { ChatWsClientMessage } from "../../types/api.ts";
5
+
6
+ /** Tracks active chat WS connections: sessionId -> ws + abort controller + project context */
7
+ const activeSessions = new Map<
8
+ string,
9
+ { providerId: string; ws: ChatWsSocket; abort?: AbortController; projectPath?: string }
10
+ >();
11
+
12
+ type ChatWsSocket = {
13
+ data: { type: string; sessionId: string; projectName?: string };
14
+ send: (data: string) => void;
15
+ };
16
+
17
+ /**
18
+ * Chat WebSocket handler for Bun.serve().
19
+ * Protocol: JSON messages as defined in ChatWsClientMessage / ChatWsServerMessage.
20
+ */
21
+ export const chatWebSocket = {
22
+ open(ws: ChatWsSocket) {
23
+ const { sessionId, projectName } = ws.data;
24
+ // Look up session's actual provider, default to "claude-sdk"
25
+ const session = chatService.getSession(sessionId);
26
+ const providerId = session?.providerId ?? "claude-sdk";
27
+
28
+ // Resolve projectPath for skills/settings support
29
+ let projectPath: string | undefined;
30
+ if (projectName) {
31
+ try { projectPath = resolveProjectPath(projectName); } catch { /* ignore */ }
32
+ }
33
+
34
+ // Backfill projectPath on existing session
35
+ if (session && !session.projectPath && projectPath) {
36
+ session.projectPath = projectPath;
37
+ }
38
+
39
+ activeSessions.set(sessionId, { providerId, ws, projectPath });
40
+ ws.send(JSON.stringify({ type: "connected", sessionId }));
41
+ },
42
+
43
+ async message(ws: ChatWsSocket, msg: string | ArrayBuffer | Uint8Array) {
44
+ const { sessionId } = ws.data;
45
+ const text =
46
+ typeof msg === "string" ? msg : new TextDecoder().decode(msg as ArrayBuffer);
47
+
48
+ let parsed: ChatWsClientMessage;
49
+ try {
50
+ parsed = JSON.parse(text) as ChatWsClientMessage;
51
+ } catch {
52
+ ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
53
+ return;
54
+ }
55
+
56
+ const entry = activeSessions.get(sessionId);
57
+ const providerId = entry?.providerId ?? "mock";
58
+
59
+ if (parsed.type === "message") {
60
+ // Ensure provider session has projectPath for skills/settings support
61
+ if (entry?.projectPath) {
62
+ const provider = providerRegistry.get(providerId);
63
+ if (provider && "ensureProjectPath" in provider) {
64
+ (provider as any).ensureProjectPath(sessionId, entry.projectPath);
65
+ }
66
+ }
67
+
68
+ const abortController = new AbortController();
69
+ const entryRef = activeSessions.get(sessionId);
70
+ if (entryRef) entryRef.abort = abortController;
71
+
72
+ try {
73
+ for await (const event of chatService.sendMessage(
74
+ providerId,
75
+ sessionId,
76
+ parsed.content,
77
+ )) {
78
+ if (abortController.signal.aborted) break;
79
+ ws.send(JSON.stringify(event));
80
+ }
81
+ } catch (e) {
82
+ if (!abortController.signal.aborted) {
83
+ ws.send(
84
+ JSON.stringify({
85
+ type: "error",
86
+ message: (e as Error).message,
87
+ }),
88
+ );
89
+ }
90
+ } finally {
91
+ if (entryRef) entryRef.abort = undefined;
92
+ }
93
+ } else if (parsed.type === "cancel") {
94
+ // Only abort the underlying SDK query — don't break the for-await loop.
95
+ // This lets Claude send its final message before the iterator ends naturally.
96
+ const provider = providerRegistry.get(providerId);
97
+ if (provider && "abortQuery" in provider && typeof (provider as any).abortQuery === "function") {
98
+ (provider as any).abortQuery(sessionId);
99
+ }
100
+ } else if (parsed.type === "approval_response") {
101
+ // Route approval response to the provider
102
+ const provider = providerRegistry.get(providerId);
103
+ if (provider && typeof provider.resolveApproval === "function") {
104
+ provider.resolveApproval(
105
+ parsed.requestId,
106
+ parsed.approved,
107
+ (parsed as any).data,
108
+ );
109
+ }
110
+ }
111
+ },
112
+
113
+ close(ws: ChatWsSocket) {
114
+ const { sessionId } = ws.data;
115
+ const entry = activeSessions.get(sessionId);
116
+ if (entry) {
117
+ // Force-break the for-await loop — no client to receive events anymore
118
+ if (entry.abort) {
119
+ entry.abort.abort();
120
+ entry.abort = undefined;
121
+ }
122
+ // Also abort the underlying SDK query so Claude stops working
123
+ const provider = providerRegistry.get(entry.providerId);
124
+ if (provider && "abortQuery" in provider && typeof (provider as any).abortQuery === "function") {
125
+ (provider as any).abortQuery(sessionId);
126
+ }
127
+ }
128
+ activeSessions.delete(sessionId);
129
+ },
130
+ };
@@ -0,0 +1,89 @@
1
+ import { terminalService } from "../../services/terminal.service.ts";
2
+ import { resolveProjectPath } from "../helpers/resolve-project.ts";
3
+
4
+ /** Control message prefix for resize commands */
5
+ const RESIZE_PREFIX = "\x01RESIZE:";
6
+
7
+ /**
8
+ * WebSocket handler configuration for Bun.serve().
9
+ * Handles terminal session attach, input, resize, and disconnect.
10
+ */
11
+ export const terminalWebSocket = {
12
+ open(ws: { data: { type: string; id: string; projectName?: string }; send: (data: string) => void }) {
13
+ const { id, projectName } = ws.data;
14
+
15
+ let session = id !== "new" ? terminalService.get(id) : undefined;
16
+
17
+ // If session doesn't exist and projectName is provided, create one
18
+ if (!session && projectName) {
19
+ try {
20
+ const projectPath = resolveProjectPath(projectName);
21
+ // Create session with the requested ID — but TerminalService generates its own ID.
22
+ // Instead, create and return the new session ID to client.
23
+ const newId = terminalService.create(projectPath);
24
+ session = terminalService.get(newId);
25
+ if (session) {
26
+ // Update ws.data to reflect actual session ID
27
+ ws.data.id = newId;
28
+ }
29
+ } catch (e) {
30
+ ws.send(JSON.stringify({ type: "error", message: (e as Error).message }));
31
+ return;
32
+ }
33
+ }
34
+
35
+ if (!session) {
36
+ ws.send(JSON.stringify({ type: "error", message: "Session not found" }));
37
+ return;
38
+ }
39
+
40
+ const sessionId = ws.data.id;
41
+
42
+ // Mark connected
43
+ terminalService.setConnected(sessionId, ws);
44
+
45
+ // Send session info
46
+ ws.send(JSON.stringify({ type: "session", id: sessionId }));
47
+
48
+ // Send buffered output for reconnect
49
+ const buffer = terminalService.getBuffer(sessionId);
50
+ if (buffer) {
51
+ ws.send(buffer);
52
+ }
53
+
54
+ // Wire output listener
55
+ terminalService.onOutput(sessionId, (_id, data) => {
56
+ try {
57
+ ws.send(data);
58
+ } catch {
59
+ // WS closed
60
+ }
61
+ });
62
+ },
63
+
64
+ message(
65
+ ws: { data: { type: string; id: string } },
66
+ msg: string | ArrayBuffer | Uint8Array,
67
+ ) {
68
+ const sessionId = ws.data.id;
69
+ const text = typeof msg === "string" ? msg : new TextDecoder().decode(msg as ArrayBuffer);
70
+
71
+ // Check for resize control message
72
+ if (text.startsWith(RESIZE_PREFIX)) {
73
+ const parts = text.slice(RESIZE_PREFIX.length).split(",");
74
+ const cols = parseInt(parts[0] ?? "80", 10);
75
+ const rows = parseInt(parts[1] ?? "24", 10);
76
+ terminalService.resize(sessionId, cols, rows);
77
+ return;
78
+ }
79
+
80
+ // Regular input — write to PTY
81
+ terminalService.write(sessionId, text);
82
+ },
83
+
84
+ close(ws: { data: { type: string; id: string } }) {
85
+ const sessionId = ws.data.id;
86
+ terminalService.removeOutputListener(sessionId);
87
+ terminalService.setDisconnected(sessionId);
88
+ },
89
+ };
@@ -0,0 +1,110 @@
1
+ import { providerRegistry } from "../providers/registry.ts";
2
+ import type {
3
+ Session,
4
+ SessionConfig,
5
+ SessionInfo,
6
+ ChatEvent,
7
+ ChatMessage,
8
+ } from "../providers/provider.interface.ts";
9
+ import { MockProvider } from "../providers/mock-provider.ts";
10
+
11
+ class ChatService {
12
+ async createSession(
13
+ providerId?: string,
14
+ config: SessionConfig = {},
15
+ ): Promise<Session> {
16
+ const provider = providerId
17
+ ? providerRegistry.get(providerId)
18
+ : providerRegistry.getDefault();
19
+ if (!provider) throw new Error(`Provider "${providerId}" not found`);
20
+ return provider.createSession(config);
21
+ }
22
+
23
+ async resumeSession(
24
+ providerId: string,
25
+ sessionId: string,
26
+ ): Promise<Session> {
27
+ const provider = providerRegistry.get(providerId);
28
+ if (!provider) throw new Error(`Provider "${providerId}" not found`);
29
+ return provider.resumeSession(sessionId);
30
+ }
31
+
32
+ async listSessions(providerId?: string, dir?: string): Promise<SessionInfo[]> {
33
+ if (providerId) {
34
+ const provider = providerRegistry.get(providerId);
35
+ if (!provider) throw new Error(`Provider "${providerId}" not found`);
36
+ // Pass dir to providers that support it (SDK provider)
37
+ if (dir && "listSessionsByDir" in provider) {
38
+ return (provider as any).listSessionsByDir(dir);
39
+ }
40
+ return provider.listSessions();
41
+ }
42
+ // Aggregate from all providers
43
+ const all: SessionInfo[] = [];
44
+ for (const info of providerRegistry.list()) {
45
+ const provider = providerRegistry.get(info.id);
46
+ if (provider) {
47
+ if (dir && "listSessionsByDir" in provider) {
48
+ all.push(...await (provider as any).listSessionsByDir(dir));
49
+ } else {
50
+ all.push(...await provider.listSessions());
51
+ }
52
+ }
53
+ }
54
+ return all.sort(
55
+ (a, b) =>
56
+ new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
57
+ );
58
+ }
59
+
60
+ async deleteSession(
61
+ providerId: string,
62
+ sessionId: string,
63
+ ): Promise<void> {
64
+ const provider = providerRegistry.get(providerId);
65
+ if (!provider) throw new Error(`Provider "${providerId}" not found`);
66
+ return provider.deleteSession(sessionId);
67
+ }
68
+
69
+ async *sendMessage(
70
+ providerId: string,
71
+ sessionId: string,
72
+ message: string,
73
+ ): AsyncIterable<ChatEvent> {
74
+ const provider = providerRegistry.get(providerId);
75
+ if (!provider) {
76
+ yield { type: "error", message: `Provider "${providerId}" not found` };
77
+ return;
78
+ }
79
+ yield* provider.sendMessage(sessionId, message);
80
+ }
81
+
82
+ /** Look up a session across all providers (for WS handler) */
83
+ getSession(sessionId: string): Session | null {
84
+ for (const info of providerRegistry.list()) {
85
+ const provider = providerRegistry.get(info.id);
86
+ if (!provider) continue;
87
+ const sessions = (provider as any).sessions as Map<string, unknown> | undefined;
88
+ if (sessions?.has(sessionId)) {
89
+ const entry = sessions.get(sessionId);
90
+ // SDK provider stores {meta, sdk}, others store Session directly
91
+ if (entry && typeof entry === "object" && "meta" in entry) {
92
+ return (entry as { meta: Session }).meta;
93
+ }
94
+ return entry as Session ?? null;
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+
100
+ async getMessages(providerId: string, sessionId: string): Promise<ChatMessage[]> {
101
+ const provider = providerRegistry.get(providerId);
102
+ if (!provider) return [];
103
+ if ("getMessages" in provider && typeof (provider as any).getMessages === "function") {
104
+ return await (provider as any).getMessages(sessionId);
105
+ }
106
+ return [];
107
+ }
108
+ }
109
+
110
+ export const chatService = new ChatService();
@@ -0,0 +1,113 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { resolve, dirname } from "node:path";
4
+
5
+ export interface LimitBucket {
6
+ utilization: number;
7
+ budgetPace: number;
8
+ resetsAt: string;
9
+ resetsInMinutes: number | null;
10
+ resetsInHours: number | null;
11
+ windowHours: number;
12
+ status: string;
13
+ }
14
+
15
+ export interface ClaudeUsage {
16
+ timestamp?: string;
17
+ session?: LimitBucket;
18
+ weekly?: LimitBucket;
19
+ weeklyOpus?: LimitBucket;
20
+ weeklySonnet?: LimitBucket;
21
+ }
22
+
23
+ /** Cache to avoid spawning ccburn too often */
24
+ let cache: { data: ClaudeUsage; timestamp: number } | null = null;
25
+ const CACHE_TTL = 30_000; // 30 seconds
26
+
27
+ /** Cached resolved path */
28
+ let ccburnBin: string | undefined;
29
+
30
+ /**
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
+ */
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;
47
+ }
48
+ }
49
+ throw new Error("ccburn not found — run: bun install");
50
+ }
51
+
52
+ /**
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.
56
+ */
57
+ export async function fetchClaudeUsage(): Promise<ClaudeUsage> {
58
+ if (cache && Date.now() - cache.timestamp < CACHE_TTL) {
59
+ return cache.data;
60
+ }
61
+
62
+ 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
+
96
+ cache = { data, timestamp: Date.now() };
97
+ return data;
98
+ } catch {
99
+ return cache?.data ?? {};
100
+ }
101
+ }
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
+ }
@@ -0,0 +1,90 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
2
+ import { resolve, dirname } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { randomBytes } from "node:crypto";
5
+ import yaml from "js-yaml";
6
+ import type { PpmConfig } from "../types/config.ts";
7
+ import { DEFAULT_CONFIG } from "../types/config.ts";
8
+
9
+ const PPM_DIR = resolve(homedir(), ".ppm");
10
+ const GLOBAL_CONFIG_PATH = resolve(PPM_DIR, "config.yaml");
11
+ const LOCAL_CONFIG_PATH = resolve(process.cwd(), "ppm.yaml");
12
+
13
+ class ConfigService {
14
+ private config: PpmConfig = structuredClone(DEFAULT_CONFIG);
15
+ private configPath: string = GLOBAL_CONFIG_PATH;
16
+
17
+ /** Load config from: explicit path → ./ppm.yaml → ~/.ppm/config.yaml */
18
+ load(explicitPath?: string): PpmConfig {
19
+ const searchPaths = [
20
+ explicitPath,
21
+ LOCAL_CONFIG_PATH,
22
+ GLOBAL_CONFIG_PATH,
23
+ ].filter(Boolean) as string[];
24
+
25
+ for (const p of searchPaths) {
26
+ if (existsSync(p)) {
27
+ const raw = readFileSync(p, "utf-8");
28
+ const parsed = yaml.load(raw) as Partial<PpmConfig> | null;
29
+ if (parsed) {
30
+ this.config = { ...structuredClone(DEFAULT_CONFIG), ...parsed };
31
+ this.configPath = p;
32
+ // Auto-generate token if auth enabled but token is empty
33
+ if (this.config.auth.enabled && !this.config.auth.token) {
34
+ this.config.auth.token = randomBytes(16).toString("hex");
35
+ this.save();
36
+ }
37
+ return this.config;
38
+ }
39
+ }
40
+ }
41
+
42
+ // No config found — create default
43
+ this.config = this.createDefault();
44
+ return this.config;
45
+ }
46
+
47
+ /** Save current config to disk */
48
+ save(): void {
49
+ const dir = dirname(this.configPath);
50
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
51
+ writeFileSync(this.configPath, yaml.dump(this.config), "utf-8");
52
+ }
53
+
54
+ /** Get a top-level config key */
55
+ get<K extends keyof PpmConfig>(key: K): PpmConfig[K] {
56
+ return this.config[key];
57
+ }
58
+
59
+ /** Set a top-level config key */
60
+ set<K extends keyof PpmConfig>(key: K, value: PpmConfig[K]): void {
61
+ this.config[key] = value;
62
+ }
63
+
64
+ /** Get the full config object */
65
+ getAll(): PpmConfig {
66
+ return this.config;
67
+ }
68
+
69
+ /** Get the path config was loaded from */
70
+ getConfigPath(): string {
71
+ return this.configPath;
72
+ }
73
+
74
+ /** Set the config path explicitly (for init command) */
75
+ setConfigPath(p: string): void {
76
+ this.configPath = p;
77
+ }
78
+
79
+ /** Create default config with auto-generated auth token */
80
+ private createDefault(): PpmConfig {
81
+ const config = structuredClone(DEFAULT_CONFIG);
82
+ config.auth.token = randomBytes(16).toString("hex");
83
+ this.configPath = GLOBAL_CONFIG_PATH;
84
+ this.save();
85
+ return config;
86
+ }
87
+ }
88
+
89
+ /** Singleton config service */
90
+ export const configService = new ConfigService();