letmecook 0.0.1 → 0.0.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.
@@ -0,0 +1,184 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { stdin as input, stdout as output } from "node:process";
3
+ import type { CliRenderer } from "@opentui/core";
4
+ import { createRenderer, destroyRenderer } from "./ui/renderer";
5
+ import { showAddReposPrompt } from "./ui/add-repos";
6
+ import { showNewSessionPrompt } from "./ui/new-session";
7
+ import { showSkillsPrompt } from "./ui/skills";
8
+ import { showMainMenu } from "./ui/main-menu";
9
+ import { showSessionDetails } from "./ui/session-details";
10
+ import { showSessionSettings } from "./ui/session-settings";
11
+ import { showDeleteConfirm } from "./ui/confirm-delete";
12
+ import type { Session } from "./types";
13
+ import { createNewSession, resumeSession } from "./flows";
14
+ import { listSessions, deleteSession, updateLastAccessed, updateSessionSettings } from "./sessions";
15
+
16
+ export async function handleTUIMode(): Promise<void> {
17
+ let renderer = await createRenderer();
18
+
19
+ try {
20
+ while (true) {
21
+ renderer = await createRenderer();
22
+ const sessions = await listSessions();
23
+ const action = await showMainMenu(renderer, sessions);
24
+
25
+ switch (action.type) {
26
+ case "new-session":
27
+ await handleNewSessionFlow(renderer);
28
+ break;
29
+
30
+ case "resume":
31
+ await handleSessionDetailsFlow(renderer, action.session);
32
+ break;
33
+
34
+ case "delete": {
35
+ const choice = await showDeleteConfirm(renderer, action.session);
36
+ if (choice === "confirm") {
37
+ await deleteSession(action.session.name);
38
+ }
39
+ break;
40
+ }
41
+
42
+ case "quit":
43
+ destroyRenderer();
44
+ console.log("\nGoodbye!");
45
+ return;
46
+ }
47
+ }
48
+ } catch (error) {
49
+ destroyRenderer();
50
+ console.error("\nError:", error instanceof Error ? error.message : error);
51
+ process.exit(1);
52
+ }
53
+ }
54
+
55
+ async function handleNewSessionFlow(renderer: CliRenderer): Promise<void> {
56
+ const addReposResult = await showAddReposPrompt(renderer);
57
+
58
+ if (addReposResult.cancelled || addReposResult.repos.length === 0) {
59
+ return;
60
+ }
61
+
62
+ const repos = addReposResult.repos;
63
+
64
+ const { skills, cancelled: skillsCancelled } = await showSkillsPrompt(renderer);
65
+
66
+ if (skillsCancelled) {
67
+ return;
68
+ }
69
+
70
+ const goalResult = await showNewSessionPrompt(renderer, repos);
71
+
72
+ if (goalResult.cancelled) {
73
+ return;
74
+ }
75
+
76
+ const result = await createNewSession(renderer, {
77
+ repos,
78
+ goal: goalResult.goal,
79
+ skills: skills.length > 0 ? skills : undefined,
80
+ mode: "tui",
81
+ });
82
+
83
+ if (!result) {
84
+ return;
85
+ }
86
+
87
+ const { session, skipped } = result;
88
+
89
+ if (skipped) {
90
+ await handleSessionDetailsFlow(renderer, session);
91
+ return;
92
+ }
93
+
94
+ // Destroy renderer before runManualSetup to release stdin for readline
95
+ destroyRenderer();
96
+
97
+ await runManualSetup(session);
98
+ await resumeSession(renderer, {
99
+ session,
100
+ mode: "tui",
101
+ initialRefresh: false,
102
+ });
103
+ }
104
+
105
+ async function runManualSetup(session: Session): Promise<void> {
106
+ const rl = createInterface({ input, output });
107
+
108
+ console.log("\n⚡️ Would you like to run any setup commands before launching claude?");
109
+ console.log(" Examples: npm install, bun install, make build");
110
+ console.log(" Press Enter to skip and launch claude immediately.\n");
111
+
112
+ while (true) {
113
+ const answer = await rl.question("> ");
114
+ const cmd = answer.trim();
115
+
116
+ if (!cmd) {
117
+ console.log("\n🚀 Launching claude...\n");
118
+ break;
119
+ }
120
+
121
+ console.log(`\nRunning: ${cmd}...`);
122
+ const proc = Bun.spawn(["bash", "-c", cmd], {
123
+ cwd: session.path,
124
+ stdio: ["inherit", "inherit", "inherit"],
125
+ });
126
+
127
+ await proc.exited;
128
+
129
+ if (proc.exitCode !== 0) {
130
+ console.log(`❌ Command failed with exit code ${proc.exitCode}`);
131
+ } else {
132
+ console.log("✅ Command completed");
133
+ }
134
+
135
+ console.log("\nEnter another command or press Enter to launch claude.");
136
+ }
137
+
138
+ rl.close();
139
+ }
140
+
141
+ async function handleSessionDetailsFlow(renderer: CliRenderer, session: Session): Promise<void> {
142
+ let currentSession = session;
143
+
144
+ while (true) {
145
+ const detailsAction = await showSessionDetails(renderer, currentSession);
146
+
147
+ if (detailsAction === "resume") {
148
+ await updateLastAccessed(currentSession.name);
149
+ await resumeSession(renderer, {
150
+ session: currentSession,
151
+ mode: "tui",
152
+ initialRefresh: true,
153
+ });
154
+ return;
155
+ }
156
+
157
+ if (detailsAction === "edit") {
158
+ const editResult = await showSessionSettings(renderer, currentSession);
159
+ if (editResult.action === "saved") {
160
+ const saved = await updateSessionSettings(editResult.session.name, {
161
+ repos: editResult.session.repos,
162
+ goal: editResult.session.goal,
163
+ });
164
+ if (saved) {
165
+ currentSession = saved;
166
+ }
167
+ } else if (editResult.action === "add-repos") {
168
+ const saved = await updateSessionSettings(editResult.session.name, {
169
+ repos: editResult.session.repos,
170
+ goal: editResult.session.goal,
171
+ });
172
+ if (saved) {
173
+ currentSession = saved;
174
+ }
175
+ console.log("[TODO] Add repos flow - needs to be implemented");
176
+ }
177
+ continue;
178
+ }
179
+
180
+ if (detailsAction === "back") {
181
+ return;
182
+ }
183
+ }
184
+ }
package/src/types.ts ADDED
@@ -0,0 +1,80 @@
1
+ export interface RepoSpec {
2
+ /** Original spec string, e.g., "owner/repo:branch" */
3
+ spec: string;
4
+ /** Repository owner */
5
+ owner: string;
6
+ /** Repository name */
7
+ name: string;
8
+ /** Branch to checkout (defaults to default branch if not specified) */
9
+ branch?: string;
10
+ /** Directory name in the session workspace */
11
+ dir: string;
12
+ /** Whether this repo is read-only (reference only, no modifications allowed) */
13
+ readOnly?: boolean;
14
+ /** Whether this repo should be refreshed to latest before resuming */
15
+ latest?: boolean;
16
+ }
17
+
18
+ export interface SessionManifest {
19
+ /** Session name (AI-generated) */
20
+ name: string;
21
+ /** Repositories in this session */
22
+ repos: RepoSpec[];
23
+ /** User-provided goal/context */
24
+ goal?: string;
25
+ /** Installed skill packages (e.g., "vercel-labs/agent-skills") */
26
+ skills?: string[];
27
+ /** ISO timestamp when session was created */
28
+ created: string;
29
+ /** ISO timestamp when session was last accessed */
30
+ lastAccessed: string;
31
+ }
32
+
33
+ export interface Session extends SessionManifest {
34
+ /** Full path to session directory */
35
+ path: string;
36
+ }
37
+
38
+ export type ConflictChoice = "resume" | "nuke" | "new" | "cancel";
39
+
40
+ export type ExitChoice = "resume" | "edit" | "home" | "delete";
41
+
42
+ export function parseRepoSpec(spec: string): RepoSpec {
43
+ // Format: owner/repo or owner/repo:branch
44
+ const colonIndex = spec.indexOf(":");
45
+ const repoPath = colonIndex === -1 ? spec : spec.slice(0, colonIndex);
46
+ const branch = colonIndex === -1 ? undefined : spec.slice(colonIndex + 1);
47
+
48
+ const slashIndex = repoPath.indexOf("/");
49
+ if (slashIndex === -1) {
50
+ throw new Error(`Invalid repo format: ${spec} (expected owner/repo or owner/repo:branch)`);
51
+ }
52
+
53
+ const owner = repoPath.slice(0, slashIndex);
54
+ const name = repoPath.slice(slashIndex + 1);
55
+
56
+ if (!owner || !name) {
57
+ throw new Error(`Invalid repo format: ${spec} (expected owner/repo or owner/repo:branch)`);
58
+ }
59
+
60
+ return {
61
+ spec,
62
+ owner,
63
+ name,
64
+ branch,
65
+ dir: name,
66
+ };
67
+ }
68
+
69
+ export function repoSpecsMatch(a: RepoSpec[], b: RepoSpec[]): boolean {
70
+ if (a.length !== b.length) return false;
71
+
72
+ const aSpecs = new Set(a.map((r) => `${r.owner}/${r.name}`));
73
+ const bSpecs = new Set(b.map((r) => `${r.owner}/${r.name}`));
74
+
75
+ for (const spec of aSpecs) {
76
+ if (!bSpecs.has(spec)) return false;
77
+ }
78
+
79
+ return true;
80
+ }
@@ -0,0 +1,396 @@
1
+ import { type CliRenderer, TextRenderable, InputRenderable, type KeyEvent } from "@opentui/core";
2
+ import { createBaseLayout, clearLayout } from "./renderer";
3
+ import { parseRepoSpec, type RepoSpec } from "../types";
4
+ import { listRepoHistory } from "../repo-history";
5
+
6
+ export interface AddReposResult {
7
+ repos: RepoSpec[];
8
+ cancelled: boolean;
9
+ }
10
+
11
+ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddReposResult> {
12
+ const history = await listRepoHistory();
13
+ const historySpecs = history.map((item) => item.spec);
14
+ let historyIndex = historySpecs.length;
15
+ const maxMatches = 6;
16
+
17
+ return new Promise((resolve) => {
18
+ clearLayout(renderer);
19
+
20
+ const { content } = createBaseLayout(renderer, "Add repositories");
21
+
22
+ const repos: RepoSpec[] = [];
23
+ let currentInput = "";
24
+ let lastMatchIndex = -1;
25
+ let lastMatchQuery = "";
26
+ let mode: "spec" | "details" = "spec";
27
+ let pendingRepo: RepoSpec | null = null;
28
+ let pendingReadOnly = false;
29
+ let pendingLatest = false;
30
+
31
+ // Repository input
32
+ const repoLabel = new TextRenderable(renderer, {
33
+ id: "repo-label",
34
+ content: "Repository (owner/repo format):",
35
+ fg: "#e2e8f0",
36
+ marginBottom: 0,
37
+ });
38
+ content.add(repoLabel);
39
+
40
+ const repoInput = new InputRenderable(renderer, {
41
+ id: "repo-input",
42
+ width: 50,
43
+ height: 1,
44
+ placeholder: "e.g., microsoft/playwright",
45
+ placeholderColor: "#64748b",
46
+ backgroundColor: "#334155",
47
+ textColor: "#f8fafc",
48
+ cursorColor: "#38bdf8",
49
+ marginTop: 1,
50
+ });
51
+ content.add(repoInput);
52
+
53
+ // Matches display
54
+ const matchesLabel = new TextRenderable(renderer, {
55
+ id: "matches-label",
56
+ content: "Matches:",
57
+ fg: "#94a3b8",
58
+ marginTop: 1,
59
+ });
60
+ content.add(matchesLabel);
61
+
62
+ const matchesList = new TextRenderable(renderer, {
63
+ id: "matches-list",
64
+ content: "(no matches)",
65
+ fg: "#64748b",
66
+ marginTop: 0,
67
+ });
68
+ content.add(matchesList);
69
+
70
+ // Details form
71
+ const detailsLabel = new TextRenderable(renderer, {
72
+ id: "details-label",
73
+ content: "\nRepository details:",
74
+ fg: "#e2e8f0",
75
+ marginTop: 1,
76
+ });
77
+ content.add(detailsLabel);
78
+
79
+ const detailsReadOnly = new TextRenderable(renderer, {
80
+ id: "details-readonly",
81
+ content: " Read-only: No",
82
+ fg: "#94a3b8",
83
+ marginTop: 0,
84
+ });
85
+ content.add(detailsReadOnly);
86
+
87
+ const detailsLatest = new TextRenderable(renderer, {
88
+ id: "details-latest",
89
+ content: " Latest: No",
90
+ fg: "#94a3b8",
91
+ marginTop: 0,
92
+ });
93
+ content.add(detailsLatest);
94
+
95
+ repoInput.onPaste = (event) => {
96
+ const text = event.text.replace(/[\r\n]+/g, "");
97
+ if (!text) return;
98
+ repoInput.insertText(text);
99
+ currentInput = repoInput.value;
100
+ if (currentInput.trim()) {
101
+ validateAndAddRepo(currentInput.trim());
102
+ } else {
103
+ statusText.content = "";
104
+ }
105
+ event.preventDefault();
106
+ };
107
+
108
+ // Status display
109
+ const statusText = new TextRenderable(renderer, {
110
+ id: "status",
111
+ content: "",
112
+ fg: "#64748b",
113
+ marginTop: 1,
114
+ });
115
+ content.add(statusText);
116
+
117
+ // Repos list
118
+ const reposLabel = new TextRenderable(renderer, {
119
+ id: "repos-label",
120
+ content: "\nAdded repositories:",
121
+ fg: "#e2e8f0",
122
+ marginTop: 1,
123
+ });
124
+ content.add(reposLabel);
125
+
126
+ const reposList = new TextRenderable(renderer, {
127
+ id: "repos-list",
128
+ content: "(none)",
129
+ fg: "#64748b",
130
+ marginTop: 1,
131
+ });
132
+ content.add(reposList);
133
+
134
+ // Instructions
135
+ const instructions = new TextRenderable(renderer, {
136
+ id: "instructions",
137
+ content: "\n[Enter] Next [Ctrl+D] Continue [↑/↓] History [Tab] Complete [Esc] Exit",
138
+ fg: "#64748b",
139
+ marginTop: 2,
140
+ });
141
+ content.add(instructions);
142
+
143
+ repoInput.focus();
144
+
145
+ function updateReposList() {
146
+ if (repos.length === 0) {
147
+ reposList.content = "(none)";
148
+ reposList.fg = "#64748b";
149
+ } else {
150
+ reposList.content = repos
151
+ .map((repo, i) => {
152
+ const roMarker = repo.readOnly ? " [RO]" : "";
153
+ const latestMarker = repo.latest ? " [Latest]" : "";
154
+ return ` ${i + 1}. ${repo.spec}${roMarker}${latestMarker}`;
155
+ })
156
+ .join("\n");
157
+ reposList.fg = "#94a3b8";
158
+ }
159
+ }
160
+
161
+ function updateDetails() {
162
+ if (mode === "details" && pendingRepo) {
163
+ detailsLabel.content = "\nRepository details:";
164
+ detailsLabel.fg = "#e2e8f0";
165
+ detailsReadOnly.content = ` Read-only: ${pendingReadOnly ? "Yes" : "No"}`;
166
+ detailsReadOnly.fg = pendingReadOnly ? "#f59e0b" : "#94a3b8";
167
+ detailsLatest.content = ` Latest: ${pendingLatest ? "Yes" : "No"}`;
168
+ detailsLatest.fg = pendingLatest ? "#22d3ee" : "#94a3b8";
169
+ instructions.content =
170
+ "\n[Enter] Add [r] Toggle read-only [l] Toggle latest [Esc] Back";
171
+ } else {
172
+ detailsLabel.content = "\nRepository details:";
173
+ detailsLabel.fg = "#475569";
174
+ detailsReadOnly.content = " Read-only: No";
175
+ detailsReadOnly.fg = "#475569";
176
+ detailsLatest.content = " Latest: No";
177
+ detailsLatest.fg = "#475569";
178
+ instructions.content =
179
+ "\n[Enter] Next [Ctrl+D] Continue [↑/↓] History [Tab] Complete [Esc] Cancel";
180
+ }
181
+ }
182
+
183
+ function updateMatches(value: string, selectedSpec?: string) {
184
+ const query = value.trim();
185
+ if (!query) {
186
+ matchesList.content = "(no matches)";
187
+ matchesList.fg = "#64748b";
188
+ return;
189
+ }
190
+
191
+ const lowerQuery = query.toLowerCase();
192
+ const matches = historySpecs
193
+ .filter((spec) => spec.toLowerCase().startsWith(lowerQuery))
194
+ .toSorted((a, b) => {
195
+ if (a.length !== b.length) return a.length - b.length;
196
+ return a.localeCompare(b);
197
+ })
198
+ .slice(0, maxMatches);
199
+
200
+ if (matches.length === 0) {
201
+ matchesList.content = "(no matches)";
202
+ matchesList.fg = "#64748b";
203
+ return;
204
+ }
205
+
206
+ matchesList.content = matches
207
+ .map((spec, index) => {
208
+ const isSelected = selectedSpec ? spec === selectedSpec : index === 0;
209
+ return `${isSelected ? "▶" : " "} ${spec}`;
210
+ })
211
+ .join("\n");
212
+ matchesList.fg = "#94a3b8";
213
+ }
214
+
215
+ function getMatches(value: string): string[] {
216
+ const query = value.trim();
217
+ if (!query) return [];
218
+ const lowerQuery = query.toLowerCase();
219
+ return historySpecs
220
+ .filter((spec) => spec.toLowerCase().startsWith(lowerQuery))
221
+ .toSorted((a, b) => {
222
+ if (a.length !== b.length) return a.length - b.length;
223
+ return a.localeCompare(b);
224
+ })
225
+ .slice(0, maxMatches);
226
+ }
227
+
228
+ function validateAndAddRepo(spec: string): boolean {
229
+ try {
230
+ parseRepoSpec(spec);
231
+ statusText.content = "✓ Valid format";
232
+ statusText.fg = "#10b981";
233
+ return true;
234
+ } catch (error) {
235
+ statusText.content = `✗ ${error instanceof Error ? error.message : "Invalid format"}`;
236
+ statusText.fg = "#ef4444";
237
+ return false;
238
+ }
239
+ }
240
+
241
+ function applyHistorySpec(spec: string) {
242
+ repoInput.value = spec;
243
+ currentInput = spec;
244
+ if (spec.trim()) {
245
+ validateAndAddRepo(spec.trim());
246
+ } else {
247
+ statusText.content = "";
248
+ }
249
+ updateMatches(currentInput);
250
+ }
251
+
252
+ function startRepoDetails() {
253
+ const spec = currentInput.trim();
254
+ if (!spec) return;
255
+
256
+ if (validateAndAddRepo(spec)) {
257
+ // Check if already added
258
+ if (repos.some((r) => r.spec === spec)) {
259
+ statusText.content = "⚠️ Repository already added";
260
+ statusText.fg = "#f59e0b";
261
+ return;
262
+ }
263
+
264
+ pendingRepo = parseRepoSpec(spec);
265
+ pendingReadOnly = false;
266
+ pendingLatest = false;
267
+ mode = "details";
268
+ repoInput.blur();
269
+ updateDetails();
270
+ }
271
+ }
272
+
273
+ function confirmAddRepo() {
274
+ if (!pendingRepo) return;
275
+ pendingRepo.readOnly = pendingReadOnly;
276
+ pendingRepo.latest = pendingLatest;
277
+ repos.push(pendingRepo);
278
+
279
+ pendingRepo = null;
280
+ pendingReadOnly = false;
281
+ pendingLatest = false;
282
+ mode = "spec";
283
+ currentInput = "";
284
+ repoInput.value = "";
285
+ repoInput.focus();
286
+ updateReposList();
287
+ updateMatches("");
288
+ updateDetails();
289
+ lastMatchIndex = -1;
290
+ lastMatchQuery = "";
291
+
292
+ statusText.content = "✓ Added successfully";
293
+ statusText.fg = "#10b981";
294
+
295
+ setTimeout(() => {
296
+ if (statusText.content?.toString() === "✓ Added successfully") {
297
+ statusText.content = "";
298
+ }
299
+ }, 2000);
300
+ }
301
+
302
+ const handleKeypress = (key: KeyEvent) => {
303
+ if (mode === "details") {
304
+ if (key.name === "r") {
305
+ pendingReadOnly = !pendingReadOnly;
306
+ if (!pendingReadOnly) {
307
+ pendingLatest = false;
308
+ }
309
+ updateDetails();
310
+ } else if (key.name === "l") {
311
+ pendingLatest = !pendingLatest;
312
+ if (pendingLatest) {
313
+ pendingReadOnly = true;
314
+ }
315
+ updateDetails();
316
+ } else if (key.name === "escape" || key.name === "backspace") {
317
+ mode = "spec";
318
+ pendingRepo = null;
319
+ pendingReadOnly = false;
320
+ pendingLatest = false;
321
+ repoInput.focus();
322
+ updateDetails();
323
+ } else if (key.name === "return" || key.name === "enter") {
324
+ confirmAddRepo();
325
+ }
326
+ } else {
327
+ // Spec input mode
328
+ if (key.name === "escape") {
329
+ cleanup();
330
+ resolve({ repos, cancelled: true });
331
+ } else if (key.name === "return" || key.name === "enter") {
332
+ startRepoDetails();
333
+ } else if (key.name === "tab") {
334
+ const query = currentInput.trim();
335
+ const matches = getMatches(query);
336
+ if (matches.length === 0) return;
337
+
338
+ const currentIndex = matches.findIndex((spec) => spec === currentInput);
339
+ let nextIndex = 0;
340
+
341
+ if (currentIndex !== -1) {
342
+ nextIndex = (currentIndex + 1) % matches.length;
343
+ } else if (lastMatchQuery === query.toLowerCase() && lastMatchIndex >= 0) {
344
+ nextIndex = (lastMatchIndex + 1) % matches.length;
345
+ }
346
+
347
+ const match = matches[nextIndex];
348
+ if (!match) return;
349
+ repoInput.value = match;
350
+ repoInput.cursorPosition = match.length;
351
+ currentInput = match;
352
+ lastMatchIndex = nextIndex;
353
+ lastMatchQuery = query.toLowerCase();
354
+ validateAndAddRepo(match.trim());
355
+ updateMatches(currentInput, match);
356
+ } else if (key.name === "up") {
357
+ if (historySpecs.length === 0) return;
358
+ historyIndex = Math.max(0, historyIndex - 1);
359
+ const spec = historySpecs[historyIndex];
360
+ if (spec) applyHistorySpec(spec);
361
+ } else if (key.name === "down") {
362
+ if (historySpecs.length === 0) return;
363
+ historyIndex = Math.min(historySpecs.length - 1, historyIndex + 1);
364
+ const spec = historySpecs[historyIndex];
365
+ if (spec) applyHistorySpec(spec);
366
+ } else if (key.name === "d" && key.ctrl) {
367
+ // Ctrl+D to finish
368
+ cleanup();
369
+ resolve({ repos, cancelled: false });
370
+ }
371
+ }
372
+ };
373
+
374
+ repoInput.on("input", (value: string) => {
375
+ currentInput = value;
376
+ historyIndex = historySpecs.length;
377
+ lastMatchIndex = -1;
378
+ lastMatchQuery = value.trim().toLowerCase();
379
+ if (value.trim()) {
380
+ validateAndAddRepo(value.trim());
381
+ } else {
382
+ statusText.content = "";
383
+ }
384
+ updateMatches(value);
385
+ });
386
+
387
+ const cleanup = () => {
388
+ renderer.keyInput.off("keypress", handleKeypress);
389
+ repoInput.blur();
390
+ clearLayout(renderer);
391
+ };
392
+
393
+ renderer.keyInput.on("keypress", handleKeypress);
394
+ updateDetails();
395
+ });
396
+ }