@michael_magdy/mic-drop 0.1.0

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.
Files changed (3) hide show
  1. package/README.md +208 -0
  2. package/dist/index.js +806 -0
  3. package/package.json +42 -0
package/README.md ADDED
@@ -0,0 +1,208 @@
1
+ # mic-drop
2
+
3
+ Turn a Jira ticket into an isolated git worktree with Claude Code running automatically — in one command.
4
+
5
+ ```bash
6
+ mic-drop PROJ-123
7
+ ```
8
+
9
+ ## Prerequisites
10
+
11
+ - [Node.js](https://nodejs.org/) 18+
12
+ - [Git](https://git-scm.com/) 2.5+ (worktree support)
13
+ - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) — `npm install -g @anthropic-ai/claude-code`
14
+ - A Jira Cloud instance with API access
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install -g @michael_magdy/mic-drop
20
+ ```
21
+
22
+ Then run the setup wizard once:
23
+
24
+ ```bash
25
+ mic-drop setup
26
+ ```
27
+
28
+ This will ask for your Jira credentials and save them securely to your OS keychain (macOS Keychain, Linux Secret Service, or Windows Credential Manager). No environment variables needed.
29
+
30
+ To generate a Jira API token, go to [Atlassian API Tokens](https://id.atlassian.com/manage-profile/security/api-tokens).
31
+
32
+ ## Quick Start
33
+
34
+ ```bash
35
+ # From inside any git repository
36
+ cd ~/Projects/my-app
37
+ mic-drop PROJ-123
38
+ ```
39
+
40
+ That's it. The tool will:
41
+ 1. Fetch the ticket title and description from Jira
42
+ 2. Create a worktree at `.worktrees/PROJ-123/` branched off your base branch
43
+ 3. Copy any configured project files (if configured in `.worktree.json`)
44
+ 4. Open a new terminal window with Claude, ticket context pasted and ready to submit
45
+
46
+ ## Usage
47
+
48
+ ```
49
+ mic-drop [options] <TICKET-123>
50
+ mic-drop setup
51
+ ```
52
+
53
+ | Option | Description |
54
+ |--------|-------------|
55
+ | `TICKET-123` | The Jira issue key (required) |
56
+ | `-p, --project <path>` | Path to the git project root. Defaults to the current git repository. |
57
+ | `-a, --auto` | Auto-submit the ticket to Claude without waiting for review |
58
+ | `-h, --help` | Show help |
59
+
60
+ ### Examples
61
+
62
+ ```bash
63
+ # Use the current directory's git root
64
+ mic-drop PROJ-42
65
+
66
+ # Specify a project explicitly
67
+ mic-drop -p ~/Projects/my-app PROJ-42
68
+
69
+ # Auto-submit without review
70
+ mic-drop PROJ-42 --auto
71
+ ```
72
+
73
+ ## Project Configuration
74
+
75
+ Create a `.worktree.json` file in your project root to customize behaviour. All fields are optional — sensible defaults are used when omitted.
76
+
77
+ ```json
78
+ {
79
+ "baseBranch": "main",
80
+ "worktreesDir": ".worktrees",
81
+ "copyFiles": [".env", ".env.local"],
82
+ "copyDirs": [],
83
+ "terminal": "warp",
84
+ "claudeMode": "--permission-mode plan"
85
+ }
86
+ ```
87
+
88
+ | Field | Default | Description |
89
+ |-------|---------|-------------|
90
+ | `baseBranch` | `develop` | Branch to base new worktrees on |
91
+ | `worktreesDir` | `.worktrees` | Where to create worktrees, relative to project root |
92
+ | `copyFiles` | `[]` | Files to copy from the main project into the worktree |
93
+ | `copyDirs` | `[]` | Directories to copy recursively |
94
+ | `terminal` | `warp` | Terminal to use: `warp`, `iterm`, `terminal` |
95
+ | `claudeMode` | `--permission-mode plan` | Flags passed to the `claude` CLI |
96
+
97
+ ### Legacy `.worktree.conf`
98
+
99
+ If your project has an existing bash-style `.worktree.conf`, `mic-drop` will read it automatically and prompt you to migrate to `.worktree.json`.
100
+
101
+ ### Branch Naming
102
+
103
+ Branches are automatically named using the pattern: `TICKET-KEY_Title-With-Hyphens`
104
+
105
+ For example, ticket `PROJ-42` with title "Fix login button" becomes branch `PROJ-42_Fix-login-button`.
106
+
107
+ ### Example Configs
108
+
109
+ **React / Next.js:**
110
+ ```json
111
+ {
112
+ "baseBranch": "main",
113
+ "copyFiles": [".env", ".env.local"]
114
+ }
115
+ ```
116
+
117
+ **Android (Kotlin/Java):**
118
+ ```json
119
+ {
120
+ "baseBranch": "develop",
121
+ "copyFiles": ["local.properties", "app/google-services.json"],
122
+ "copyDirs": ["keystores", ".gradle"]
123
+ }
124
+ ```
125
+
126
+ **Python / Django:**
127
+ ```json
128
+ {
129
+ "baseBranch": "main",
130
+ "copyFiles": [".env"],
131
+ "copyDirs": [".venv"]
132
+ }
133
+ ```
134
+
135
+ ## Directory Structure
136
+
137
+ After running, your file system looks like this:
138
+
139
+ ```
140
+ ~/Projects/my-app/
141
+ ├── .git/
142
+ ├── .worktrees/ ← added to .gitignore automatically
143
+ │ └── PROJ-42/ ← Claude is working here (isolated branch)
144
+ │ └── src/
145
+ ├── .worktree.json
146
+ └── src/ ← your main branch, untouched
147
+ ```
148
+
149
+ Each worktree is a fully independent checkout on its own branch. You can build, test, and run them separately while Claude works.
150
+
151
+ ## Worktree Cleanup
152
+
153
+ When Claude finishes and you've merged the PR:
154
+
155
+ ```bash
156
+ # From the main project directory
157
+ git worktree remove .worktrees/PROJ-42
158
+ git branch -d PROJ-42_Fix-login-button
159
+
160
+ # Or prune all finished worktrees at once:
161
+ git worktree prune
162
+ ```
163
+
164
+ ## Workflow
165
+
166
+ ```
167
+ ┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
168
+ │ Jira Board │────▶│ mic-drop PROJ-42 │────▶│ Claude works │
169
+ │ Pick ticket│ │ One command │ │ independently │
170
+ └─────────────┘ └──────────────────┘ └────────┬────────┘
171
+
172
+ ┌──────────────────┐ │
173
+ │ You keep coding │ │
174
+ │ on your branch │◀─────────────┘
175
+ └────────┬─────────┘ (in parallel)
176
+
177
+ ┌────────▼─────────┐
178
+ │ Review and │
179
+ │ merge the PR │
180
+ └──────────────────┘
181
+ ```
182
+
183
+ ## Troubleshooting
184
+
185
+ **"No credentials found. Run: mic-drop setup"**
186
+ Run `mic-drop setup` to configure your Jira credentials.
187
+
188
+ **"Not inside a git repository"**
189
+ Run the command from inside a git repo, or pass `-p /path/to/project`.
190
+
191
+ **Could not fetch ticket**
192
+ Check that your Jira domain, email, and API token are correct. Run `mic-drop setup` to reconfigure.
193
+
194
+ **"Worktree already exists"**
195
+ A worktree for that ticket was already created. Remove it first:
196
+ ```bash
197
+ git worktree remove .worktrees/PROJ-42
198
+ ```
199
+
200
+ **Files not being copied**
201
+ Verify paths in `copyFiles` and `copyDirs` are relative to the project root. The tool will warn about missing files but won't fail.
202
+
203
+ **Terminal opens but Claude doesn't start (Warp)**
204
+ macOS requires Accessibility permissions for terminal automation. Go to **System Preferences → Privacy & Security → Accessibility** and ensure your terminal app is listed.
205
+
206
+ ## License
207
+
208
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,806 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
25
+
26
+ // src/index.ts
27
+ var import_commander = require("commander");
28
+
29
+ // src/commands/setup.ts
30
+ var import_prompts = require("@inquirer/prompts");
31
+ var import_path5 = __toESM(require("path"));
32
+
33
+ // src/config/credentials.ts
34
+ var import_keytar = __toESM(require("keytar"));
35
+ var SERVICE = "mic-drop";
36
+ async function saveCredentials(creds) {
37
+ await import_keytar.default.setPassword(SERVICE, "jira-domain", creds.domain);
38
+ await import_keytar.default.setPassword(SERVICE, "jira-email", creds.email);
39
+ await import_keytar.default.setPassword(SERVICE, "jira-api-token", creds.apiToken);
40
+ }
41
+ async function loadCredentials() {
42
+ const [domain, email, apiToken] = await Promise.all([
43
+ import_keytar.default.getPassword(SERVICE, "jira-domain"),
44
+ import_keytar.default.getPassword(SERVICE, "jira-email"),
45
+ import_keytar.default.getPassword(SERVICE, "jira-api-token")
46
+ ]);
47
+ if (!domain || !email || !apiToken) return null;
48
+ return { domain, email, apiToken };
49
+ }
50
+
51
+ // src/config/projectConfig.ts
52
+ var import_fs = __toESM(require("fs"));
53
+ var import_path = __toESM(require("path"));
54
+
55
+ // src/config/schema.ts
56
+ var import_zod = require("zod");
57
+ var ProjectConfigSchema = import_zod.z.object({
58
+ baseBranch: import_zod.z.string().default("develop"),
59
+ worktreesDir: import_zod.z.string().default(".worktrees"),
60
+ copyFiles: import_zod.z.array(import_zod.z.string()).default([]),
61
+ copyDirs: import_zod.z.array(import_zod.z.string()).default([]),
62
+ terminal: import_zod.z.string().default("warp"),
63
+ claudeMode: import_zod.z.string().default("--permission-mode plan")
64
+ });
65
+ var PROJECT_CONFIG_DEFAULTS = {
66
+ baseBranch: "develop",
67
+ worktreesDir: ".worktrees",
68
+ copyFiles: [],
69
+ copyDirs: [],
70
+ terminal: "warp",
71
+ claudeMode: "--permission-mode plan"
72
+ };
73
+
74
+ // src/config/projectConfig.ts
75
+ var JSON_CONFIG = ".worktree.json";
76
+ var LEGACY_CONFIG = ".worktree.conf";
77
+ async function loadProjectConfig(repoRoot) {
78
+ const jsonPath = import_path.default.join(repoRoot, JSON_CONFIG);
79
+ const legacyPath = import_path.default.join(repoRoot, LEGACY_CONFIG);
80
+ if (import_fs.default.existsSync(jsonPath)) {
81
+ const raw = JSON.parse(import_fs.default.readFileSync(jsonPath, "utf-8"));
82
+ const config = ProjectConfigSchema.parse(raw);
83
+ return { config, source: "json" };
84
+ }
85
+ if (import_fs.default.existsSync(legacyPath)) {
86
+ const config = parseLegacyConf(import_fs.default.readFileSync(legacyPath, "utf-8"));
87
+ return { config, source: "legacy" };
88
+ }
89
+ return { config: { ...PROJECT_CONFIG_DEFAULTS }, source: "defaults" };
90
+ }
91
+ function saveProjectConfig(repoRoot, config) {
92
+ const jsonPath = import_path.default.join(repoRoot, JSON_CONFIG);
93
+ import_fs.default.writeFileSync(jsonPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
94
+ }
95
+ function parseLegacyConf(content) {
96
+ const partial = {};
97
+ for (const line of content.split("\n")) {
98
+ const trimmed = line.trim();
99
+ if (!trimmed || trimmed.startsWith("#")) continue;
100
+ const arrayMatch = trimmed.match(/^(\w+)=\(([^)]*)\)$/);
101
+ if (arrayMatch) {
102
+ const key = arrayMatch[1];
103
+ const raw = arrayMatch[2].trim();
104
+ const items = [];
105
+ for (const m of raw.matchAll(/\"([^\"]*)\"|'([^']*)'|(\S+)/g)) {
106
+ items.push(m[1] ?? m[2] ?? m[3]);
107
+ }
108
+ partial[key] = items;
109
+ continue;
110
+ }
111
+ const scalarMatch = trimmed.match(/^(\w+)=(?:"([^"]*)"|'([^']*)'|(.*))$/);
112
+ if (scalarMatch) {
113
+ const key = scalarMatch[1];
114
+ const value = scalarMatch[2] ?? scalarMatch[3] ?? scalarMatch[4] ?? "";
115
+ partial[key] = value;
116
+ }
117
+ }
118
+ return ProjectConfigSchema.parse({
119
+ baseBranch: partial["BASE_BRANCH"],
120
+ worktreesDir: partial["WORKTREES_DIR"],
121
+ copyFiles: partial["COPY_FILES"],
122
+ copyDirs: partial["COPY_DIRS"],
123
+ terminal: partial["TERMINAL"],
124
+ claudeMode: partial["CLAUDE_MODE"]
125
+ });
126
+ }
127
+
128
+ // src/jira/adfParser.ts
129
+ function extractText(node) {
130
+ const parts = [];
131
+ if (node.type === "text" && node.text) {
132
+ parts.push(node.text);
133
+ }
134
+ if (node.content) {
135
+ for (const child of node.content) {
136
+ parts.push(...extractText(child));
137
+ }
138
+ }
139
+ return parts;
140
+ }
141
+ function parseAdf(description) {
142
+ if (!description || typeof description !== "object") {
143
+ return "No description provided.";
144
+ }
145
+ const texts = extractText(description);
146
+ if (texts.length === 0) return "No description provided.";
147
+ return texts.join("\n");
148
+ }
149
+
150
+ // src/jira/client.ts
151
+ var JiraAuthError = class extends Error {
152
+ constructor() {
153
+ super("Invalid Jira credentials. Run mic-drop setup to reconfigure.");
154
+ this.name = "JiraAuthError";
155
+ }
156
+ };
157
+ var JiraTicketNotFoundError = class extends Error {
158
+ constructor(key) {
159
+ super(`Ticket ${key} not found.`);
160
+ this.name = "JiraTicketNotFoundError";
161
+ }
162
+ };
163
+ var JiraApiError = class extends Error {
164
+ constructor(status, message) {
165
+ super(message);
166
+ this.status = status;
167
+ this.name = "JiraApiError";
168
+ }
169
+ status;
170
+ };
171
+ async function fetchIssue(creds, issueKey) {
172
+ const token = Buffer.from(`${creds.email}:${creds.apiToken}`).toString("base64");
173
+ const url = `https://${creds.domain}/rest/api/3/issue/${issueKey}`;
174
+ const res = await fetch(url, {
175
+ headers: {
176
+ Authorization: `Basic ${token}`,
177
+ "Content-Type": "application/json"
178
+ }
179
+ });
180
+ if (res.status === 401 || res.status === 403) {
181
+ throw new JiraAuthError();
182
+ }
183
+ if (res.status === 404) {
184
+ throw new JiraTicketNotFoundError(issueKey);
185
+ }
186
+ if (!res.ok) {
187
+ throw new JiraApiError(res.status, `Jira API returned HTTP ${res.status}`);
188
+ }
189
+ const data = await res.json();
190
+ const title = data.fields?.summary;
191
+ if (!title) {
192
+ throw new JiraApiError(200, `Could not parse ticket ${issueKey} \u2014 missing summary field.`);
193
+ }
194
+ return {
195
+ key: issueKey,
196
+ title,
197
+ description: parseAdf(data.fields.description)
198
+ };
199
+ }
200
+ async function verifyCredentials(creds) {
201
+ const token = Buffer.from(`${creds.email}:${creds.apiToken}`).toString("base64");
202
+ const url = `https://${creds.domain}/rest/api/3/myself`;
203
+ const res = await fetch(url, {
204
+ headers: {
205
+ Authorization: `Basic ${token}`,
206
+ "Content-Type": "application/json"
207
+ }
208
+ });
209
+ if (res.status === 401 || res.status === 403) {
210
+ throw new JiraAuthError();
211
+ }
212
+ if (!res.ok) {
213
+ throw new JiraApiError(res.status, `Jira API returned HTTP ${res.status}`);
214
+ }
215
+ }
216
+
217
+ // src/utils/logger.ts
218
+ var import_chalk = __toESM(require("chalk"));
219
+ var logger = {
220
+ info: (msg) => console.log(import_chalk.default.cyan(" " + msg)),
221
+ success: (msg) => console.log(import_chalk.default.green(" \u2713 " + msg)),
222
+ warn: (msg) => console.log(import_chalk.default.yellow(" \u26A0 " + msg)),
223
+ error: (msg) => console.error(import_chalk.default.red(" \u2717 " + msg)),
224
+ step: (msg) => console.log(import_chalk.default.bold("\n" + msg)),
225
+ detail: (label, value) => console.log(import_chalk.default.gray(" " + label + ":") + " " + value)
226
+ };
227
+
228
+ // src/terminal/fallback.ts
229
+ var FallbackLauncher = class {
230
+ name = "fallback";
231
+ platform = ["darwin", "linux", "win32"];
232
+ async isAvailable() {
233
+ return true;
234
+ }
235
+ async launch({ workingDirectory, command }) {
236
+ logger.warn("Could not auto-launch a terminal. Run these commands manually:");
237
+ console.log(`
238
+ cd ${workingDirectory}`);
239
+ console.log(` ${command}
240
+ `);
241
+ }
242
+ };
243
+
244
+ // src/terminal/launchers/warp.ts
245
+ var import_fs2 = __toESM(require("fs"));
246
+ var import_path2 = __toESM(require("path"));
247
+ var import_child_process = require("child_process");
248
+ var WarpLauncher = class {
249
+ name = "warp";
250
+ platform = ["darwin"];
251
+ async isAvailable() {
252
+ try {
253
+ (0, import_child_process.execSync)("test -d /Applications/Warp.app", { stdio: "ignore" });
254
+ return true;
255
+ } catch {
256
+ return false;
257
+ }
258
+ }
259
+ async launch({ workingDirectory, command }) {
260
+ const scriptPath = import_path2.default.join(workingDirectory, ".start-claude.sh");
261
+ import_fs2.default.writeFileSync(scriptPath, `#!/bin/bash
262
+ ${command}
263
+ `, { mode: 493 });
264
+ (0, import_child_process.spawn)("open", ["-a", "Warp", workingDirectory], { detached: true, stdio: "ignore" }).unref();
265
+ await sleep(2e3);
266
+ const script = [
267
+ `tell application "System Events" to tell process "Warp" to keystroke "bash .start-claude.sh"`,
268
+ `tell application "System Events" to tell process "Warp" to keystroke return`
269
+ ].join("\n");
270
+ (0, import_child_process.spawn)("osascript", ["-e", script], { detached: true, stdio: "ignore" }).unref();
271
+ }
272
+ };
273
+ function sleep(ms) {
274
+ return new Promise((resolve) => setTimeout(resolve, ms));
275
+ }
276
+
277
+ // src/terminal/launchers/iterm.ts
278
+ var import_child_process2 = require("child_process");
279
+ var ITermLauncher = class {
280
+ name = "iterm";
281
+ platform = ["darwin"];
282
+ async isAvailable() {
283
+ try {
284
+ (0, import_child_process2.execSync)("test -d '/Applications/iTerm.app'", { stdio: "ignore" });
285
+ return true;
286
+ } catch {
287
+ try {
288
+ (0, import_child_process2.execSync)("test -d '/Applications/iTerm2.app'", { stdio: "ignore" });
289
+ return true;
290
+ } catch {
291
+ return false;
292
+ }
293
+ }
294
+ }
295
+ async launch({ workingDirectory, command }) {
296
+ const escapedDir = workingDirectory.replace(/'/g, "'\\''");
297
+ const escapedCmd = command.replace(/'/g, "'\\''");
298
+ const script = `
299
+ tell application "iTerm"
300
+ activate
301
+ set newWindow to (create window with default profile)
302
+ tell current session of newWindow
303
+ write text "cd '${escapedDir}' && ${escapedCmd}"
304
+ end tell
305
+ end tell`;
306
+ (0, import_child_process2.spawn)("osascript", ["-e", script], { detached: true, stdio: "ignore" }).unref();
307
+ }
308
+ };
309
+
310
+ // src/terminal/launchers/terminal-app.ts
311
+ var import_child_process3 = require("child_process");
312
+ var TerminalAppLauncher = class {
313
+ name = "terminal";
314
+ platform = ["darwin"];
315
+ async isAvailable() {
316
+ return process.platform === "darwin";
317
+ }
318
+ async launch({ workingDirectory, command }) {
319
+ const escapedDir = workingDirectory.replace(/'/g, "'\\''");
320
+ const escapedCmd = command.replace(/'/g, "'\\''");
321
+ const script = `
322
+ tell application "Terminal"
323
+ activate
324
+ do script "cd '${escapedDir}' && ${escapedCmd}"
325
+ end tell`;
326
+ (0, import_child_process3.spawn)("osascript", ["-e", script], { detached: true, stdio: "ignore" }).unref();
327
+ }
328
+ };
329
+
330
+ // src/terminal/launchers/gnome-terminal.ts
331
+ var import_child_process4 = require("child_process");
332
+ var GnomeTerminalLauncher = class {
333
+ name = "gnome-terminal";
334
+ platform = ["linux"];
335
+ async isAvailable() {
336
+ try {
337
+ (0, import_child_process4.execSync)("which gnome-terminal", { stdio: "ignore" });
338
+ return true;
339
+ } catch {
340
+ return false;
341
+ }
342
+ }
343
+ async launch({ workingDirectory, command }) {
344
+ (0, import_child_process4.spawn)(
345
+ "gnome-terminal",
346
+ ["--working-directory", workingDirectory, "--", "bash", "-c", command],
347
+ { detached: true, stdio: "ignore" }
348
+ ).unref();
349
+ }
350
+ };
351
+
352
+ // src/terminal/launchers/konsole.ts
353
+ var import_child_process5 = require("child_process");
354
+ var KonsoleLauncher = class {
355
+ name = "konsole";
356
+ platform = ["linux"];
357
+ async isAvailable() {
358
+ try {
359
+ (0, import_child_process5.execSync)("which konsole", { stdio: "ignore" });
360
+ return true;
361
+ } catch {
362
+ return false;
363
+ }
364
+ }
365
+ async launch({ workingDirectory, command }) {
366
+ (0, import_child_process5.spawn)("konsole", ["--workdir", workingDirectory, "-e", command], {
367
+ detached: true,
368
+ stdio: "ignore"
369
+ }).unref();
370
+ }
371
+ };
372
+
373
+ // src/terminal/launchers/windows-terminal.ts
374
+ var import_child_process6 = require("child_process");
375
+ var WindowsTerminalLauncher = class {
376
+ name = "windows-terminal";
377
+ platform = ["win32"];
378
+ async isAvailable() {
379
+ try {
380
+ (0, import_child_process6.execSync)("where wt", { stdio: "ignore" });
381
+ return true;
382
+ } catch {
383
+ return false;
384
+ }
385
+ }
386
+ async launch({ workingDirectory, command }) {
387
+ (0, import_child_process6.spawn)("wt", ["-d", workingDirectory, "powershell", "-Command", command], {
388
+ detached: true,
389
+ stdio: "ignore"
390
+ }).unref();
391
+ }
392
+ };
393
+
394
+ // src/terminal/registry.ts
395
+ var ALL_LAUNCHERS = [
396
+ new WarpLauncher(),
397
+ new ITermLauncher(),
398
+ new TerminalAppLauncher(),
399
+ new GnomeTerminalLauncher(),
400
+ new KonsoleLauncher(),
401
+ new WindowsTerminalLauncher()
402
+ ];
403
+ var FALLBACK = new FallbackLauncher();
404
+ function getLauncher(name) {
405
+ const platform = process.platform;
406
+ const launcher = ALL_LAUNCHERS.find(
407
+ (l) => l.name === name && l.platform.includes(platform)
408
+ );
409
+ return launcher ?? FALLBACK;
410
+ }
411
+ async function getAvailableLaunchers() {
412
+ const platform = process.platform;
413
+ const platformLaunchers = ALL_LAUNCHERS.filter(
414
+ (l) => l.platform.includes(platform)
415
+ );
416
+ const available = [];
417
+ for (const launcher of platformLaunchers) {
418
+ if (await launcher.isAvailable()) {
419
+ available.push(launcher);
420
+ }
421
+ }
422
+ return available;
423
+ }
424
+
425
+ // src/git/gitignore.ts
426
+ var import_fs3 = __toESM(require("fs"));
427
+ var import_path3 = __toESM(require("path"));
428
+ function ensureGitignoreEntry(repoRoot, entry) {
429
+ const gitignorePath = import_path3.default.join(repoRoot, ".gitignore");
430
+ const entryNorm = entry.endsWith("/") ? entry : entry + "/";
431
+ const entryBase = entry.replace(/\/$/, "");
432
+ let existing = "";
433
+ if (import_fs3.default.existsSync(gitignorePath)) {
434
+ existing = import_fs3.default.readFileSync(gitignorePath, "utf-8");
435
+ const lines = existing.split("\n");
436
+ for (const line of lines) {
437
+ const trimmed = line.trim();
438
+ if (trimmed === entryBase || trimmed === entryNorm) {
439
+ return;
440
+ }
441
+ }
442
+ }
443
+ const toAppend = existing.endsWith("\n") || existing === "" ? entryNorm + "\n" : "\n" + entryNorm + "\n";
444
+ import_fs3.default.appendFileSync(gitignorePath, toAppend, "utf-8");
445
+ }
446
+
447
+ // src/git/worktree.ts
448
+ var import_fs4 = __toESM(require("fs"));
449
+ var import_path4 = __toESM(require("path"));
450
+ var import_simple_git = __toESM(require("simple-git"));
451
+ var WorktreeExistsError = class extends Error {
452
+ constructor(targetDir) {
453
+ super(`Worktree already exists at ${targetDir}. Remove it first with:
454
+ git worktree remove ${targetDir}`);
455
+ this.name = "WorktreeExistsError";
456
+ }
457
+ };
458
+ var GitNotRepoError = class extends Error {
459
+ constructor() {
460
+ super("Not inside a git repository.");
461
+ this.name = "GitNotRepoError";
462
+ }
463
+ };
464
+ async function resolveRepoRoot(startDir) {
465
+ const git = (0, import_simple_git.default)(startDir);
466
+ const isRepo = await git.checkIsRepo();
467
+ if (!isRepo) throw new GitNotRepoError();
468
+ return await git.revparse(["--show-toplevel"]);
469
+ }
470
+ async function createWorktree(opts) {
471
+ const { repoRoot, targetDir, branchName, baseBranch } = opts;
472
+ const git = (0, import_simple_git.default)(repoRoot);
473
+ if (import_fs4.default.existsSync(targetDir)) {
474
+ throw new WorktreeExistsError(targetDir);
475
+ }
476
+ await git.raw(["worktree", "prune"]);
477
+ await git.fetch("origin", baseBranch);
478
+ const branchSummary = await git.branch(["-a"]);
479
+ const branchExists = branchName in branchSummary.branches || `remotes/origin/${branchName}` in branchSummary.branches;
480
+ import_fs4.default.mkdirSync(import_path4.default.dirname(targetDir), { recursive: true });
481
+ if (branchExists) {
482
+ await git.raw(["worktree", "add", targetDir, branchName]);
483
+ } else {
484
+ await git.raw([
485
+ "worktree",
486
+ "add",
487
+ targetDir,
488
+ `origin/${baseBranch}`,
489
+ "-b",
490
+ branchName
491
+ ]);
492
+ }
493
+ try {
494
+ const worktreeGit = (0, import_simple_git.default)(targetDir);
495
+ await worktreeGit.branch(["--unset-upstream"]);
496
+ } catch {
497
+ }
498
+ return { branchReused: branchExists };
499
+ }
500
+ async function excludeFromWorktree(worktreeDir, entries) {
501
+ const git = (0, import_simple_git.default)(worktreeDir);
502
+ const gitDir = (await git.revparse(["--git-dir"])).trim();
503
+ const absoluteGitDir = import_path4.default.isAbsolute(gitDir) ? gitDir : import_path4.default.join(worktreeDir, gitDir);
504
+ const excludePath = import_path4.default.join(absoluteGitDir, "info", "exclude");
505
+ import_fs4.default.mkdirSync(import_path4.default.dirname(excludePath), { recursive: true });
506
+ const existing = import_fs4.default.existsSync(excludePath) ? import_fs4.default.readFileSync(excludePath, "utf-8") : "";
507
+ const existingLines = existing.split("\n");
508
+ const toAdd = entries.filter((e) => !existingLines.includes(e));
509
+ if (toAdd.length > 0) {
510
+ import_fs4.default.appendFileSync(excludePath, toAdd.join("\n") + "\n", "utf-8");
511
+ }
512
+ }
513
+ function copyProjectFiles(repoRoot, targetDir, copyFiles, copyDirs) {
514
+ const copied = [];
515
+ const missing = [];
516
+ for (const file of copyFiles) {
517
+ const src = import_path4.default.join(repoRoot, file);
518
+ const dest = import_path4.default.join(targetDir, file);
519
+ if (import_fs4.default.existsSync(src)) {
520
+ import_fs4.default.mkdirSync(import_path4.default.dirname(dest), { recursive: true });
521
+ import_fs4.default.copyFileSync(src, dest);
522
+ copied.push(file);
523
+ } else {
524
+ missing.push(file);
525
+ }
526
+ }
527
+ for (const dir of copyDirs) {
528
+ const src = import_path4.default.join(repoRoot, dir);
529
+ const dest = import_path4.default.join(targetDir, dir);
530
+ if (import_fs4.default.existsSync(src) && import_fs4.default.statSync(src).isDirectory()) {
531
+ copyDirRecursive(src, dest);
532
+ copied.push(dir + "/");
533
+ } else {
534
+ missing.push(dir + "/");
535
+ }
536
+ }
537
+ return { copied, missing };
538
+ }
539
+ function copyDirRecursive(src, dest) {
540
+ import_fs4.default.mkdirSync(dest, { recursive: true });
541
+ for (const entry of import_fs4.default.readdirSync(src, { withFileTypes: true })) {
542
+ const srcPath = import_path4.default.join(src, entry.name);
543
+ const destPath = import_path4.default.join(dest, entry.name);
544
+ if (entry.isDirectory()) {
545
+ copyDirRecursive(srcPath, destPath);
546
+ } else {
547
+ import_fs4.default.copyFileSync(srcPath, destPath);
548
+ }
549
+ }
550
+ }
551
+
552
+ // src/utils/spinner.ts
553
+ var import_ora = __toESM(require("ora"));
554
+ function createSpinner(text) {
555
+ return (0, import_ora.default)({ text, color: "cyan" });
556
+ }
557
+ async function withSpinner(text, fn) {
558
+ const spinner = createSpinner(text);
559
+ spinner.start();
560
+ try {
561
+ const result = await fn();
562
+ spinner.succeed();
563
+ return result;
564
+ } catch (err) {
565
+ spinner.fail();
566
+ throw err;
567
+ }
568
+ }
569
+
570
+ // src/commands/setup.ts
571
+ async function runSetup() {
572
+ logger.step("mic-drop setup");
573
+ console.log();
574
+ const existingCreds = await loadCredentials().catch(() => null);
575
+ const domain = await (0, import_prompts.input)({
576
+ message: "Jira domain (e.g. yourcompany.atlassian.net):",
577
+ default: existingCreds?.domain,
578
+ validate: (v) => v.trim() ? true : "Required"
579
+ });
580
+ const email = await (0, import_prompts.input)({
581
+ message: "Jira email:",
582
+ default: existingCreds?.email,
583
+ validate: (v) => v.trim() ? true : "Required"
584
+ });
585
+ const apiToken = await (0, import_prompts.password)({
586
+ message: "Jira API token:",
587
+ validate: (v) => v.trim() ? true : "Required"
588
+ });
589
+ const creds = {
590
+ domain: domain.trim(),
591
+ email: email.trim(),
592
+ apiToken: apiToken.trim()
593
+ };
594
+ await withSpinner("Verifying credentials...", async () => {
595
+ await verifyCredentials(creds);
596
+ }).catch((err) => {
597
+ if (err instanceof JiraAuthError) {
598
+ logger.error("Invalid credentials. Check your domain, email, and API token.");
599
+ process.exit(1);
600
+ }
601
+ throw err;
602
+ });
603
+ await saveCredentials(creds);
604
+ logger.success("Credentials saved to system keychain.");
605
+ console.log();
606
+ logger.step("Per-project configuration");
607
+ let repoRoot = null;
608
+ try {
609
+ repoRoot = await resolveRepoRoot(process.cwd());
610
+ } catch (err) {
611
+ if (!(err instanceof GitNotRepoError)) throw err;
612
+ }
613
+ if (!repoRoot) {
614
+ console.log(
615
+ " (Not in a git repo \u2014 run mic-drop setup from inside a project to configure it)\n"
616
+ );
617
+ console.log("Setup complete. Run: mic-drop PROJ-123\n");
618
+ return;
619
+ }
620
+ const configProject = await (0, import_prompts.confirm)({
621
+ message: `Configure project at ${repoRoot}?`,
622
+ default: true
623
+ });
624
+ if (!configProject) {
625
+ console.log("\nSetup complete. Run: mic-drop PROJ-123\n");
626
+ return;
627
+ }
628
+ const { config: existing, source } = await loadProjectConfig(repoRoot);
629
+ if (source === "legacy") {
630
+ logger.warn(
631
+ "Found .worktree.conf (legacy format). Settings have been migrated \u2014 a new .worktree.json will be written."
632
+ );
633
+ }
634
+ const baseBranch = await (0, import_prompts.input)({
635
+ message: "Base branch:",
636
+ default: existing.baseBranch
637
+ });
638
+ const worktreesDir = await (0, import_prompts.input)({
639
+ message: "Worktrees directory (relative to project root):",
640
+ default: existing.worktreesDir
641
+ });
642
+ const copyFilesRaw = await (0, import_prompts.input)({
643
+ message: "Files to copy into worktree (comma-separated, or leave empty):",
644
+ default: existing.copyFiles.join(", ")
645
+ });
646
+ const copyDirsRaw = await (0, import_prompts.input)({
647
+ message: "Directories to copy into worktree (comma-separated, or leave empty):",
648
+ default: existing.copyDirs.join(", ")
649
+ });
650
+ const available = await getAvailableLaunchers();
651
+ const terminalChoices = [
652
+ ...available.map((l) => ({ name: l.name, value: l.name })),
653
+ { name: "none (print instructions only)", value: "fallback" }
654
+ ];
655
+ const terminal = terminalChoices.length > 1 ? await (0, import_prompts.select)({
656
+ message: "Preferred terminal:",
657
+ choices: terminalChoices,
658
+ default: available.find((l) => l.name === existing.terminal) ? existing.terminal : terminalChoices[0].value
659
+ }) : terminalChoices[0].value;
660
+ const claudeMode = await (0, import_prompts.input)({
661
+ message: "Claude CLI flags:",
662
+ default: existing.claudeMode
663
+ });
664
+ const parseList = (raw) => raw.split(",").map((s) => s.trim()).filter(Boolean);
665
+ const newConfig = {
666
+ baseBranch: baseBranch.trim() || "develop",
667
+ worktreesDir: worktreesDir.trim() || ".worktrees",
668
+ copyFiles: parseList(copyFilesRaw),
669
+ copyDirs: parseList(copyDirsRaw),
670
+ terminal,
671
+ claudeMode: claudeMode.trim() || "--permission-mode plan"
672
+ };
673
+ saveProjectConfig(repoRoot, newConfig);
674
+ logger.success(`Config written to ${import_path5.default.join(repoRoot, ".worktree.json")}`);
675
+ ensureGitignoreEntry(repoRoot, newConfig.worktreesDir);
676
+ logger.success(`.gitignore updated with ${newConfig.worktreesDir}/`);
677
+ console.log("\nSetup complete. Run: mic-drop PROJ-123\n");
678
+ }
679
+
680
+ // src/commands/run.ts
681
+ var import_path6 = __toESM(require("path"));
682
+ var import_fs5 = __toESM(require("fs"));
683
+
684
+ // src/utils/slugify.ts
685
+ var MAX_DESC_LENGTH = 60;
686
+ function slugify(title) {
687
+ return title.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "").slice(0, MAX_DESC_LENGTH).replace(/-+$/, "");
688
+ }
689
+ function buildBranchName(issueKey, title) {
690
+ return `${issueKey}_${slugify(title)}`;
691
+ }
692
+
693
+ // src/ticket/formatter.ts
694
+ function formatTicketFile(issue, baseBranch) {
695
+ return [
696
+ `[${issue.key}] ${issue.title}`,
697
+ "",
698
+ issue.description,
699
+ "",
700
+ `When you're done implementing this, create a pull request using:`,
701
+ `gh pr create --base ${baseBranch}`
702
+ ].join("\n");
703
+ }
704
+
705
+ // src/commands/run.ts
706
+ async function runTicket(issueKey, opts) {
707
+ let repoRoot;
708
+ try {
709
+ repoRoot = await resolveRepoRoot(opts.project ?? process.cwd());
710
+ } catch (err) {
711
+ if (err instanceof GitNotRepoError) {
712
+ logger.error("Not inside a git repository. Use -p to specify the project path.");
713
+ process.exit(1);
714
+ }
715
+ throw err;
716
+ }
717
+ const { config, source } = await loadProjectConfig(repoRoot);
718
+ if (source === "legacy") {
719
+ logger.warn("Using legacy .worktree.conf \u2014 run `mic-drop setup` to migrate to .worktree.json");
720
+ }
721
+ const creds = await loadCredentials().catch(() => null);
722
+ if (!creds) {
723
+ logger.error("No credentials found. Run: mic-drop setup");
724
+ process.exit(1);
725
+ }
726
+ logger.step(`Fetching ${issueKey}...`);
727
+ const issue = await withSpinner(
728
+ `Fetching ${issueKey} from Jira`,
729
+ () => fetchIssue(creds, issueKey)
730
+ ).catch((err) => {
731
+ logger.error(err.message);
732
+ process.exit(1);
733
+ });
734
+ logger.detail("Title", issue.title);
735
+ const branchName = buildBranchName(issueKey, issue.title);
736
+ const worktreesRoot = import_path6.default.isAbsolute(config.worktreesDir) ? config.worktreesDir : import_path6.default.join(repoRoot, config.worktreesDir);
737
+ const targetDir = import_path6.default.join(worktreesRoot, issueKey);
738
+ logger.step("Preparing worktree...");
739
+ logger.detail("Branch", branchName);
740
+ logger.detail("Base", `origin/${config.baseBranch}`);
741
+ logger.detail("Target", targetDir);
742
+ const { branchReused } = await withSpinner(
743
+ "Creating worktree",
744
+ () => createWorktree({
745
+ repoRoot,
746
+ targetDir,
747
+ branchName,
748
+ baseBranch: config.baseBranch,
749
+ copyFiles: config.copyFiles,
750
+ copyDirs: config.copyDirs
751
+ })
752
+ ).catch((err) => {
753
+ if (err instanceof WorktreeExistsError) {
754
+ logger.error(err.message);
755
+ process.exit(1);
756
+ }
757
+ logger.error(`Worktree creation failed: ${err.message}`);
758
+ process.exit(1);
759
+ });
760
+ if (branchReused) {
761
+ logger.warn(`Branch ${branchName} already existed \u2014 reusing it.`);
762
+ }
763
+ if (config.copyFiles.length > 0 || config.copyDirs.length > 0) {
764
+ const { copied, missing } = copyProjectFiles(
765
+ repoRoot,
766
+ targetDir,
767
+ config.copyFiles,
768
+ config.copyDirs
769
+ );
770
+ for (const f of copied) logger.success(`Copied ${f}`);
771
+ for (const f of missing) logger.warn(`Not found, skipped: ${f}`);
772
+ }
773
+ const ticketContent = formatTicketFile(issue, config.baseBranch);
774
+ const ticketFile = import_path6.default.join(targetDir, ".ticket.md");
775
+ import_fs5.default.writeFileSync(ticketFile, ticketContent, "utf-8");
776
+ logger.success("Wrote .ticket.md");
777
+ await excludeFromWorktree(targetDir, [".ticket.md", ".start-claude.sh"]);
778
+ ensureGitignoreEntry(repoRoot, config.worktreesDir);
779
+ logger.success("Updated .gitignore");
780
+ const claudeCommand = `claude ${config.claudeMode} "$(cat .ticket.md)"`;
781
+ logger.step("Launching terminal...");
782
+ const launcher = getLauncher(config.terminal);
783
+ logger.detail("Terminal", launcher.name);
784
+ logger.detail("Directory", targetDir);
785
+ logger.detail("Command", claudeCommand);
786
+ await launcher.launch({
787
+ workingDirectory: targetDir,
788
+ command: claudeCommand
789
+ });
790
+ logger.success(`Done! Claude is starting in ${launcher.name === "fallback" ? "manual mode" : launcher.name}.`);
791
+ console.log();
792
+ }
793
+
794
+ // src/index.ts
795
+ var program = new import_commander.Command();
796
+ program.name("mic-drop").description("Turn a Jira ticket into an isolated git worktree with Claude Code \u2014 in one command.").version("0.1.0");
797
+ program.command("setup").description("Interactive setup wizard for Jira credentials and project configuration").action(async () => {
798
+ await runSetup();
799
+ });
800
+ program.argument("<issue-key>", "Jira issue key (e.g. PROJ-123)").option("-p, --project <path>", "Path to the git project root (defaults to current directory)").description("Create a worktree and launch Claude for the given Jira ticket").action(async (issueKey, opts) => {
801
+ await runTicket(issueKey, opts);
802
+ });
803
+ program.parseAsync(process.argv).catch((err) => {
804
+ console.error(err.message);
805
+ process.exit(1);
806
+ });
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@michael_magdy/mic-drop",
3
+ "version": "0.1.0",
4
+ "author": "Michael Magdy",
5
+ "description": "Turn a Jira ticket into an isolated git worktree with Claude Code running automatically — in one command.",
6
+ "type": "commonjs",
7
+ "main": "dist/index.js",
8
+ "bin": {
9
+ "mic-drop": "dist/index.js"
10
+ },
11
+ "scripts": {
12
+ "build": "tsup",
13
+ "dev": "tsup --watch",
14
+ "test": "vitest run",
15
+ "test:watch": "vitest",
16
+ "lint": "eslint src --ext .ts",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "engines": {
20
+ "node": ">=18.0.0"
21
+ },
22
+ "keywords": ["jira", "git", "worktree", "claude", "cli", "ai"],
23
+ "license": "MIT",
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "dependencies": {
28
+ "@inquirer/prompts": "^7.0.0",
29
+ "chalk": "^5.3.0",
30
+ "commander": "^12.0.0",
31
+ "keytar": "^7.9.0",
32
+ "ora": "^8.0.1",
33
+ "simple-git": "^3.22.0",
34
+ "zod": "^3.22.4"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^20.0.0",
38
+ "tsup": "^8.0.2",
39
+ "typescript": "^5.4.5",
40
+ "vitest": "^1.6.0"
41
+ }
42
+ }