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.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # letmecook
2
+
3
+ Multi-repo workspace manager for AI coding sessions. Clone multiple GitHub repos into a persistent session and launch opencode with an interactive TUI.
4
+
5
+ ## Features
6
+
7
+ - **Interactive TUI** - User-friendly guided experience built with [OpenTUI](https://github.com/sst/opentui)
8
+ - **Agent transparency** - See exactly what the agent plans to do before execution
9
+ - **Real-time progress** - Live updates showing cloning and preparation
10
+ - **Manual Setup Prompt** - Run custom setup commands (e.g., `npm install`) before starting
11
+ - **Session-based workflows** - Workspaces persist until you explicitly nuke them
12
+ - **AI-generated session names** - Memorable names based on repos and your goal
13
+ - **Multi-repo support** - Clone multiple repos with optional branch specification
14
+ - **AGENTS.md generation** - Auto-generated context file for AI agents
15
+ - **100% backward compatible** - All existing CLI commands continue to work
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ bun install
21
+ bun link
22
+ ```
23
+
24
+ ## Setup
25
+
26
+ Create a `.env` file with your AI Gateway API key (for session naming):
27
+
28
+ ```bash
29
+ AI_GATEWAY_API_KEY=your-key-here
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ### Interactive Mode (Recommended)
35
+
36
+ Launch the user-friendly TUI interface:
37
+
38
+ ```bash
39
+ letmecook
40
+ ```
41
+
42
+ The TUI guides you through:
43
+
44
+ 1. **Adding repositories** - Interactive repo collection
45
+ 2. **Session goal** - Describe what you want to work on
46
+ 3. **Agent proposal transparency** - See exactly what the agent plans to do
47
+ 4. **Real-time progress** - Live updates showing cloning progress
48
+ 5. **Manual Setup** - Option to run commands like `npm install` before launching
49
+ 6. **Interactive CLI** - Enter opencode with full context
50
+
51
+ ### CLI Mode (Backward Compatible)
52
+
53
+ All existing commands continue to work exactly as before:
54
+
55
+ ```bash
56
+ # Single repo
57
+ letmecook microsoft/playwright
58
+
59
+ # Multiple repos
60
+ letmecook microsoft/playwright openai/agents
61
+
62
+ # With specific branches
63
+ letmecook facebook/react:experimental vercel/next.js:canary
64
+
65
+ # Explicit TUI mode
66
+ letmecook --tui
67
+ ```
68
+
69
+ ### Manage sessions
70
+
71
+ ```bash
72
+ # List all sessions (interactive)
73
+ letmecook --list
74
+
75
+ # Resume a specific session
76
+ letmecook --resume <session-name>
77
+
78
+ # Delete a session
79
+ letmecook --nuke <session-name>
80
+
81
+ # Delete all sessions
82
+ letmecook --nuke-all
83
+ ```
84
+
85
+ ### Session persistence
86
+
87
+ When you exit opencode, you're prompted to keep or nuke the session. Sessions are kept by default - just press Enter.
88
+
89
+ To resume later:
90
+
91
+ ```bash
92
+ letmecook --resume <session-name>
93
+ ```
94
+
95
+ ## Session Structure
96
+
97
+ ```
98
+ ~/.letmecook/sessions/<session-name>/
99
+ ├── manifest.json # Session metadata
100
+ ├── AGENTS.md # Context for AI agents
101
+ ├── repo1/ # Cloned repository
102
+ └── repo2/ # Another cloned repository
103
+ ```
104
+
105
+ ## Requirements
106
+
107
+ - [Bun](https://bun.sh)
108
+ - [opencode](https://opencode.ai) - must be in PATH
109
+ - [Zig](https://ziglang.org) - required by OpenTUI
110
+ - `AI_GATEWAY_API_KEY` environment variable (for AI naming)
111
+
112
+ ## Development
113
+
114
+ ```bash
115
+ # Run directly
116
+ bun run index.ts microsoft/playwright
117
+
118
+ # Type check
119
+ bun run tsc --noEmit
120
+ ```
package/bin.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bun
2
+ // eslint-disable-next-line no-unassigned-import
3
+ import "./index.ts";
package/index.ts ADDED
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { parseRepoSpec, type RepoSpec } from "./src/types";
4
+ import { listSessions, getSession, updateLastAccessed, deleteAllSessions } from "./src/sessions";
5
+ import { createRenderer, destroyRenderer } from "./src/ui/renderer";
6
+ import { showNewSessionPrompt } from "./src/ui/new-session";
7
+ import { showSessionList } from "./src/ui/list";
8
+ import { createNewSession, resumeSession } from "./src/flows";
9
+ import { handleTUIMode } from "./src/tui-mode";
10
+
11
+ function printUsage(): void {
12
+ console.log(`
13
+ letmecook - Multi-repo workspace manager for AI coding sessions
14
+
15
+ Usage:
16
+ letmecook Launch interactive TUI (recommended)
17
+ letmecook --tui Launch interactive TUI explicitly
18
+ letmecook <owner/repo> [owner/repo:branch...] Create or resume a session (CLI)
19
+ letmecook --list List all sessions
20
+ letmecook --resume <session-name> Resume a session
21
+ letmecook --nuke <session-name> Delete a session
22
+ letmecook --nuke-all Delete all sessions
23
+ letmecook --help Show this help
24
+
25
+ Examples:
26
+ # Interactive mode (new - recommended)
27
+ letmecook
28
+
29
+ # CLI mode
30
+ letmecook microsoft/playwright
31
+ letmecook facebook/react openai/agents
32
+ letmecook --resume playwright-agent-tests
33
+ `);
34
+ }
35
+
36
+ async function handleNewSessionCLI(repos: RepoSpec[]): Promise<void> {
37
+ const renderer = await createRenderer();
38
+
39
+ try {
40
+ const { goal, cancelled } = await showNewSessionPrompt(renderer, repos);
41
+
42
+ if (cancelled) {
43
+ destroyRenderer();
44
+ console.log("\nCancelled.");
45
+ return;
46
+ }
47
+
48
+ const result = await createNewSession(renderer, {
49
+ repos,
50
+ goal,
51
+ mode: "cli",
52
+ });
53
+
54
+ if (!result) {
55
+ destroyRenderer();
56
+ console.log("\nCancelled.");
57
+ return;
58
+ }
59
+
60
+ const { session, skipped } = result;
61
+
62
+ if (skipped) {
63
+ destroyRenderer();
64
+ console.log(`\nResuming existing session: ${session.name}\n`);
65
+ await resumeSession(renderer, {
66
+ session,
67
+ mode: "cli",
68
+ initialRefresh: true,
69
+ });
70
+ return;
71
+ }
72
+
73
+ destroyRenderer();
74
+ console.log(`\nSession created: ${session.name}`);
75
+ console.log(`Path: ${session.path}\n`);
76
+
77
+ await resumeSession(renderer, {
78
+ session,
79
+ mode: "cli",
80
+ initialRefresh: false,
81
+ });
82
+ } catch (error) {
83
+ destroyRenderer();
84
+ console.error("\nError:", error instanceof Error ? error.message : error);
85
+ process.exit(1);
86
+ }
87
+ }
88
+
89
+ async function handleList(): Promise<void> {
90
+ const renderer = await createRenderer();
91
+
92
+ try {
93
+ while (true) {
94
+ const sessions = await listSessions();
95
+ const action = await showSessionList(renderer, sessions);
96
+
97
+ switch (action.type) {
98
+ case "resume":
99
+ destroyRenderer();
100
+ await updateLastAccessed(action.session.name);
101
+ console.log(`\nResuming session: ${action.session.name}\n`);
102
+ await resumeSession(renderer, {
103
+ session: action.session,
104
+ mode: "cli",
105
+ initialRefresh: true,
106
+ });
107
+ return;
108
+
109
+ case "delete":
110
+ console.log("[TODO] Delete session flow");
111
+ break;
112
+
113
+ case "nuke-all":
114
+ const count = await deleteAllSessions();
115
+ destroyRenderer();
116
+ console.log(`\nNuked ${count} session(s).`);
117
+ return;
118
+
119
+ case "quit":
120
+ destroyRenderer();
121
+ return;
122
+ }
123
+ }
124
+ } catch (error) {
125
+ destroyRenderer();
126
+ console.error("\nError:", error instanceof Error ? error.message : error);
127
+ process.exit(1);
128
+ }
129
+ }
130
+
131
+ async function handleResume(sessionName: string): Promise<void> {
132
+ const session = await getSession(sessionName);
133
+
134
+ if (!session) {
135
+ console.error(`Session not found: ${sessionName}`);
136
+ console.log("\nAvailable sessions:");
137
+ const sessions = await listSessions();
138
+ if (sessions.length === 0) {
139
+ console.log(" (none)");
140
+ } else {
141
+ sessions.forEach((s) => console.log(` - ${s.name}`));
142
+ }
143
+ process.exit(1);
144
+ }
145
+
146
+ await updateLastAccessed(session.name);
147
+ console.log(`\nResuming session: ${session.name}\n`);
148
+
149
+ const renderer = await createRenderer();
150
+ await resumeSession(renderer, {
151
+ session,
152
+ mode: "cli",
153
+ initialRefresh: true,
154
+ });
155
+ }
156
+
157
+ async function handleNuke(_sessionName: string): Promise<void> {
158
+ console.log("[TODO] Nuke session flow");
159
+ }
160
+
161
+ async function handleNukeAll(): Promise<void> {
162
+ const count = await deleteAllSessions();
163
+ console.log(`Nuked ${count} session(s).`);
164
+ }
165
+
166
+ async function handleCLIMode(args: string[]): Promise<void> {
167
+ const firstArg = args[0];
168
+
169
+ if (firstArg === "--list" || firstArg === "-l") {
170
+ await handleList();
171
+ } else if (firstArg === "--resume" || firstArg === "-r") {
172
+ const sessionName = args[1];
173
+ if (!sessionName) {
174
+ console.error("Missing session name. Usage: letmecook --resume <session-name>");
175
+ process.exit(1);
176
+ }
177
+ await handleResume(sessionName);
178
+ } else if (firstArg === "--nuke") {
179
+ const sessionName = args[1];
180
+ if (!sessionName) {
181
+ console.error("Missing session name. Usage: letmecook --nuke <session-name>");
182
+ process.exit(1);
183
+ }
184
+ await handleNuke(sessionName);
185
+ } else if (firstArg === "--nuke-all") {
186
+ await handleNukeAll();
187
+ } else if (firstArg?.startsWith("-")) {
188
+ console.error(`Unknown option: ${firstArg}`);
189
+ printUsage();
190
+ process.exit(1);
191
+ } else {
192
+ try {
193
+ const repos = parseRepos(args);
194
+ await handleNewSessionCLI(repos);
195
+ } catch (error) {
196
+ console.error("Error:", error instanceof Error ? error.message : error);
197
+ process.exit(1);
198
+ }
199
+ }
200
+ }
201
+
202
+ function parseRepos(args: string[]): RepoSpec[] {
203
+ const repos: RepoSpec[] = [];
204
+
205
+ for (const arg of args) {
206
+ if (!arg || arg.startsWith("-")) continue;
207
+
208
+ if (!arg.includes("/")) {
209
+ throw new Error(`Invalid repo format: ${arg} (expected owner/repo)`);
210
+ }
211
+
212
+ const repo = parseRepoSpec(arg);
213
+ repos.push(repo);
214
+ }
215
+
216
+ return repos;
217
+ }
218
+
219
+ console.clear();
220
+ const args = process.argv.slice(2);
221
+ const firstArg = args[0];
222
+
223
+ if (args.length === 0 || firstArg === "--help" || firstArg === "-h") {
224
+ printUsage();
225
+ process.exit(0);
226
+ }
227
+
228
+ if (firstArg === "--tui") {
229
+ await handleTUIMode();
230
+ } else if (args.length === 0) {
231
+ await handleTUIMode();
232
+ } else {
233
+ await handleCLIMode(args);
234
+ }
package/package.json CHANGED
@@ -1,11 +1,48 @@
1
1
  {
2
2
  "name": "letmecook",
3
- "version": "0.0.1",
4
- "description": "Coming soon",
3
+ "version": "0.0.4",
5
4
  "repository": {
6
5
  "type": "git",
7
- "url": "git+https://github.com/rustydotwtf/letmecook.git"
6
+ "url": "https://github.com/rustydotwtf/letmecook.git"
8
7
  },
9
- "author": "",
10
- "license": "MIT"
8
+ "bin": {
9
+ "letmecook": "./bin.js"
10
+ },
11
+ "files": [
12
+ "bin.js",
13
+ "index.ts",
14
+ "src"
15
+ ],
16
+ "type": "module",
17
+ "module": "index.ts",
18
+ "publishConfig": {
19
+ "access": "public",
20
+ "tag": "latest"
21
+ },
22
+ "scripts": {
23
+ "prepare": "husky",
24
+ "lint": "oxlint",
25
+ "lint:fix": "oxlint --fix",
26
+ "lint:ci": "oxlint --type-aware --deny-warnings --tsconfig ./tsconfig.json",
27
+ "format": "oxfmt --write .",
28
+ "format:check": "oxfmt --check .",
29
+ "check": "bun run lint && bun run format:check",
30
+ "fix": "bun run lint:fix && bun run format"
31
+ },
32
+ "dependencies": {
33
+ "@opentui/core": "^0.1.63",
34
+ "ai": "^6.0.3"
35
+ },
36
+ "devDependencies": {
37
+ "@types/bun": "latest",
38
+ "husky": "^9.1.7",
39
+ "oxfmt": "^0.26.0",
40
+ "oxlint": "^1.41.0"
41
+ },
42
+ "peerDependencies": {
43
+ "typescript": "^5"
44
+ },
45
+ "engines": {
46
+ "bun": ">=1.0.0"
47
+ }
11
48
  }
@@ -0,0 +1,115 @@
1
+ import { join } from "node:path";
2
+ import { symlink } from "node:fs/promises";
3
+ import type { Session } from "./types";
4
+
5
+ export function generateAgentsMd(session: Session): string {
6
+ const createdDate = new Date(session.created).toLocaleDateString("en-US", {
7
+ year: "numeric",
8
+ month: "long",
9
+ day: "numeric",
10
+ hour: "numeric",
11
+ minute: "2-digit",
12
+ });
13
+
14
+ const hasReadOnlyRepos = session.repos.some((repo) => repo.readOnly);
15
+ const hasLatestRepos = session.repos.some((repo) => repo.latest);
16
+ const hasSkills = session.skills && session.skills.length > 0;
17
+
18
+ const repoRows = session.repos
19
+ .map((repo) => {
20
+ const branch = repo.branch || "default";
21
+ const url = `https://github.com/${repo.owner}/${repo.name}`;
22
+ const readOnlyStatus = repo.readOnly ? "**YES**" : "no";
23
+ const latestStatus = repo.latest ? "**YES**" : "no";
24
+ return `| \`${repo.dir}/\` | [${repo.owner}/${repo.name}](${url}) | ${branch} | ${readOnlyStatus} | ${latestStatus} |`;
25
+ })
26
+ .join("\n");
27
+
28
+ const readOnlyRepos = session.repos.filter((repo) => repo.readOnly);
29
+ const latestRepos = session.repos.filter((repo) => repo.latest);
30
+
31
+ const skillsSection = hasSkills
32
+ ? `
33
+ ## 🎯 Skills
34
+
35
+ Installed skill packages (managed by bunx skills):
36
+
37
+ ${(session.skills || []).map((skill) => `- \`${skill}\``).join("\n")}
38
+
39
+ These skills are available for use in this session and are automatically updated before launching.
40
+ `
41
+ : "";
42
+
43
+ const readOnlyWarning = hasReadOnlyRepos
44
+ ? `
45
+ ## ⚠️ Read-Only Repositories
46
+
47
+ **WARNING: The following repositories are marked as READ-ONLY:**
48
+
49
+ ${readOnlyRepos.map((repo) => `- \`${repo.dir}/\` (${repo.owner}/${repo.name})`).join("\n")}
50
+
51
+ **AI agents must NOT:**
52
+ - Create, modify, or delete any files in these directories
53
+ - Make commits affecting these repositories
54
+ - Use bash commands to circumvent file permissions
55
+
56
+ **Why are these read-only?**
57
+ These repositories are included for reference only. The user wants to read and understand the code without risk of accidental modifications.
58
+ `
59
+ : "";
60
+
61
+ const latestNotice = hasLatestRepos
62
+ ? `
63
+ ## 🔄 Latest Repositories
64
+
65
+ These repositories are pinned to **Latest** and will be refreshed before resuming the session (only if clean).
66
+
67
+ ${latestRepos.map((repo) => `- \`${repo.dir}/\` (${repo.owner}/${repo.name})`).join("\n")}
68
+ `
69
+ : "";
70
+
71
+ return `# letmecook Session: ${session.name}
72
+
73
+ ${session.goal ? `> ${session.goal}\n` : ""}
74
+ ## Session Info
75
+ - **Created**: ${createdDate}
76
+
77
+ ## Repositories
78
+
79
+ | Directory | Repository | Branch | Read-Only | Latest |
80
+ |-----------|------------|--------|-----------|--------|
81
+ ${repoRows}
82
+ ${readOnlyWarning}
83
+ ${latestNotice}
84
+ ${skillsSection}
85
+ ## Important Notes
86
+
87
+ - This is a **multi-repo workspace** - each subdirectory is a separate git repository
88
+ - Make commits within individual repo directories, not from the workspace root
89
+ - This workspace root is NOT a git repository
90
+ - Your changes persist until you explicitly nuke the session
91
+
92
+ ## Resume This Session
93
+
94
+ \`\`\`bash
95
+ letmecook --resume ${session.name}
96
+ \`\`\`
97
+ `;
98
+ }
99
+
100
+ export async function writeAgentsMd(session: Session): Promise<void> {
101
+ const content = generateAgentsMd(session);
102
+ const path = join(session.path, "AGENTS.md");
103
+ await Bun.write(path, content);
104
+ }
105
+
106
+ export async function createClaudeMdSymlink(sessionPath: string): Promise<void> {
107
+ const symlinkPath = join(sessionPath, "CLAUDE.md");
108
+ try {
109
+ await symlink("AGENTS.md", symlinkPath);
110
+ } catch (error) {
111
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
112
+ console.warn(`Could not create CLAUDE.md symlink: ${error}`);
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,57 @@
1
+ import type { CliRenderer } from "@opentui/core";
2
+ import type { Session } from "../types";
3
+ import { updateSessionRepos } from "../sessions";
4
+ import { cloneAllRepos } from "../git";
5
+ import { writeAgentsMd } from "../agents-md";
6
+ import { recordRepoHistory } from "../repo-history";
7
+ import { showAddReposPrompt } from "../ui/add-repos";
8
+
9
+ export interface AddReposParams {
10
+ renderer: CliRenderer;
11
+ session: Session;
12
+ }
13
+
14
+ export interface AddReposResult {
15
+ session: Session;
16
+ cancelled: boolean;
17
+ }
18
+
19
+ export async function addReposFlow(params: AddReposParams): Promise<AddReposResult> {
20
+ const { renderer, session } = params;
21
+
22
+ const addResult = await showAddReposPrompt(renderer);
23
+
24
+ if (!addResult.cancelled && addResult.repos.length > 0) {
25
+ const existingSpecs = new Set(session.repos.map((r) => r.spec));
26
+ const newRepos = addResult.repos.filter((r) => !existingSpecs.has(r.spec));
27
+
28
+ if (newRepos.length > 0) {
29
+ console.log(`\nCloning ${newRepos.length} new repository(ies)...`);
30
+
31
+ await cloneAllRepos(newRepos, session.path, (repoIndex, status) => {
32
+ const repo = newRepos[repoIndex];
33
+ if (repo) {
34
+ if (status === "done") {
35
+ console.log(` ✓ ${repo.owner}/${repo.name}`);
36
+ } else if (status === "error") {
37
+ console.log(` ✗ ${repo.owner}/${repo.name} (failed)`);
38
+ }
39
+ }
40
+ });
41
+
42
+ const allRepos = [...session.repos, ...newRepos];
43
+ const updatedSession = await updateSessionRepos(session.name, allRepos);
44
+ const nextSession = updatedSession ?? session;
45
+
46
+ await recordRepoHistory(newRepos);
47
+ await writeAgentsMd(nextSession);
48
+
49
+ console.log("\n✅ Repositories added.\n");
50
+ return { session: nextSession, cancelled: false };
51
+ }
52
+
53
+ console.log("\nNo new repositories to add (all already in session).\n");
54
+ }
55
+
56
+ return { session, cancelled: addResult.cancelled };
57
+ }
@@ -0,0 +1,57 @@
1
+ import type { CliRenderer } from "@opentui/core";
2
+ import type { Session } from "../types";
3
+ import { updateSessionSkills } from "../sessions";
4
+ import { writeAgentsMd } from "../agents-md";
5
+ import { addSkillToSession } from "../skills";
6
+ import { showSkillsPrompt } from "../ui/skills";
7
+
8
+ export interface AddSkillsParams {
9
+ renderer: CliRenderer;
10
+ session: Session;
11
+ }
12
+
13
+ export interface AddSkillsResult {
14
+ session: Session;
15
+ cancelled: boolean;
16
+ }
17
+
18
+ export async function addSkillsFlow(params: AddSkillsParams): Promise<AddSkillsResult> {
19
+ const { renderer, session } = params;
20
+
21
+ const { skills, cancelled } = await showSkillsPrompt(renderer, session.skills || []);
22
+
23
+ if (!cancelled && skills.length > 0) {
24
+ const existingSkills = new Set(session.skills || []);
25
+ const newSkills = skills.filter((s) => !existingSkills.has(s));
26
+
27
+ if (newSkills.length > 0) {
28
+ console.log(`\nAdding ${newSkills.length} skill package(s)...`);
29
+
30
+ for (const skill of newSkills) {
31
+ console.log(` Adding ${skill}...`);
32
+ const { success } = await addSkillToSession(session, skill, (output) => {
33
+ console.log(` ${output}`);
34
+ });
35
+
36
+ if (success) {
37
+ console.log(` ✓ ${skill}`);
38
+ } else {
39
+ console.log(` ✗ ${skill} (addition failed)`);
40
+ }
41
+ }
42
+
43
+ const allSkills = [...(session.skills || []), ...newSkills];
44
+ const updatedSession = await updateSessionSkills(session.name, allSkills);
45
+
46
+ if (updatedSession) {
47
+ await writeAgentsMd(updatedSession);
48
+ console.log("\n✅ Skills added.\n");
49
+ return { session: updatedSession, cancelled: false };
50
+ }
51
+ }
52
+
53
+ console.log("\nNo new skills to add (all already in session).\n");
54
+ }
55
+
56
+ return { session, cancelled };
57
+ }