zdev 0.2.2 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zdev",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Multi-agent worktree development environment for cloud dev with preview URLs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,7 +5,28 @@ import {
5
5
  ZEBU_HOME,
6
6
  WORKTREES_DIR,
7
7
  } from "../config.js";
8
- import { isProcessRunning, getTraefikStatus } from "../utils.js";
8
+ import { isProcessRunning, getTraefikStatus, run } from "../utils.js";
9
+
10
+ // Get tmux sessions matching a pattern
11
+ function getTmuxSessions(pattern: string): string[] {
12
+ const socketDir = process.env.CLAWDBOT_TMUX_SOCKET_DIR || "/tmp/clawdbot-tmux-sockets";
13
+ const socket = `${socketDir}/clawdbot.sock`;
14
+
15
+ // Try clawdbot socket first
16
+ let result = run("tmux", ["-S", socket, "list-sessions", "-F", "#{session_name}"]);
17
+
18
+ if (!result.success) {
19
+ // Fall back to default tmux socket
20
+ result = run("tmux", ["list-sessions", "-F", "#{session_name}"]);
21
+ }
22
+
23
+ if (!result.success) return [];
24
+
25
+ return result.stdout
26
+ .split("\n")
27
+ .filter(Boolean)
28
+ .filter(name => name.toLowerCase().includes(pattern.toLowerCase()));
29
+ }
9
30
 
