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/src/git.ts ADDED
@@ -0,0 +1,256 @@
1
+ import { join } from "node:path";
2
+ import { rm } from "node:fs/promises";
3
+ import type { RepoSpec } from "./types";
4
+ import { readProcessOutputWithBuffer } from "./utils/stream";
5
+
6
+ export interface CloneProgress {
7
+ repo: RepoSpec;
8
+ status: "pending" | "cloning" | "done" | "error";
9
+ error?: string;
10
+ outputLines?: string[]; // Last 5 lines of git output
11
+ }
12
+
13
+ export interface RefreshResult {
14
+ repo: RepoSpec;
15
+ status: "updated" | "up-to-date" | "skipped" | "error";
16
+ reason?: string;
17
+ }
18
+
19
+ export type RefreshProgressStatus = "refreshing" | "updated" | "up-to-date" | "skipped" | "error";
20
+
21
+ export async function cloneRepo(
22
+ repo: RepoSpec,
23
+ sessionPath: string,
24
+ onProgress?: (status: CloneProgress["status"], outputLines?: string[]) => void,
25
+ ): Promise<void> {
26
+ const url = `https://github.com/${repo.owner}/${repo.name}.git`;
27
+ const targetDir = join(sessionPath, repo.dir);
28
+ onProgress?.("cloning");
29
+
30
+ try {
31
+ const args = repo.branch
32
+ ? [
33
+ "git",
34
+ "clone",
35
+ "--depth",
36
+ "1",
37
+ "--single-branch",
38
+ "--branch",
39
+ repo.branch,
40
+ "--progress",
41
+ url,
42
+ targetDir,
43
+ ]
44
+ : ["git", "clone", "--depth", "1", "--single-branch", "--progress", url, targetDir];
45
+
46
+ const proc = Bun.spawn(args, {
47
+ stdout: "pipe",
48
+ stderr: "pipe",
49
+ });
50
+
51
+ // Buffer to collect output lines
52
+ const outputBuffer: string[] = [];
53
+ const MAX_LINES = 5;
54
+
55
+ // Helper to add line to buffer and notify
56
+ const addLine = (line: string) => {
57
+ const trimmed = line.trim();
58
+ if (trimmed) {
59
+ outputBuffer.push(trimmed);
60
+ if (outputBuffer.length > MAX_LINES) {
61
+ outputBuffer.shift();
62
+ }
63
+ onProgress?.("cloning", [...outputBuffer]);
64
+ }
65
+ };
66
+
67
+ // Read stderr (git clone sends progress to stderr)
68
+ const stderrReader = proc.stderr.getReader();
69
+ const decoder = new TextDecoder();
70
+ let stderrBuffer = "";
71
+
72
+ const readStderr = async () => {
73
+ while (true) {
74
+ const { done, value } = await stderrReader.read();
75
+ if (done) break;
76
+ stderrBuffer += decoder.decode(value, { stream: true });
77
+
78
+ // Process lines (split on newline or carriage return for progress updates)
79
+ const lines = stderrBuffer.split(/[\r\n]+/);
80
+ stderrBuffer = lines.pop() || "";
81
+
82
+ for (const line of lines) {
83
+ addLine(line);
84
+ }
85
+ }
86
+ // Process remaining buffer
87
+ if (stderrBuffer.trim()) {
88
+ addLine(stderrBuffer);
89
+ }
90
+ };
91
+
92
+ // Read stdout as well
93
+ const stdoutReader = proc.stdout.getReader();
94
+ let stdoutBuffer = "";
95
+
96
+ const readStdout = async () => {
97
+ while (true) {
98
+ const { done, value } = await stdoutReader.read();
99
+ if (done) break;
100
+ stdoutBuffer += decoder.decode(value, { stream: true });
101
+
102
+ const lines = stdoutBuffer.split(/[\r\n]+/);
103
+ stdoutBuffer = lines.pop() || "";
104
+
105
+ for (const line of lines) {
106
+ addLine(line);
107
+ }
108
+ }
109
+ if (stdoutBuffer.trim()) {
110
+ addLine(stdoutBuffer);
111
+ }
112
+ };
113
+
114
+ // Read both streams and wait for process
115
+ await Promise.all([readStderr(), readStdout()]);
116
+ const exitCode = await proc.exited;
117
+
118
+ if (exitCode !== 0) {
119
+ onProgress?.("error", [...outputBuffer]);
120
+ throw new Error(`git clone exited with code ${exitCode}`);
121
+ }
122
+
123
+ onProgress?.("done", [...outputBuffer]);
124
+ } catch (error) {
125
+ onProgress?.("error");
126
+ throw new Error(
127
+ `Failed to clone ${repo.owner}/${repo.name}: ${
128
+ error instanceof Error ? error.message : String(error)
129
+ }`,
130
+ { cause: error },
131
+ );
132
+ }
133
+ }
134
+
135
+ export async function recloneRepo(
136
+ repo: RepoSpec,
137
+ sessionPath: string,
138
+ onProgress?: (status: CloneProgress["status"], outputLines?: string[]) => void,
139
+ ): Promise<void> {
140
+ const repoPath = join(sessionPath, repo.dir);
141
+
142
+ try {
143
+ await rm(repoPath, { recursive: true, force: true });
144
+ await cloneRepo(repo, sessionPath, onProgress);
145
+ } catch (error) {
146
+ onProgress?.("error");
147
+ throw new Error(
148
+ `Failed to reclone ${repo.owner}/${repo.name}: ${
149
+ error instanceof Error ? error.message : String(error)
150
+ }`,
151
+ { cause: error },
152
+ );
153
+ }
154
+ }
155
+
156
+ export async function cloneAllRepos(
157
+ repos: RepoSpec[],
158
+ sessionPath: string,
159
+ onProgress?: (repoIndex: number, status: CloneProgress["status"], outputLines?: string[]) => void,
160
+ ): Promise<void> {
161
+ // Clone repos in parallel
162
+ await Promise.all(
163
+ repos.map(async (repo, index) => {
164
+ await cloneRepo(repo, sessionPath, (status, outputLines) => {
165
+ onProgress?.(index, status, outputLines);
166
+ });
167
+ }),
168
+ );
169
+ }
170
+
171
+ export async function hasUncommittedChanges(repoPath: string): Promise<boolean> {
172
+ const proc = Bun.spawn(["git", "status", "--porcelain"], {
173
+ cwd: repoPath,
174
+ stdout: "pipe",
175
+ stderr: "pipe",
176
+ });
177
+ const output = await new Response(proc.stdout).text();
178
+ await proc.exited;
179
+ return output.trim().length > 0;
180
+ }
181
+
182
+ export async function sessionHasUncommittedChanges(
183
+ repos: RepoSpec[],
184
+ sessionPath: string,
185
+ ): Promise<{ hasChanges: boolean; reposWithChanges: RepoSpec[] }> {
186
+ const results = await Promise.all(
187
+ repos.map(async (repo) => ({
188
+ repo,
189
+ hasChanges: await hasUncommittedChanges(join(sessionPath, repo.dir)),
190
+ })),
191
+ );
192
+ const reposWithChanges = results.filter((r) => r.hasChanges).map((r) => r.repo);
193
+ return { hasChanges: reposWithChanges.length > 0, reposWithChanges };
194
+ }
195
+
196
+ export async function refreshLatestRepos(
197
+ repos: RepoSpec[],
198
+ sessionPath: string,
199
+ onProgress?: (repoIndex: number, status: RefreshProgressStatus, outputLines?: string[]) => void,
200
+ ): Promise<RefreshResult[]> {
201
+ const latestRepos = repos.filter((repo) => repo.latest);
202
+ if (latestRepos.length === 0) return [];
203
+
204
+ const results: RefreshResult[] = [];
205
+
206
+ for (const [repoIndex, repo] of latestRepos.entries()) {
207
+ const repoPath = join(sessionPath, repo.dir);
208
+ const dirty = await hasUncommittedChanges(repoPath);
209
+
210
+ if (dirty) {
211
+ results.push({
212
+ repo,
213
+ status: "skipped",
214
+ reason: "uncommitted changes",
215
+ });
216
+ onProgress?.(repoIndex, "skipped", ["Skipped: uncommitted changes"]);
217
+ continue;
218
+ }
219
+
220
+ onProgress?.(repoIndex, "refreshing", [`Pulling ${repo.owner}/${repo.name}...`]);
221
+
222
+ const proc = Bun.spawn(["git", "-C", repoPath, "pull", "--ff-only", "--depth", "1"], {
223
+ stdout: "pipe",
224
+ stderr: "pipe",
225
+ });
226
+
227
+ const { success, output, fullOutput } = await readProcessOutputWithBuffer(proc, {
228
+ maxBufferLines: 5,
229
+ onBufferUpdate: (buffer) => onProgress?.(repoIndex, "refreshing", buffer),
230
+ });
231
+ const exitCode = success ? 0 : 1;
232
+
233
+ if (exitCode !== 0) {
234
+ const reason = fullOutput.trim() || `git pull exited with code ${exitCode}`;
235
+ results.push({
236
+ repo,
237
+ status: "error",
238
+ reason,
239
+ });
240
+ onProgress?.(repoIndex, "error", output.length > 0 ? [...output] : [reason]);
241
+ continue;
242
+ }
243
+
244
+ const normalized = fullOutput.toLowerCase();
245
+ const upToDate =
246
+ normalized.includes("already up to date") || normalized.includes("already up-to-date");
247
+
248
+ results.push({
249
+ repo,
250
+ status: upToDate ? "up-to-date" : "updated",
251
+ });
252
+ onProgress?.(repoIndex, upToDate ? "up-to-date" : "updated", [...output]);
253
+ }
254
+
255
+ return results;
256
+ }
package/src/naming.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { generateText } from "ai";
2
+ import type { RepoSpec } from "./types";
3
+ import { generateUniqueName } from "./sessions";
4
+
5
+ export async function generateSessionName(repos: RepoSpec[], goal?: string): Promise<string> {
6
+ const repoList = repos.map((r) => `${r.owner}/${r.name}`).join(", ");
7
+
8
+ try {
9
+ const { text } = await generateText({
10
+ model: "xai/grok-4-fast-non-reasoning",
11
+ temperature: 1.2, // Higher temperature for more creative/varied names
12
+ prompt: `Generate a creative, memorable session name for a coding workspace.
13
+
14
+ Repos: ${repoList}
15
+ ${goal ? `Goal: ${goal}` : "No specific goal provided."}
16
+
17
+ Requirements:
18
+ - Max 24 characters total
19
+ - Lowercase kebab-case only (e.g., "test-bot-fusion", "react-dash")
20
+ - Creative and memorable - don't just mash repo names together
21
+ - Should capture the essence of the goal if provided
22
+ - No special characters except hyphens
23
+ - No numbers unless they add meaning
24
+
25
+ Reply with ONLY the name, nothing else. No quotes, no explanation.`,
26
+ });
27
+
28
+ // Clean up the response
29
+ let name = text
30
+ .trim()
31
+ .toLowerCase()
32
+ .replace(/[^a-z0-9-]/g, "")
33
+ .replace(/--+/g, "-")
34
+ .replace(/^-|-$/g, "")
35
+ .slice(0, 24);
36
+
37
+ // Fallback if AI returns garbage
38
+ if (!name || name.length < 3) {
39
+ name = generateFallbackName(repos);
40
+ }
41
+
42
+ // Ensure uniqueness
43
+ return generateUniqueName(name);
44
+ } catch (error) {
45
+ // If AI fails, use fallback
46
+ console.error("AI naming failed, using fallback:", error);
47
+ const fallback = generateFallbackName(repos);
48
+ return generateUniqueName(fallback);
49
+ }
50
+ }
51
+
52
+ function generateFallbackName(repos: RepoSpec[]): string {
53
+ // Simple fallback: first repo name + short hash
54
+ const base = repos[0]?.name || "session";
55
+ const hash = Math.random().toString(36).slice(2, 6);
56
+ return `${base}-${hash}`.slice(0, 24);
57
+ }
@@ -0,0 +1,20 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export async function runInteractiveOpencode(sessionPath: string): Promise<void> {
4
+ return new Promise((resolve) => {
5
+ const proc = spawn("claude", ["--dangerously-skip-permissions"], {
6
+ cwd: sessionPath,
7
+ stdio: "inherit",
8
+ env: { ...process.env, IS_DEMO: "1" },
9
+ });
10
+
11
+ proc.on("close", () => {
12
+ resolve();
13
+ });
14
+
15
+ proc.on("error", (error) => {
16
+ console.error(`Failed to start claude: ${error.message}`);
17
+ resolve();
18
+ });
19
+ });
20
+ }
@@ -0,0 +1,82 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { mkdir } from "node:fs/promises";
5
+ import type { RepoSpec } from "./types";
6
+
7
+ const DATA_DIR = join(homedir(), ".letmecook");
8
+ const DB_PATH = join(DATA_DIR, "history.sqlite");
9
+
10
+ let db: Database | null = null;
11
+
12
+ export interface RepoHistoryItem {
13
+ spec: string;
14
+ owner: string;
15
+ name: string;
16
+ branch: string | null;
17
+ lastUsed: string;
18
+ timesUsed: number;
19
+ }
20
+
21
+ async function getDb(): Promise<Database> {
22
+ if (db) return db;
23
+
24
+ await mkdir(DATA_DIR, { recursive: true });
25
+ db = new Database(DB_PATH, { create: true });
26
+ db.exec(`
27
+ create table if not exists repo_history (
28
+ spec text primary key,
29
+ owner text not null,
30
+ name text not null,
31
+ branch text,
32
+ last_used text not null,
33
+ times_used integer not null default 1
34
+ );
35
+ `);
36
+ db.exec("create index if not exists idx_repo_history_last_used on repo_history(last_used);");
37
+
38
+ return db;
39
+ }
40
+
41
+ export async function listRepoHistory(limit = 50): Promise<RepoHistoryItem[]> {
42
+ const database = await getDb();
43
+ const stmt = database.prepare(`
44
+ select
45
+ spec,
46
+ owner,
47
+ name,
48
+ branch,
49
+ last_used as lastUsed,
50
+ times_used as timesUsed
51
+ from repo_history
52
+ order by last_used desc
53
+ limit ?
54
+ `);
55
+
56
+ return stmt.all(limit) as RepoHistoryItem[];
57
+ }
58
+
59
+ export async function recordRepoHistory(repos: RepoSpec[]): Promise<void> {
60
+ if (repos.length === 0) return;
61
+
62
+ const database = await getDb();
63
+ const now = new Date().toISOString();
64
+ const stmt = database.prepare(`
65
+ insert into repo_history (spec, owner, name, branch, last_used, times_used)
66
+ values (?, ?, ?, ?, ?, 1)
67
+ on conflict(spec) do update set
68
+ owner = excluded.owner,
69
+ name = excluded.name,
70
+ branch = excluded.branch,
71
+ last_used = excluded.last_used,
72
+ times_used = times_used + 1
73
+ `);
74
+
75
+ const insertMany = database.transaction((items: RepoSpec[]) => {
76
+ for (const repo of items) {
77
+ stmt.run(repo.spec, repo.owner, repo.name, repo.branch ?? null, now);
78
+ }
79
+ });
80
+
81
+ insertMany(repos);
82
+ }
@@ -0,0 +1,217 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { mkdir, readdir, rm } from "node:fs/promises";
4
+ import type { Session, SessionManifest, RepoSpec } from "./types";
5
+ import { repoSpecsMatch } from "./types";
6
+
7
+ const LETMECOOK_DIR = join(homedir(), ".letmecook");
8
+ const SESSIONS_DIR = join(LETMECOOK_DIR, "sessions");
9
+
10
+ export async function ensureSessionsDir(): Promise<void> {
11
+ await mkdir(SESSIONS_DIR, { recursive: true });
12
+ }
13
+
14
+ export async function getSessionPath(name: string): Promise<string> {
15
+ return join(SESSIONS_DIR, name);
16
+ }
17
+
18
+ export async function listSessions(): Promise<Session[]> {
19
+ await ensureSessionsDir();
20
+
21
+ const entries = await readdir(SESSIONS_DIR, { withFileTypes: true });
22
+ const sessions: Session[] = [];
23
+
24
+ for (const entry of entries) {
25
+ if (!entry.isDirectory()) continue;
26
+
27
+ const sessionPath = join(SESSIONS_DIR, entry.name);
28
+ const manifestPath = join(sessionPath, "manifest.json");
29
+
30
+ try {
31
+ const manifestFile = Bun.file(manifestPath);
32
+ if (await manifestFile.exists()) {
33
+ const manifest: SessionManifest = await manifestFile.json();
34
+ sessions.push({
35
+ ...manifest,
36
+ path: sessionPath,
37
+ });
38
+ }
39
+ } catch {
40
+ // Skip invalid sessions
41
+ }
42
+ }
43
+
44
+ // Sort by lastAccessed, most recent first
45
+ sessions.sort((a, b) => new Date(b.lastAccessed).getTime() - new Date(a.lastAccessed).getTime());
46
+
47
+ return sessions;
48
+ }
49
+
50
+ export async function getSession(name: string): Promise<Session | null> {
51
+ const sessionPath = join(SESSIONS_DIR, name);
52
+ const manifestPath = join(sessionPath, "manifest.json");
53
+
54
+ try {
55
+ const manifestFile = Bun.file(manifestPath);
56
+ if (await manifestFile.exists()) {
57
+ const manifest: SessionManifest = await manifestFile.json();
58
+ return {
59
+ ...manifest,
60
+ path: sessionPath,
61
+ };
62
+ }
63
+ } catch {
64
+ // Session doesn't exist or is invalid
65
+ }
66
+
67
+ return null;
68
+ }
69
+
70
+ export async function findMatchingSession(repos: RepoSpec[]): Promise<Session | null> {
71
+ const sessions = await listSessions();
72
+
73
+ for (const session of sessions) {
74
+ if (repoSpecsMatch(session.repos, repos)) {
75
+ return session;
76
+ }
77
+ }
78
+
79
+ return null;
80
+ }
81
+
82
+ export async function createSession(
83
+ name: string,
84
+ repos: RepoSpec[],
85
+ goal?: string,
86
+ skills?: string[],
87
+ ): Promise<Session> {
88
+ await ensureSessionsDir();
89
+
90
+ const sessionPath = join(SESSIONS_DIR, name);
91
+ await mkdir(sessionPath, { recursive: true });
92
+
93
+ const now = new Date().toISOString();
94
+ const manifest: SessionManifest = {
95
+ name,
96
+ repos,
97
+ goal,
98
+ skills,
99
+ created: now,
100
+ lastAccessed: now,
101
+ };
102
+
103
+ const manifestPath = join(sessionPath, "manifest.json");
104
+ await Bun.write(manifestPath, JSON.stringify(manifest, null, 2));
105
+
106
+ return {
107
+ ...manifest,
108
+ path: sessionPath,
109
+ };
110
+ }
111
+
112
+ export async function updateLastAccessed(name: string): Promise<void> {
113
+ const session = await getSession(name);
114
+ if (!session) return;
115
+
116
+ const manifest: SessionManifest = {
117
+ name: session.name,
118
+ repos: session.repos,
119
+ goal: session.goal,
120
+ created: session.created,
121
+ lastAccessed: new Date().toISOString(),
122
+ };
123
+
124
+ const manifestPath = join(session.path, "manifest.json");
125
+ await Bun.write(manifestPath, JSON.stringify(manifest, null, 2));
126
+ }
127
+
128
+ export async function updateSessionRepos(name: string, repos: RepoSpec[]): Promise<Session | null> {
129
+ const session = await getSession(name);
130
+ if (!session) return null;
131
+
132
+ const manifest: SessionManifest = {
133
+ name: session.name,
134
+ repos,
135
+ goal: session.goal,
136
+ created: session.created,
137
+ lastAccessed: new Date().toISOString(),
138
+ };
139
+
140
+ const manifestPath = join(session.path, "manifest.json");
141
+ await Bun.write(manifestPath, JSON.stringify(manifest, null, 2));
142
+
143
+ return {
144
+ ...manifest,
145
+ path: session.path,
146
+ };
147
+ }
148
+
149
+ export async function updateSessionSettings(
150
+ name: string,
151
+ settings: { repos?: RepoSpec[]; goal?: string; skills?: string[] },
152
+ ): Promise<Session | null> {
153
+ const session = await getSession(name);
154
+ if (!session) return null;
155
+
156
+ const manifest: SessionManifest = {
157
+ name: session.name,
158
+ repos: settings.repos ?? session.repos,
159
+ goal: settings.goal ?? session.goal,
160
+ skills: settings.skills ?? session.skills,
161
+ created: session.created,
162
+ lastAccessed: new Date().toISOString(),
163
+ };
164
+
165
+ const manifestPath = join(session.path, "manifest.json");
166
+ await Bun.write(manifestPath, JSON.stringify(manifest, null, 2));
167
+
168
+ return {
169
+ ...manifest,
170
+ path: session.path,
171
+ };
172
+ }
173
+
174
+ export async function updateSessionSkills(name: string, skills: string[]): Promise<Session | null> {
175
+ return updateSessionSettings(name, { skills });
176
+ }
177
+
178
+ export async function deleteSession(name: string): Promise<boolean> {
179
+ const sessionPath = join(SESSIONS_DIR, name);
180
+
181
+ try {
182
+ await rm(sessionPath, { recursive: true, force: true });
183
+ return true;
184
+ } catch {
185
+ return false;
186
+ }
187
+ }
188
+
189
+ export async function deleteAllSessions(): Promise<number> {
190
+ const sessions = await listSessions();
191
+ let deleted = 0;
192
+
193
+ for (const session of sessions) {
194
+ if (await deleteSession(session.name)) {
195
+ deleted++;
196
+ }
197
+ }
198
+
199
+ return deleted;
200
+ }
201
+
202
+ export async function sessionExists(name: string): Promise<boolean> {
203
+ const session = await getSession(name);
204
+ return session !== null;
205
+ }
206
+
207
+ export async function generateUniqueName(baseName: string): Promise<string> {
208
+ let name = baseName;
209
+ let counter = 2;
210
+
211
+ while (await sessionExists(name)) {
212
+ name = `${baseName}-${counter}`;
213
+ counter++;
214
+ }
215
+
216
+ return name;
217
+ }
package/src/skills.ts ADDED
@@ -0,0 +1,49 @@
1
+ import type { Session } from "./types";
2
+ import { updateSessionSkills } from "./sessions";
3
+ import { writeAgentsMd } from "./agents-md";
4
+ import { readProcessOutput } from "./utils/stream";
5
+
6
+ export async function updateSkills(
7
+ session: Session,
8
+ onProgress?: (output: string) => void,
9
+ ): Promise<{ success: boolean; output: string[] }> {
10
+ if (!session.skills || session.skills.length === 0) {
11
+ return { success: true, output: [] };
12
+ }
13
+
14
+ const proc = Bun.spawn(["bunx", "skills", "update", "-y"], {
15
+ cwd: session.path,
16
+ stdout: "pipe",
17
+ stderr: "pipe",
18
+ });
19
+
20
+ return readProcessOutput(proc, onProgress);
21
+ }
22
+
23
+ export async function addSkillToSession(
24
+ session: Session,
25
+ skillString: string,
26
+ onProgress?: (output: string) => void,
27
+ ): Promise<{ success: boolean; output: string[] }> {
28
+ const proc = Bun.spawn(["bunx", "skills", "add", skillString, "-y"], {
29
+ cwd: session.path,
30
+ stdout: "pipe",
31
+ stderr: "pipe",
32
+ });
33
+
34
+ return readProcessOutput(proc, onProgress);
35
+ }
36
+
37
+ export async function removeSkillFromSession(
38
+ session: Session,
39
+ skillString: string,
40
+ ): Promise<Session> {
41
+ const updatedSkills = (session.skills || []).filter((s) => s !== skillString);
42
+ const updatedSession = await updateSessionSkills(session.name, updatedSkills);
43
+
44
+ if (updatedSession) {
45
+ await writeAgentsMd(updatedSession);
46
+ }
47
+
48
+ return updatedSession || session;
49
+ }