letmecook 0.0.21 → 0.0.22
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/index.ts +85 -256
- package/package.json +5 -1
- package/src/agents-md.ts +12 -15
- package/src/cli-mode.ts +266 -0
- package/src/flows/add-repos.ts +51 -115
- package/src/flows/new-session.ts +58 -141
- package/src/flows/resume-session.ts +33 -37
- package/src/git.ts +39 -77
- package/src/naming.ts +1 -1
- package/src/splash.ts +199 -0
- package/src/tui-mode.ts +2 -0
- package/src/types.ts +4 -2
- package/src/ui/add-repos.ts +34 -26
- package/src/ui/common/repo-formatter.ts +4 -4
- package/src/ui/new-session.ts +2 -2
- package/src/ui/progress.ts +1 -1
- package/src/ui/renderer.ts +7 -14
- package/src/ui/session-settings.ts +4 -3
- package/src/reference-repo.ts +0 -288
package/src/cli-mode.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
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
|
+
|
|
15
|
+
export function printCLIUsage(): void {
|
|
16
|
+
console.log(`
|
|
17
|
+
letmecook CLI mode
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
letmecook --cli <owner/repo> [owner/repo:branch...] Create or resume a session
|
|
21
|
+
letmecook --cli --list List all sessions
|
|
22
|
+
letmecook --cli --resume <session-name> Resume a session
|
|
23
|
+
letmecook --cli --delete <session-name> Delete a session
|
|
24
|
+
letmecook --cli --nuke [--yes] Nuke everything
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
letmecook --cli microsoft/playwright
|
|
28
|
+
letmecook --cli facebook/react openai/agents
|
|
29
|
+
letmecook --cli --resume playwright-agent-tests
|
|
30
|
+
`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function handleNewSessionCLI(repos: RepoSpec[]): Promise<void> {
|
|
34
|
+
const renderer = await createRenderer();
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const { goal, cancelled } = await showNewSessionPrompt(renderer, repos);
|
|
38
|
+
|
|
39
|
+
if (cancelled) {
|
|
40
|
+
destroyRenderer();
|
|
41
|
+
console.log("\nCancelled.");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const result = await createNewSession(renderer, {
|
|
46
|
+
repos,
|
|
47
|
+
goal,
|
|
48
|
+
mode: "cli",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (!result) {
|
|
52
|
+
destroyRenderer();
|
|
53
|
+
console.log("\nCancelled.");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { session, skipped } = result;
|
|
58
|
+
|
|
59
|
+
if (skipped) {
|
|
60
|
+
destroyRenderer();
|
|
61
|
+
console.log(`\nResuming existing session: ${session.name}\n`);
|
|
62
|
+
await resumeSession(renderer, {
|
|
63
|
+
session,
|
|
64
|
+
mode: "cli",
|
|
65
|
+
initialRefresh: true,
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
destroyRenderer();
|
|
71
|
+
console.log(`\nSession created: ${session.name}`);
|
|
72
|
+
console.log(`Path: ${session.path}\n`);
|
|
73
|
+
|
|
74
|
+
await resumeSession(renderer, {
|
|
75
|
+
session,
|
|
76
|
+
mode: "cli",
|
|
77
|
+
initialRefresh: false,
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
destroyRenderer();
|
|
81
|
+
console.error("\nError:", error instanceof Error ? error.message : error);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function handleList(): Promise<void> {
|
|
87
|
+
const renderer = await createRenderer();
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
while (true) {
|
|
91
|
+
const sessions = await listSessions();
|
|
92
|
+
const action = await showSessionList(renderer, sessions);
|
|
93
|
+
|
|
94
|
+
switch (action.type) {
|
|
95
|
+
case "resume":
|
|
96
|
+
destroyRenderer();
|
|
97
|
+
await updateLastAccessed(action.session.name);
|
|
98
|
+
console.log(`\nResuming session: ${action.session.name}\n`);
|
|
99
|
+
await resumeSession(renderer, {
|
|
100
|
+
session: action.session,
|
|
101
|
+
mode: "cli",
|
|
102
|
+
initialRefresh: true,
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
|
|
106
|
+
case "delete":
|
|
107
|
+
console.log("[TODO] Delete session flow");
|
|
108
|
+
break;
|
|
109
|
+
|
|
110
|
+
case "nuke": {
|
|
111
|
+
const choice = await showNukeConfirm(renderer, sessions.length);
|
|
112
|
+
if (choice === "confirm") {
|
|
113
|
+
const count = await deleteAllSessions();
|
|
114
|
+
destroyRenderer();
|
|
115
|
+
console.log(`\nNuked ${count} session(s) and all data.`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
case "quit":
|
|
122
|
+
destroyRenderer();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch (error) {
|
|
127
|
+
destroyRenderer();
|
|
128
|
+
console.error("\nError:", error instanceof Error ? error.message : error);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function handleResume(sessionName: string): Promise<void> {
|
|
134
|
+
const session = await getSession(sessionName);
|
|
135
|
+
|
|
136
|
+
if (!session) {
|
|
137
|
+
console.error(`Session not found: ${sessionName}`);
|
|
138
|
+
console.log("\nAvailable sessions:");
|
|
139
|
+
const sessions = await listSessions();
|
|
140
|
+
if (sessions.length === 0) {
|
|
141
|
+
console.log(" (none)");
|
|
142
|
+
} else {
|
|
143
|
+
sessions.forEach((s) => console.log(` - ${s.name}`));
|
|
144
|
+
}
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
await updateLastAccessed(session.name);
|
|
149
|
+
console.log(`\nResuming session: ${session.name}\n`);
|
|
150
|
+
|
|
151
|
+
const renderer = await createRenderer();
|
|
152
|
+
await resumeSession(renderer, {
|
|
153
|
+
session,
|
|
154
|
+
mode: "cli",
|
|
155
|
+
initialRefresh: true,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function handleDelete(sessionName: string): Promise<void> {
|
|
160
|
+
const session = await getSession(sessionName);
|
|
161
|
+
|
|
162
|
+
if (!session) {
|
|
163
|
+
console.error(`Session not found: ${sessionName}`);
|
|
164
|
+
const sessions = await listSessions();
|
|
165
|
+
if (sessions.length > 0) {
|
|
166
|
+
console.log("\nAvailable sessions:");
|
|
167
|
+
sessions.forEach((s) => console.log(` - ${s.name}`));
|
|
168
|
+
}
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const deleted = await deleteSession(sessionName);
|
|
173
|
+
if (deleted) {
|
|
174
|
+
console.log(`Deleted session: ${sessionName}`);
|
|
175
|
+
} else {
|
|
176
|
+
console.error(`Failed to delete session: ${sessionName}`);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function handleNuke(skipConfirm = false): Promise<void> {
|
|
182
|
+
const sessions = await listSessions();
|
|
183
|
+
if (sessions.length === 0) {
|
|
184
|
+
console.log("Nothing to nuke.");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Skip confirmation if --yes flag or non-interactive (piped input)
|
|
189
|
+
if (skipConfirm || !process.stdin.isTTY) {
|
|
190
|
+
const count = await deleteAllSessions();
|
|
191
|
+
console.log(`Nuked ${count} session(s) and all data.`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const renderer = await createRenderer();
|
|
196
|
+
const choice = await showNukeConfirm(renderer, sessions.length);
|
|
197
|
+
destroyRenderer();
|
|
198
|
+
|
|
199
|
+
if (choice === "confirm") {
|
|
200
|
+
const count = await deleteAllSessions();
|
|
201
|
+
console.log(`Nuked ${count} session(s) and all data.`);
|
|
202
|
+
} else {
|
|
203
|
+
console.log("Cancelled.");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function parseRepos(args: string[]): RepoSpec[] {
|
|
208
|
+
const repos: RepoSpec[] = [];
|
|
209
|
+
|
|
210
|
+
for (const arg of args) {
|
|
211
|
+
if (!arg || arg.startsWith("-")) continue;
|
|
212
|
+
|
|
213
|
+
if (!arg.includes("/")) {
|
|
214
|
+
throw new Error(`Invalid repo format: ${arg} (expected owner/repo)`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const repo = parseRepoSpec(arg);
|
|
218
|
+
repos.push(repo);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return repos;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function handleCLIMode(args: string[]): Promise<void> {
|
|
225
|
+
const firstArg = args[0];
|
|
226
|
+
|
|
227
|
+
if (firstArg === "--list" || firstArg === "-l") {
|
|
228
|
+
await handleList();
|
|
229
|
+
} else if (firstArg === "--resume" || firstArg === "-r") {
|
|
230
|
+
const sessionName = args[1];
|
|
231
|
+
if (!sessionName) {
|
|
232
|
+
console.error("Missing session name. Usage: letmecook --cli --resume <session-name>");
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
await handleResume(sessionName);
|
|
236
|
+
} else if (firstArg === "--delete" || firstArg === "-d") {
|
|
237
|
+
const sessionName = args[1];
|
|
238
|
+
if (!sessionName) {
|
|
239
|
+
console.error("Missing session name. Usage: letmecook --cli --delete <session-name>");
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
await handleDelete(sessionName);
|
|
243
|
+
} else if (firstArg === "--nuke") {
|
|
244
|
+
const hasYes = args.includes("--yes") || args.includes("-y");
|
|
245
|
+
await handleNuke(hasYes);
|
|
246
|
+
} else if (!firstArg || firstArg.startsWith("-")) {
|
|
247
|
+
// No args or unknown flag after --cli
|
|
248
|
+
if (firstArg?.startsWith("-")) {
|
|
249
|
+
console.error(`Unknown CLI option: ${firstArg}`);
|
|
250
|
+
}
|
|
251
|
+
printCLIUsage();
|
|
252
|
+
process.exit(firstArg ? 1 : 0);
|
|
253
|
+
} else {
|
|
254
|
+
try {
|
|
255
|
+
const repos = parseRepos(args);
|
|
256
|
+
if (repos.length === 0) {
|
|
257
|
+
printCLIUsage();
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
await handleNewSessionCLI(repos);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
console.error("Error:", error instanceof Error ? error.message : error);
|
|
263
|
+
process.exit(1);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
package/src/flows/add-repos.ts
CHANGED
|
@@ -6,7 +6,6 @@ import { writeAgentsMd } from "../agents-md";
|
|
|
6
6
|
import { recordRepoHistory } from "../repo-history";
|
|
7
7
|
import { showAddReposPrompt } from "../ui/add-repos";
|
|
8
8
|
import { runCommands, hideCommandRunner, type CommandTask } from "../ui/common/command-runner";
|
|
9
|
-
import { ensureReferenceRepo, linkReferenceRepo, ensureReferencesDir } from "../reference-repo";
|
|
10
9
|
import { rm } from "node:fs/promises";
|
|
11
10
|
|
|
12
11
|
export interface AddReposParams {
|
|
@@ -20,10 +19,7 @@ export interface AddReposResult {
|
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
function reposToCommandTasks(repos: RepoSpec[], sessionPath: string): CommandTask[] {
|
|
23
|
-
|
|
24
|
-
const regularRepos = repos.filter((repo) => !repo.reference);
|
|
25
|
-
|
|
26
|
-
return regularRepos.map((repo) => {
|
|
22
|
+
return repos.map((repo) => {
|
|
27
23
|
const url = `https://github.com/${repo.owner}/${repo.name}.git`;
|
|
28
24
|
const targetDir = join(sessionPath, repo.dir);
|
|
29
25
|
const args = repo.branch
|
|
@@ -49,34 +45,6 @@ function reposToCommandTasks(repos: RepoSpec[], sessionPath: string): CommandTas
|
|
|
49
45
|
});
|
|
50
46
|
}
|
|
51
47
|
|
|
52
|
-
async function setupReferenceRepos(
|
|
53
|
-
repos: RepoSpec[],
|
|
54
|
-
sessionPath: string,
|
|
55
|
-
): Promise<{ successful: RepoSpec[]; failed: Array<{ repo: RepoSpec; error: string }> }> {
|
|
56
|
-
const referenceRepos = repos.filter((repo) => repo.reference);
|
|
57
|
-
const successful: RepoSpec[] = [];
|
|
58
|
-
const failed: Array<{ repo: RepoSpec; error: string }> = [];
|
|
59
|
-
|
|
60
|
-
if (referenceRepos.length === 0) {
|
|
61
|
-
return { successful, failed };
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
await ensureReferencesDir();
|
|
65
|
-
|
|
66
|
-
for (const repo of referenceRepos) {
|
|
67
|
-
try {
|
|
68
|
-
await ensureReferenceRepo(repo);
|
|
69
|
-
await linkReferenceRepo(repo, sessionPath);
|
|
70
|
-
successful.push(repo);
|
|
71
|
-
} catch (error) {
|
|
72
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
73
|
-
failed.push({ repo, error: errorMsg });
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return { successful, failed };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
48
|
export async function addReposFlow(params: AddReposParams): Promise<AddReposResult> {
|
|
81
49
|
const { renderer, session } = params;
|
|
82
50
|
|
|
@@ -87,102 +55,70 @@ export async function addReposFlow(params: AddReposParams): Promise<AddReposResu
|
|
|
87
55
|
const newRepos = addResult.repos.filter((r) => !existingSpecs.has(r.spec));
|
|
88
56
|
|
|
89
57
|
if (newRepos.length > 0) {
|
|
90
|
-
// Setup reference repos first (symlinks from cache)
|
|
91
|
-
const { successful: successfulRefs, failed: failedRefs } = await setupReferenceRepos(
|
|
92
|
-
newRepos,
|
|
93
|
-
session.path,
|
|
94
|
-
);
|
|
95
|
-
|
|
96
|
-
// Report reference repo failures
|
|
97
|
-
if (failedRefs.length > 0) {
|
|
98
|
-
console.error(`\n⚠️ ${failedRefs.length} reference repo(s) failed:`);
|
|
99
|
-
failedRefs.forEach(({ repo, error }) => {
|
|
100
|
-
console.error(` ✗ ${repo.owner}/${repo.name}: ${error}`);
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Clone regular repos
|
|
105
58
|
const tasks = reposToCommandTasks(newRepos, session.path);
|
|
106
|
-
let results: Awaited<ReturnType<typeof runCommands>> = [];
|
|
107
|
-
|
|
108
|
-
if (tasks.length > 0) {
|
|
109
|
-
results = await runCommands(renderer, {
|
|
110
|
-
title: "Adding repositories",
|
|
111
|
-
tasks,
|
|
112
|
-
showOutput: true,
|
|
113
|
-
outputLines: 5,
|
|
114
|
-
allowAbort: true,
|
|
115
|
-
allowSkip: tasks.length > 1,
|
|
116
|
-
allowBackground: true,
|
|
117
|
-
sessionName: session.name,
|
|
118
|
-
});
|
|
119
59
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
} catch {
|
|
131
|
-
// Ignore cleanup errors
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
// Also clean up reference symlinks
|
|
135
|
-
for (const repo of successfulRefs) {
|
|
136
|
-
const repoPath = join(session.path, repo.dir);
|
|
137
|
-
try {
|
|
138
|
-
await rm(repoPath, { recursive: true, force: true });
|
|
139
|
-
} catch {
|
|
140
|
-
// Ignore cleanup errors
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
return { session, cancelled: true };
|
|
144
|
-
}
|
|
60
|
+
const results = await runCommands(renderer, {
|
|
61
|
+
title: "Adding repositories",
|
|
62
|
+
tasks,
|
|
63
|
+
showOutput: true,
|
|
64
|
+
outputLines: 5,
|
|
65
|
+
allowAbort: true,
|
|
66
|
+
allowSkip: tasks.length > 1,
|
|
67
|
+
allowBackground: true,
|
|
68
|
+
sessionName: session.name,
|
|
69
|
+
});
|
|
145
70
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
71
|
+
// Handle aborted operation - return without changes
|
|
72
|
+
const wasAborted = results.some((r) => r.outcome === "aborted");
|
|
73
|
+
if (wasAborted) {
|
|
74
|
+
hideCommandRunner(renderer);
|
|
75
|
+
// Clean up any partially cloned repos
|
|
76
|
+
for (const repo of newRepos) {
|
|
77
|
+
const repoPath = join(session.path, repo.dir);
|
|
78
|
+
try {
|
|
79
|
+
await rm(repoPath, { recursive: true, force: true });
|
|
80
|
+
} catch {
|
|
81
|
+
// Ignore cleanup errors
|
|
158
82
|
}
|
|
159
83
|
}
|
|
84
|
+
return { session, cancelled: true };
|
|
85
|
+
}
|
|
160
86
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
}
|
|
87
|
+
// Clean up skipped repo directories
|
|
88
|
+
const skippedResults = results.filter((r) => r.outcome === "skipped");
|
|
89
|
+
for (const skipped of skippedResults) {
|
|
90
|
+
const repoSpec = skipped.task.label.replace("Cloning ", "");
|
|
91
|
+
const repo = newRepos.find((r) => `${r.owner}/${r.name}` === repoSpec);
|
|
92
|
+
if (repo) {
|
|
93
|
+
const repoPath = join(session.path, repo.dir);
|
|
94
|
+
try {
|
|
95
|
+
await rm(repoPath, { recursive: true, force: true });
|
|
96
|
+
} catch {
|
|
97
|
+
// Ignore cleanup errors
|
|
98
|
+
}
|
|
171
99
|
}
|
|
172
100
|
}
|
|
173
101
|
|
|
174
|
-
await new Promise((resolve) => setTimeout(resolve, 700));
|
|
175
|
-
hideCommandRunner(renderer);
|
|
176
|
-
|
|
177
102
|
// Filter to only successfully cloned repos
|
|
178
|
-
const
|
|
179
|
-
if (repo.reference) return false; // Reference repos handled separately
|
|
103
|
+
const successfulRepos = newRepos.filter((repo) => {
|
|
180
104
|
const result = results.find((r) => r.task.label === `Cloning ${repo.owner}/${repo.name}`);
|
|
181
105
|
return result?.outcome === "completed";
|
|
182
106
|
});
|
|
183
107
|
|
|
184
|
-
//
|
|
185
|
-
const
|
|
108
|
+
// Check for errors (not skipped/aborted)
|
|
109
|
+
const errors = results.filter((r) => r.outcome === "error");
|
|
110
|
+
if (errors.length > 0) {
|
|
111
|
+
console.error(`\n⚠️ ${errors.length} repository(ies) failed to clone:`);
|
|
112
|
+
errors.forEach((err) => {
|
|
113
|
+
console.error(` ✗ ${err.task.label}`);
|
|
114
|
+
if (err.error) {
|
|
115
|
+
console.error(` ${err.error}`);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, 700));
|
|
121
|
+
hideCommandRunner(renderer);
|
|
186
122
|
|
|
187
123
|
if (successfulRepos.length > 0) {
|
|
188
124
|
const allRepos = [...session.repos, ...successfulRepos];
|