10
31
  export interface ListOptions {
11
32
  json?: boolean;
@@ -52,8 +73,15 @@ export async function list(options: ListOptions = {}): Promise<void> {
52
73
  ? isProcessRunning(alloc.pids.convex)
53
74
  : false;
54
75
 
55
- const statusEmoji = frontendRunning && convexRunning ? "🟢" :
56
- frontendRunning || convexRunning ? "🟔" : "šŸ”“";
76
+ // Check for tmux sessions related to this feature
77
+ const featureSlug = name.toLowerCase().replace(/[^a-z0-9]/g, "-");
78
+ const tmuxSessions = getTmuxSessions(featureSlug);
79
+ const hasTmux = tmuxSessions.length > 0;
80
+
81
+ const isRunning = frontendRunning || convexRunning || hasTmux;
82
+ const isFullyRunning = (frontendRunning && convexRunning) || (hasTmux && tmuxSessions.length >= 2);
83
+
84
+ const statusEmoji = isFullyRunning ? "🟢" : isRunning ? "🟔" : "šŸ”“";
57
85
 
58
86
  console.log(`${statusEmoji} ${name}`);
59
87
  console.log(` Project: ${alloc.project}`);
@@ -65,8 +93,19 @@ export async function list(options: ListOptions = {}): Promise<void> {
65
93
  console.log(` Public: https://${alloc.funnelPath}.${traefikStatus.devDomain}`);
66
94
  }
67
95
 
68
- console.log(` Frontend: ${frontendRunning ? `running (PID: ${alloc.pids.frontend})` : "stopped"}`);
69
- console.log(` Convex: ${convexRunning ? `running (PID: ${alloc.pids.convex})` : "stopped"}`);
96
+ // Show PID-based status
97
+ if (alloc.pids.frontend || alloc.pids.convex) {
98
+ console.log(` Frontend: ${frontendRunning ? `running (PID: ${alloc.pids.frontend})` : "stopped"}`);
99
+ console.log(` Convex: ${convexRunning ? `running (PID: ${alloc.pids.convex})` : "stopped"}`);
100
+ }
101
+
102
+ // Show tmux sessions if any
103
+ if (hasTmux) {
104
+ console.log(` Tmux: ${tmuxSessions.join(", ")}`);
105
+ } else if (!frontendRunning && !convexRunning) {
106
+ console.log(` Servers: stopped`);
107
+ }
108
+
70
109
  console.log(` Started: ${new Date(alloc.started).toLocaleString()}`);
71
110
  console.log();
72
111
  }
@@ -8,6 +8,123 @@ export interface PrOptions {
8
8
  body?: string;
9
9
  draft?: boolean;
10
10
  web?: boolean;
11
+ ai?: boolean; // Use AI (Claude) to generate title/body
12
+ }
13
+
14
+ /**
15
+ * Use Claude to generate PR title and description from diff
16
+ */
17
+ function generateWithAI(diff: string, commits: string[], worktreePath: string): { title: string; body: string } | null {
18
+ // Check if claude CLI is available
19
+ const claudeCheck = run("which", ["claude"]);
20
+ if (!claudeCheck.success) {
21
+ return null;
22
+ }
23
+
24
+ const prompt = `You are generating a GitHub PR title and description.
25
+
26
+ Based on the following git diff and commit messages, generate:
27
+ 1. A concise PR title (max 72 chars, no quotes)
28
+ 2. A brief description of the changes (2-4 bullet points)
29
+
30
+ Commit messages:
31
+ ${commits.map(c => `- ${c}`).join("\n")}
32
+
33
+ Diff summary (first 3000 chars):
34
+ ${diff.slice(0, 3000)}
35
+
36
+ Respond in this exact format:
37
+ TITLE: <your title here>
38
+ BODY:
39
+ - <bullet point 1>
40
+ - <bullet point 2>
41
+ - <bullet point 3>`;
42
+
43
+ const result = run("claude", ["-p", prompt, "--no-input"], {
44
+ cwd: worktreePath,
45
+ env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: "zdev" }
46
+ });
47
+
48
+ if (!result.success || !result.stdout) {
49
+ return null;
50
+ }
51
+
52
+ const output = result.stdout.trim();
53
+ const titleMatch = output.match(/TITLE:\s*(.+)/);
54
+ const bodyMatch = output.match(/BODY:\s*([\s\S]+)/);
55
+
56
+ if (!titleMatch) {
57
+ return null;
58
+ }
59
+
60
+ return {
61
+ title: titleMatch[1].trim().replace(/^["']|["']$/g, ""),
62
+ body: bodyMatch ? bodyMatch[1].trim() : "",
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Generate a smart PR title based on changed files and commits
68
+ */
69
+ function generateSmartTitle(files: string[], commits: string[], featureName: string): string {
70
+ const components = new Set<string>();
71
+ const areas = new Set<string>();
72
+
73
+ for (const file of files) {
74
+ if (!file.match(/\.(tsx?|jsx?|css|scss)$/)) continue;
75
+
76
+ const componentMatch = file.match(/components\/([^/]+)\/([^/]+)\.(tsx?|jsx?)$/);
77
+ if (componentMatch) {
78
+ components.add(componentMatch[2].replace(/\.(tsx?|jsx?)$/, ""));
79
+ continue;
80
+ }
81
+
82
+ const singleComponent = file.match(/components\/([^/]+)\.(tsx?|jsx?)$/);
83
+ if (singleComponent) {
84
+ components.add(singleComponent[1]);
85
+ continue;
86
+ }
87
+
88
+ const routeMatch = file.match(/routes\/(.+)\.(tsx?|jsx?)$/);
89
+ if (routeMatch) {
90
+ const routeName = routeMatch[1].replace(/[[\]$_.]/g, " ").trim();
91
+ if (routeName && routeName !== "index") {
92
+ areas.add(routeName);
93
+ }
94
+ continue;
95
+ }
96
+
97
+ const pathParts = file.split("/");
98
+ if (pathParts.length > 1) {
99
+ const folder = pathParts[pathParts.length - 2];
100
+ if (!["src", "web", "app", "lib", "utils"].includes(folder)) {
101
+ areas.add(folder);
102
+ }
103
+ }
104
+ }
105
+
106
+ const items = [...components, ...areas].slice(0, 3);
107
+
108
+ if (items.length > 0) {
109
+ let action = "Update";
110
+ const commitText = commits.join(" ").toLowerCase();
111
+ if (commitText.includes("fix")) action = "Fix";
112
+ else if (commitText.includes("add") || commitText.includes("new")) action = "Add";
113
+ else if (commitText.includes("refactor")) action = "Refactor";
114
+ else if (commitText.includes("improve") || commitText.includes("enhance")) action = "Improve";
115
+ else if (commitText.includes("mobile") || commitText.includes("responsive")) action = "Improve";
116
+
117
+ return `${action} ${items.join(", ")}`;
118
+ }
119
+
120
+ if (commits.length > 0 && commits[0].length < 72) {
121
+ return commits[0];
122
+ }
123
+
124
+ return featureName
125
+ .split(/[-_]/)
126
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
127
+ .join(" ");
11
128
  }
12
129
 
13
130
  export async function pr(
@@ -17,22 +134,20 @@ export async function pr(
17
134
  ): Promise<void> {
18
135
  const fullPath = resolve(projectPath);
19
136
 
20
- // Check if we're in a worktree or need to find one
21
137
  let worktreePath = fullPath;
22
138
  let allocation: WorktreeAllocation | undefined;
23
139
  let projectName = getRepoName(fullPath) || basename(fullPath);
24
140
 
25
141
  const config = loadConfig();
26
142
 
27
- // If featureName provided, find the allocation
143
+ // Find allocation if featureName provided
28
144
  if (featureName) {
29
145
  const allocKey = `${projectName}-${featureName}`;
30
146
  allocation = config.allocations[allocKey];
31
147
 
32
148
  if (!allocation) {
33
- // Try to find by just feature name
34
149
  const found = Object.entries(config.allocations).find(
35
- ([key, alloc]) => key.endsWith(`-${featureName}`)
150
+ ([key]) => key.endsWith(`-${featureName}`)
36
151
  );
37
152
  if (found) {
38
153
  allocation = found[1];
@@ -44,7 +159,7 @@ export async function pr(
44
159
  worktreePath = allocation.worktreePath || resolve(config.worktreesDir, `${projectName}-${featureName}`);
45
160
  }
46
161
  } else {
47
- // Try to detect from current directory
162
+ // Detect from current directory
48
163
  const cwd = process.cwd();
49
164
  const found = Object.entries(config.allocations).find(
50
165
  ([_, alloc]) => alloc.worktreePath === cwd || cwd.startsWith(alloc.worktreePath || "")
@@ -76,149 +191,82 @@ export async function pr(
76
191
  console.log(` Feature: ${featureName}`);
77
192
  }
78
193
 
79
- // Check if gh CLI is available
194
+ // Check gh CLI
80
195
  const ghCheck = run("which", ["gh"]);
81
196
  if (!ghCheck.success) {
82
197
  console.error("āŒ GitHub CLI (gh) not found. Install: https://cli.github.com");
83
198
  process.exit(1);
84
199
  }
85
200
 
86
- // Check if authenticated
87
201
  const authCheck = run("gh", ["auth", "status"], { cwd: worktreePath });
88
202
  if (!authCheck.success) {
89
203
  console.error("āŒ Not authenticated with GitHub. Run: gh auth login");
90
204
  process.exit(1);
91
205
  }
92
206
 
93
- // Push branch if not pushed
207
+ // Push branch
94
208
  console.log(`\nšŸ“¤ Pushing branch...`);
95
209
  const pushResult = run("git", ["push", "-u", "origin", branch], { cwd: worktreePath });
96
- if (!pushResult.success) {
97
- // Check if it's just "already up to date"
98
- if (!pushResult.stderr.includes("Everything up-to-date")) {
99
- console.error(` Failed to push: ${pushResult.stderr}`);
100
- process.exit(1);
101
- }
102
- console.log(` Already up to date`);
103
- } else {
104
- console.log(` Pushed to origin/${branch}`);
210
+ if (!pushResult.success && !pushResult.stderr.includes("Everything up-to-date")) {
211
+ console.error(` Failed to push: ${pushResult.stderr}`);
212
+ process.exit(1);
105
213
  }
214
+ console.log(` Pushed to origin/${branch}`);
106
215
 
107
- // Get base branch for comparison
216
+ // Get base branch
108
217
  const defaultBranch = run("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], { cwd: worktreePath });
109
218
  const baseBranch = defaultBranch.success ? defaultBranch.stdout.trim().replace("origin/", "") : "main";
110
219
 
111
- // Get commits since base branch
220
+ // Get commits and diff info
112
221
  const commitsResult = run("git", ["log", `origin/${baseBranch}..HEAD`, "--pretty=format:%s"], { cwd: worktreePath });
113
222
  const commits = commitsResult.success ? commitsResult.stdout.trim().split("\n").filter(Boolean) : [];
114
223
 
115
- // Get files changed
116
- const filesResult = run("git", ["diff", `origin/${baseBranch}..HEAD`, "--stat", "--stat-width=60"], { cwd: worktreePath });
117
- const filesSummary = filesResult.success ? filesResult.stdout.trim() : "";
224
+ const diffResult = run("git", ["diff", `origin/${baseBranch}..HEAD`], { cwd: worktreePath });
225
+ const diff = diffResult.success ? diffResult.stdout : "";
118
226
 
119
- // Get short diff summary
120
227
  const diffStatResult = run("git", ["diff", `origin/${baseBranch}..HEAD`, "--shortstat"], { cwd: worktreePath });
121
228
  const diffStat = diffStatResult.success ? diffStatResult.stdout.trim() : "";
122
229
 
123
- // Get changed files for smart title generation
124
230
  const changedFilesResult = run("git", ["diff", `origin/${baseBranch}..HEAD`, "--name-only"], { cwd: worktreePath });
125
231
  const changedFiles = changedFilesResult.success ? changedFilesResult.stdout.trim().split("\n").filter(Boolean) : [];
126
232
 
127
- // Build PR title
233
+ // Generate title and body
128
234
  let title = options.title;
129
- if (!title) {
130
- // Try to generate smart title from changed files
131
- title = generateSmartTitle(changedFiles, commits, featureName || branch.replace(/^feature\//, ""));
132
- }
133
- }
235
+ let aiBody = "";
134
236
 
135
- /**
136
- * Generate a smart PR title based on changed files and commits
137
- */
138
- function generateSmartTitle(files: string[], commits: string[], featureName: string): string {
139
- // Extract meaningful names from file paths
140
- const components = new Set<string>();
141
- const areas = new Set<string>();
142
-
143
- for (const file of files) {
144
- // Skip non-code files
145
- if (!file.match(/\.(tsx?|jsx?|css|scss)$/)) continue;
146
-
147
- // Extract component names from paths like src/components/OrderCard.tsx
148
- const componentMatch = file.match(/components\/([^/]+)\/([^/]+)\.(tsx?|jsx?)$/);
149
- if (componentMatch) {
150
- components.add(componentMatch[2].replace(/\.(tsx?|jsx?)$/, ""));
151
- continue;
152
- }
153
-
154
- // Single component file
155
- const singleComponent = file.match(/components\/([^/]+)\.(tsx?|jsx?)$/);
156
- if (singleComponent) {
157
- components.add(singleComponent[1]);
158
- continue;
159
- }
160
-
161
- // Routes
162
- const routeMatch = file.match(/routes\/(.+)\.(tsx?|jsx?)$/);
163
- if (routeMatch) {
164
- const routeName = routeMatch[1].replace(/[[\]$_.]/g, " ").trim();
165
- if (routeName && routeName !== "index") {
166
- areas.add(routeName);
167
- }
168
- continue;
237
+ // Try AI generation if --ai flag or no title provided
238
+ if (options.ai || (!title && diff.length > 0)) {
239
+ console.log(`\nšŸ¤– Generating PR content with AI...`);
240
+ const aiResult = generateWithAI(diff, commits, worktreePath);
241
+ if (aiResult) {
242
+ if (!title) title = aiResult.title;
243
+ aiBody = aiResult.body;
244
+ console.log(` Generated title: ${title}`);
245
+ } else if (options.ai) {
246
+ console.log(` AI generation failed, using smart fallback`);
169
247
  }
170
-
171
- // Other meaningful paths
172
- const pathParts = file.split("/");
173
- if (pathParts.length > 1) {
174
- const folder = pathParts[pathParts.length - 2];
175
- if (!["src", "web", "app", "lib", "utils"].includes(folder)) {
176
- areas.add(folder);
177
- }
178
- }
179
- }
180
-
181
- // Build title from components/areas
182
- const items = [...components, ...areas].slice(0, 3);
183
-
184
- if (items.length > 0) {
185
- // Determine action from commits
186
- let action = "Update";
187
- const commitText = commits.join(" ").toLowerCase();
188
- if (commitText.includes("fix")) action = "Fix";
189
- else if (commitText.includes("add") || commitText.includes("new")) action = "Add";
190
- else if (commitText.includes("refactor")) action = "Refactor";
191
- else if (commitText.includes("improve") || commitText.includes("enhance")) action = "Improve";
192
- else if (commitText.includes("mobile") || commitText.includes("responsive")) action = "Improve";
193
-
194
- return `${action} ${items.join(", ")}`;
195
248
  }
196
-
197
- // Fallback: use first commit or feature name
198
- if (commits.length > 0 && commits[0].length < 72) {
199
- return commits[0];
249
+
250
+ // Fallback to smart title
251
+ if (!title) {
252
+ title = generateSmartTitle(changedFiles, commits, featureName || branch.replace(/^feature\//, ""));
200
253
  }
201
-
202
- // Final fallback: humanize feature name
203
- return featureName
204
- .split(/[-_]/)
205
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
206
- .join(" ");
207
254
 
208
255
  // Build PR body
209
256
  let body = options.body || "";
210
257
 
211
- // Add preview URL if we have allocation
212
- if (allocation && allocation.publicUrl) {
258
+ // Add preview URL
259
+ if (allocation?.publicUrl) {
213
260
  body += `## Preview\nšŸ”— ${allocation.publicUrl}\n\n`;
214
261
  } else if (config.devDomain && featureName && projectName) {
215
- // Try to construct preview URL
216
262
  const previewUrl = `https://${projectName}-${featureName}.${config.devDomain}`;
217
263
  body += `## Preview\nšŸ”— ${previewUrl}\n\n`;
218
264
  }
219
265
 
220
- // Add commits section if we have commits
221
- if (commits.length > 0) {
266
+ // Add AI-generated body or commits
267
+ if (aiBody) {
268
+ body += `## Changes\n${aiBody}\n\n`;
269
+ } else if (commits.length > 0) {
222
270
  body += `## Changes\n`;
223
271
  commits.forEach((commit) => {
224
272
  body += `- ${commit}\n`;
@@ -226,7 +274,7 @@ function generateSmartTitle(files: string[], commits: string[], featureName: str
226
274
  body += "\n";
227
275
  }
228
276
 
229
- // Add files changed
277
+ // Add stats
230
278
  if (diffStat) {
231
279
  body += `## Summary\n\`\`\`\n${diffStat}\n\`\`\`\n\n`;
232
280
  }
@@ -251,6 +299,7 @@ function generateSmartTitle(files: string[], commits: string[], featureName: str
251
299
 
252
300
  // Create PR
253
301
  console.log(`\nšŸ“ Creating pull request...`);
302
+ console.log(` Title: ${title}`);
254
303
 
255
304
  const prArgs = ["pr", "create", "--title", title, "--body", body];
256
305
 
@@ -272,7 +321,6 @@ function generateSmartTitle(files: string[], commits: string[], featureName: str
272
321
  const prResult = run("gh", prArgs, { cwd: worktreePath });
273
322
 
274
323
  if (!prResult.success) {
275
- // Check if it's because PR already exists
276
324
  if (prResult.stderr.includes("already exists")) {
277
325
  console.log(` PR already exists for this branch`);
278
326
  const viewResult = run("gh", ["pr", "view", "--json", "url"], { cwd: worktreePath });
@@ -280,9 +328,7 @@ function generateSmartTitle(files: string[], commits: string[], featureName: str
280
328
  try {
281
329
  const prData = JSON.parse(viewResult.stdout);
282
330
  console.log(`\nšŸ”— ${prData.url}`);
283
- } catch {
284
- // Ignore
285
- }
331
+ } catch {}
286
332
  }
287
333
  return;
288
334
  }
@@ -290,13 +336,11 @@ function generateSmartTitle(files: string[], commits: string[], featureName: str
290
336
  process.exit(1);
291
337
  }
292
338
 
293
- // Extract PR URL from output
294
339
  const prUrl = prResult.stdout.trim();
295
340
 
296
341
  console.log(`\nāœ… Pull request created!`);
297
342
  console.log(`\nšŸ”— ${prUrl}`);
298
343
 
299
- // Show preview URL again for easy access
300
344
  if (allocation?.publicUrl) {
301
345
  console.log(`\nšŸ“± Preview: ${allocation.publicUrl}`);
302
346
  }
package/src/index.ts CHANGED
@@ -134,12 +134,14 @@ program
134
134
  .option("-b, --body <body>", "PR body (preview URL auto-added)")
135
135
  .option("-d, --draft", "Create as draft PR")
136
136
  .option("-w, --web", "Open in browser instead of CLI")
137
+ .option("--ai", "Use Claude to generate title and description from diff")
137
138
  .action(async (feature, options) => {
138
139
  await pr(feature, options.project, {
139
140
  title: options.title,
140
141
  body: options.body,
141
142
  draft: options.draft,
142
143
  web: options.web,
144
+ ai: options.ai,
143
145
  });
144
146
  });
145
147