@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.
- package/README.md +208 -0
- package/dist/index.js +806 -0
- 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
|
+
}
|