letmecook 0.0.21 → 0.0.23

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.
@@ -0,0 +1,366 @@
1
+ import { parseRepoSpec, type RepoSpec } from "./types";
2
+ import {
3
+ listSessions,
4
+ getSession,
5
+ updateLastAccessed,
6
+ deleteAllSessions,
7
+ deleteSession,
8
+ } from "./sessions";
9
+ import { createRenderer, destroyRenderer } from "./ui/renderer";
10
+ import { showNewSessionPrompt } from "./ui/new-session";
11
+ import { showSessionList } from "./ui/list";
12
+ import { showNukeConfirm } from "./ui/confirm-nuke";
13
+ import { createNewSession, resumeSession } from "./flows";
14
+ import { ChatLogger } from "./chat-logger";
15
+
16
+ export function printCLIUsage(): void {
17
+ console.log(`
18
+ letmecook CLI mode
19
+
20
+ Usage:
21
+ letmecook --cli <owner/repo> [owner/repo:branch...] Create or resume a session
22
+ letmecook --cli --list List all sessions
23
+ letmecook --cli --resume <session-name> Resume a session
24
+ letmecook --cli --delete <session-name> Delete a session
25
+ letmecook --cli --nuke [--yes] Nuke everything
26
+ letmecook --cli --logs List chat logs
27
+ letmecook --cli --logs --view <log-id> View chat log
28
+ letmecook --cli --logs --delete <log-id> Delete chat log
29
+ letmecook --cli --logs --nuke [--yes] Delete all chat logs
30
+
31
+ Examples:
32
+ letmecook --cli microsoft/playwright
33
+ letmecook --cli facebook/react openai/agents
34
+ letmecook --cli --resume playwright-agent-tests
35
+ letmecook --cli --logs
36
+ letmecook --cli --logs --view 1700000000000-abc123
37
+ `);
38
+ }
39
+
40
+ export async function handleNewSessionCLI(repos: RepoSpec[]): Promise<void> {
41
+ const renderer = await createRenderer();
42
+
43
+ try {
44
+ const { goal, cancelled } = await showNewSessionPrompt(renderer, repos);
45
+
46
+ if (cancelled) {
47
+ destroyRenderer();
48
+ console.log("\nCancelled.");
49
+ return;
50
+ }
51
+
52
+ const result = await createNewSession(renderer, {
53
+ repos,
54
+ goal,
55
+ mode: "cli",
56
+ });
57
+
58
+ if (!result) {
59
+ destroyRenderer();
60
+ console.log("\nCancelled.");
61
+ return;
62
+ }
63
+
64
+ const { session, skipped } = result;
65
+
66
+ if (skipped) {
67
+ destroyRenderer();
68
+ console.log(`\nResuming existing session: ${session.name}\n`);
69
+ await resumeSession(renderer, {
70
+ session,
71
+ mode: "cli",
72
+ initialRefresh: true,
73
+ });
74
+ return;
75
+ }
76
+
77
+ destroyRenderer();
78
+ console.log(`\nSession created: ${session.name}`);
79
+ console.log(`Path: ${session.path}\n`);
80
+
81
+ await resumeSession(renderer, {
82
+ session,
83
+ mode: "cli",
84
+ initialRefresh: false,
85
+ });
86
+ } catch (error) {
87
+ destroyRenderer();
88
+ console.error("\nError:", error instanceof Error ? error.message : error);
89
+ process.exit(1);
90
+ }
91
+ }
92
+
93
+ export async function handleList(): Promise<void> {
94
+ const renderer = await createRenderer();
95
+
96
+ try {
97
+ while (true) {
98
+ const sessions = await listSessions();
99
+ const action = await showSessionList(renderer, sessions);
100
+
101
+ switch (action.type) {
102
+ case "resume":
103
+ destroyRenderer();
104
+ await updateLastAccessed(action.session.name);
105
+ console.log(`\nResuming session: ${action.session.name}\n`);
106
+ await resumeSession(renderer, {
107
+ session: action.session,
108
+ mode: "cli",
109
+ initialRefresh: true,
110
+ });
111
+ return;
112
+
113
+ case "delete":
114
+ console.log("[TODO] Delete session flow");
115
+ break;
116
+
117
+ case "nuke": {
118
+ const choice = await showNukeConfirm(renderer, sessions.length);
119
+ if (choice === "confirm") {
120
+ const count = await deleteAllSessions();
121
+ destroyRenderer();
122
+ console.log(`\nNuked ${count} session(s) and all data.`);
123
+ return;
124
+ }
125
+ break;
126
+ }
127
+
128
+ case "quit":
129
+ destroyRenderer();
130
+ return;
131
+ }
132
+ }
133
+ } catch (error) {
134
+ destroyRenderer();
135
+ console.error("\nError:", error instanceof Error ? error.message : error);
136
+ process.exit(1);
137
+ }
138
+ }
139
+
140
+ export async function handleResume(sessionName: string): Promise<void> {
141
+ const session = await getSession(sessionName);
142
+
143
+ if (!session) {
144
+ console.error(`Session not found: ${sessionName}`);
145
+ console.log("\nAvailable sessions:");
146
+ const sessions = await listSessions();
147
+ if (sessions.length === 0) {
148
+ console.log(" (none)");
149
+ } else {
150
+ sessions.forEach((s) => console.log(` - ${s.name}`));
151
+ }
152
+ process.exit(1);
153
+ }
154
+
155
+ await updateLastAccessed(session.name);
156
+ console.log(`\nResuming session: ${session.name}\n`);
157
+
158
+ const renderer = await createRenderer();
159
+ await resumeSession(renderer, {
160
+ session,
161
+ mode: "cli",
162
+ initialRefresh: true,
163
+ });
164
+ }
165
+
166
+ export async function handleDelete(sessionName: string): Promise<void> {
167
+ const session = await getSession(sessionName);
168
+
169
+ if (!session) {
170
+ console.error(`Session not found: ${sessionName}`);
171
+ const sessions = await listSessions();
172
+ if (sessions.length > 0) {
173
+ console.log("\nAvailable sessions:");
174
+ sessions.forEach((s) => console.log(` - ${s.name}`));
175
+ }
176
+ process.exit(1);
177
+ }
178
+
179
+ const deleted = await deleteSession(sessionName);
180
+ if (deleted) {
181
+ console.log(`Deleted session: ${sessionName}`);
182
+ } else {
183
+ console.error(`Failed to delete session: ${sessionName}`);
184
+ process.exit(1);
185
+ }
186
+ }
187
+
188
+ export async function handleNuke(skipConfirm = false): Promise<void> {
189
+ const sessions = await listSessions();
190
+ if (sessions.length === 0) {
191
+ console.log("Nothing to nuke.");
192
+ return;
193
+ }
194
+
195
+ // Skip confirmation if --yes flag or non-interactive (piped input)
196
+ if (skipConfirm || !process.stdin.isTTY) {
197
+ const count = await deleteAllSessions();
198
+ console.log(`Nuked ${count} session(s) and all data.`);
199
+ return;
200
+ }
201
+
202
+ const renderer = await createRenderer();
203
+ const choice = await showNukeConfirm(renderer, sessions.length);
204
+ destroyRenderer();
205
+
206
+ if (choice === "confirm") {
207
+ const count = await deleteAllSessions();
208
+ console.log(`Nuked ${count} session(s) and all data.`);
209
+ } else {
210
+ console.log("Cancelled.");
211
+ }
212
+ }
213
+
214
+ export async function handleChatLogsList(): Promise<void> {
215
+ const logs = await ChatLogger.listLogs();
216
+ if (logs.length === 0) {
217
+ console.log("No chat logs found.");
218
+ return;
219
+ }
220
+
221
+ console.log("Chat Logs:");
222
+ for (const log of logs) {
223
+ console.log(` ID: ${log.id}`);
224
+ console.log(` Created: ${log.createdAt}`);
225
+ console.log("");
226
+ }
227
+ }
228
+
229
+ export async function handleChatLogView(logId: string): Promise<void> {
230
+ const log = await ChatLogger.getLog(logId);
231
+ if (!log) {
232
+ console.error(`Chat log not found: ${logId}`);
233
+ process.exit(1);
234
+ }
235
+
236
+ console.log(JSON.stringify(log, null, 2));
237
+ }
238
+
239
+ export async function handleChatLogDelete(logId: string): Promise<void> {
240
+ const log = await ChatLogger.getLog(logId);
241
+ if (!log) {
242
+ console.error(`Chat log not found: ${logId}`);
243
+ process.exit(1);
244
+ }
245
+
246
+ const deleted = await ChatLogger.deleteLog(logId);
247
+ if (deleted) {
248
+ console.log(`Deleted chat log: ${logId}`);
249
+ } else {
250
+ console.error(`Failed to delete chat log: ${logId}`);
251
+ process.exit(1);
252
+ }
253
+ }
254
+
255
+ export async function handleChatLogsNuke(skipConfirm = false): Promise<void> {
256
+ const logs = await ChatLogger.listLogs();
257
+ if (logs.length === 0) {
258
+ console.log("No chat logs to delete.");
259
+ return;
260
+ }
261
+
262
+ // Skip confirmation if --yes flag or non-interactive (piped input)
263
+ if (skipConfirm || !process.stdin.isTTY) {
264
+ const count = await ChatLogger.deleteAllLogs();
265
+ console.log(`Deleted ${count} chat log(s).`);
266
+ return;
267
+ }
268
+
269
+ // Prompt for confirmation
270
+ process.stdout.write(`Delete all ${logs.length} chat logs? [y/N] `);
271
+ const response = await new Promise<string>((resolve) => {
272
+ process.stdin.once("data", (data) => {
273
+ resolve(data.toString().trim().toLowerCase());
274
+ });
275
+ });
276
+
277
+ if (response === "y" || response === "yes") {
278
+ const count = await ChatLogger.deleteAllLogs();
279
+ console.log(`\nDeleted ${count} chat log(s).`);
280
+ } else {
281
+ console.log("Cancelled.");
282
+ }
283
+ }
284
+
285
+ export function parseRepos(args: string[]): RepoSpec[] {
286
+ const repos: RepoSpec[] = [];
287
+
288
+ for (const arg of args) {
289
+ if (!arg || arg.startsWith("-")) continue;
290
+
291
+ if (!arg.includes("/")) {
292
+ throw new Error(`Invalid repo format: ${arg} (expected owner/repo)`);
293
+ }
294
+
295
+ const repo = parseRepoSpec(arg);
296
+ repos.push(repo);
297
+ }
298
+
299
+ return repos;
300
+ }
301
+
302
+ export async function handleCLIMode(args: string[]): Promise<void> {
303
+ const firstArg = args[0];
304
+
305
+ if (firstArg === "--list" || firstArg === "-l") {
306
+ await handleList();
307
+ } else if (firstArg === "--resume" || firstArg === "-r") {
308
+ const sessionName = args[1];
309
+ if (!sessionName) {
310
+ console.error("Missing session name. Usage: letmecook --cli --resume <session-name>");
311
+ process.exit(1);
312
+ }
313
+ await handleResume(sessionName);
314
+ } else if (firstArg === "--delete" || firstArg === "-d") {
315
+ const sessionName = args[1];
316
+ if (!sessionName) {
317
+ console.error("Missing session name. Usage: letmecook --cli --delete <session-name>");
318
+ process.exit(1);
319
+ }
320
+ await handleDelete(sessionName);
321
+ } else if (firstArg === "--nuke") {
322
+ const hasYes = args.includes("--yes") || args.includes("-y");
323
+ await handleNuke(hasYes);
324
+ } else if (firstArg === "--logs") {
325
+ const secondArg = args[1];
326
+ if (secondArg === "--view") {
327
+ const logId = args[2];
328
+ if (!logId) {
329
+ console.error("Missing log ID. Usage: letmecook --cli --logs --view <log-id>");
330
+ process.exit(1);
331
+ }
332
+ await handleChatLogView(logId);
333
+ } else if (secondArg === "--delete") {
334
+ const logId = args[2];
335
+ if (!logId) {
336
+ console.error("Missing log ID. Usage: letmecook --cli --logs --delete <log-id>");
337
+ process.exit(1);
338
+ }
339
+ await handleChatLogDelete(logId);
340
+ } else if (secondArg === "--nuke") {
341
+ const hasYes = args.includes("--yes") || args.includes("-y");
342
+ await handleChatLogsNuke(hasYes);
343
+ } else {
344
+ await handleChatLogsList();
345
+ }
346
+ } else if (!firstArg || firstArg.startsWith("-")) {
347
+ // No args or unknown flag after --cli
348
+ if (firstArg?.startsWith("-")) {
349
+ console.error(`Unknown CLI option: ${firstArg}`);
350
+ }
351
+ printCLIUsage();
352
+ process.exit(firstArg ? 1 : 0);
353
+ } else {
354
+ try {
355
+ const repos = parseRepos(args);
356
+ if (repos.length === 0) {
357
+ printCLIUsage();
358
+ process.exit(1);
359
+ }
360
+ await handleNewSessionCLI(repos);
361
+ } catch (error) {
362
+ console.error("Error:", error instanceof Error ? error.message : error);
363
+ process.exit(1);
364
+ }
365
+ }
366
+ }
@@ -0,0 +1,147 @@
1
+ import { EventEmitter } from "events";
2
+ import type { ChatConfig } from "./flows/chat-to-config";
3
+
4
+ export interface PartialConfig {
5
+ repos: string[];
6
+ skills: string[];
7
+ goal: string | null;
8
+ }
9
+
10
+ export interface ConfigBuilderEvents {
11
+ "config-changed": (config: PartialConfig) => void;
12
+ "repo-added": (repo: string) => void;
13
+ "repo-removed": (repo: string) => void;
14
+ "skill-added": (skill: string) => void;
15
+ "goal-set": (goal: string) => void;
16
+ }
17
+
18
+ export class ConfigBuilder extends EventEmitter {
19
+ private _config: PartialConfig;
20
+
21
+ constructor() {
22
+ super();
23
+ this._config = {
24
+ repos: [],
25
+ skills: [],
26
+ goal: null,
27
+ };
28
+ }
29
+
30
+ get config(): PartialConfig {
31
+ return { ...this._config };
32
+ }
33
+
34
+ /**
35
+ * Add a repository to the config
36
+ * @returns true if added, false if already exists
37
+ */
38
+ addRepo(repo: string): boolean {
39
+ const normalized = repo.trim();
40
+ if (!normalized) return false;
41
+
42
+ // Check if already exists
43
+ if (this._config.repos.includes(normalized)) {
44
+ return false;
45
+ }
46
+
47
+ this._config.repos.push(normalized);
48
+ this.emit("repo-added", normalized);
49
+ this.emit("config-changed", this.config);
50
+ return true;
51
+ }
52
+
53
+ /**
54
+ * Remove a repository from the config
55
+ * @returns true if removed, false if not found
56
+ */
57
+ removeRepo(repo: string): boolean {
58
+ const normalized = repo.trim();
59
+ const index = this._config.repos.indexOf(normalized);
60
+
61
+ if (index === -1) {
62
+ return false;
63
+ }
64
+
65
+ this._config.repos.splice(index, 1);
66
+ this.emit("repo-removed", normalized);
67
+ this.emit("config-changed", this.config);
68
+ return true;
69
+ }
70
+
71
+ /**
72
+ * Add a skill to the config
73
+ * @returns true if added, false if already exists
74
+ */
75
+ addSkill(skill: string): boolean {
76
+ const normalized = skill.trim();
77
+ if (!normalized) return false;
78
+
79
+ // Check if already exists
80
+ if (this._config.skills.includes(normalized)) {
81
+ return false;
82
+ }
83
+
84
+ this._config.skills.push(normalized);
85
+ this.emit("skill-added", normalized);
86
+ this.emit("config-changed", this.config);
87
+ return true;
88
+ }
89
+
90
+ /**
91
+ * Remove a skill from the config
92
+ * @returns true if removed, false if not found
93
+ */
94
+ removeSkill(skill: string): boolean {
95
+ const normalized = skill.trim();
96
+ const index = this._config.skills.indexOf(normalized);
97
+
98
+ if (index === -1) {
99
+ return false;
100
+ }
101
+
102
+ this._config.skills.splice(index, 1);
103
+ this.emit("config-changed", this.config);
104
+ return true;
105
+ }
106
+
107
+ /**
108
+ * Set the goal for the config
109
+ */
110
+ setGoal(goal: string): void {
111
+ const normalized = goal.trim();
112
+ this._config.goal = normalized || null;
113
+ this.emit("goal-set", normalized);
114
+ this.emit("config-changed", this.config);
115
+ }
116
+
117
+ /**
118
+ * Check if the config has minimum requirements to proceed
119
+ * Currently requires at least one repo
120
+ */
121
+ isReady(): boolean {
122
+ return this._config.repos.length > 0;
123
+ }
124
+
125
+ /**
126
+ * Convert to final ChatConfig format
127
+ */
128
+ toFinalConfig(): ChatConfig {
129
+ return {
130
+ repos: [...this._config.repos],
131
+ skills: [...this._config.skills],
132
+ goal: this._config.goal || "",
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Reset the config to initial state
138
+ */
139
+ reset(): void {
140
+ this._config = {
141
+ repos: [],
142
+ skills: [],
143
+ goal: null,
144
+ };
145
+ this.emit("config-changed", this.config);
146
+ }
147
+ }
package/src/env.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ const LETMECOOK_DIR = join(homedir(), ".letmecook");
5
+
6
+ export async function loadEnvAsync(): Promise<void> {
7
+ // Check if AI_GATEWAY_API_KEY is already set
8
+ if (process.env.AI_GATEWAY_API_KEY) {
9
+ return;
10
+ }
11
+
12
+ // Try loading from ~/.letmecook/.env
13
+ const globalEnvPath = join(LETMECOOK_DIR, ".env");
14
+ const globalEnvFile = Bun.file(globalEnvPath);
15
+
16
+ // Try loading from ./.env (current directory)
17
+ const localEnvPath = join(process.cwd(), ".env");
18
+ const localEnvFile = Bun.file(localEnvPath);
19
+
20
+ // Load global first, then local (local overrides)
21
+ try {
22
+ if (await globalEnvFile.exists()) {
23
+ const content = await globalEnvFile.text();
24
+ parseAndSetEnv(content);
25
+ }
26
+ } catch {
27
+ // Ignore errors
28
+ }
29
+
30
+ try {
31
+ if (await localEnvFile.exists()) {
32
+ const content = await localEnvFile.text();
33
+ parseAndSetEnv(content);
34
+ }
35
+ } catch {
36
+ // Ignore errors
37
+ }
38
+ }
39
+
40
+ export function loadEnv(): void {
41
+ // Synchronous version - fire and forget
42
+ loadEnvAsync().catch(() => {
43
+ // Ignore errors
44
+ });
45
+ }
46
+
47
+ function parseAndSetEnv(content: string): void {
48
+ for (const line of content.split("\n")) {
49
+ const trimmed = line.trim();
50
+
51
+ // Skip comments and empty lines
52
+ if (!trimmed || trimmed.startsWith("#")) {
53
+ continue;
54
+ }
55
+
56
+ const equalIndex = trimmed.indexOf("=");
57
+ if (equalIndex === -1) {
58
+ continue;
59
+ }
60
+
61
+ const key = trimmed.slice(0, equalIndex).trim();
62
+ let value = trimmed.slice(equalIndex + 1).trim();
63
+
64
+ // Remove quotes if present
65
+ if (
66
+ (value.startsWith('"') && value.endsWith('"')) ||
67
+ (value.startsWith("'") && value.endsWith("'"))
68
+ ) {
69
+ value = value.slice(1, -1);
70
+ }
71
+
72
+ if (key) {
73
+ process.env[key] = value;
74
+ }
75
+ }
76
+ }