yggtree 1.4.2 → 1.4.3
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 +125 -109
- package/dist/commands/wt/create-branch.js +3 -1
- package/dist/commands/wt/create-multi.js +11 -2
- package/dist/commands/wt/create-sandbox.js +3 -1
- package/dist/commands/wt/create.js +86 -44
- package/dist/commands/wt/enter.js +3 -27
- package/dist/commands/wt/open.js +207 -53
- package/dist/index.js +132 -126
- package/dist/lib/env-files.js +72 -0
- package/dist/lib/git.js +5 -0
- package/dist/lib/repo-context.js +46 -0
- package/dist/lib/update-check.js +55 -0
- package/package.json +8 -4
- package/dist/commands/wt/close.js +0 -96
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import inquirer from 'inquirer';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import {
|
|
4
|
+
import { getRepoName, createWorktree, fetchAll, listWorktrees } from '../../lib/git.js';
|
|
5
5
|
import { runBootstrap } from '../../lib/config.js';
|
|
6
6
|
import { WORKTREES_ROOT } from '../../lib/paths.js';
|
|
7
7
|
import { log, ui, createSpinner } from '../../lib/ui.js';
|
|
8
8
|
import { ensureAutocompletePrompt } from '../../lib/prompt.js';
|
|
9
|
+
import { promptAndCopyEnvFiles } from '../../lib/env-files.js';
|
|
10
|
+
import { ensureRepoContext } from '../../lib/repo-context.js';
|
|
9
11
|
import { enterCommand } from './enter.js';
|
|
10
|
-
import { detectInstalledOpenTools,
|
|
12
|
+
import { detectInstalledOpenTools, promptOpenActions, resolveOpenToolAction, runOpenActions, } from './open.js';
|
|
11
13
|
import { execa } from 'execa';
|
|
12
14
|
import fs from 'fs-extra';
|
|
13
15
|
function toSlug(value) {
|
|
@@ -16,7 +18,7 @@ function toSlug(value) {
|
|
|
16
18
|
.replace(/[\/\\]/g, '-')
|
|
17
19
|
.replace(/\s+/g, '-');
|
|
18
20
|
}
|
|
19
|
-
async function listBranchCandidates() {
|
|
21
|
+
export async function listBranchCandidates() {
|
|
20
22
|
const [localRefs, remoteRefs] = await Promise.all([
|
|
21
23
|
execa('git', ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
|
|
22
24
|
execa('git', ['for-each-ref', '--format=%(refname:short)', 'refs/remotes/origin']),
|
|
@@ -32,21 +34,26 @@ async function listBranchCandidates() {
|
|
|
32
34
|
.filter(ref => ref !== 'origin/HEAD' && !ref.endsWith('/HEAD'))
|
|
33
35
|
.map(ref => ref.replace(/^origin\//, ''));
|
|
34
36
|
const localSet = new Set(localBranches);
|
|
35
|
-
const
|
|
37
|
+
const uniqueRemoteBranches = [...new Set(remoteBranches)];
|
|
36
38
|
const localCandidates = localBranches.map(branchName => ({
|
|
37
39
|
branchName,
|
|
38
40
|
checkoutRef: branchName,
|
|
39
41
|
createLocalBranch: false,
|
|
40
42
|
source: 'local',
|
|
43
|
+
attachedBranchName: branchName,
|
|
44
|
+
sourceLabel: 'local',
|
|
41
45
|
}));
|
|
42
|
-
const remoteCandidates =
|
|
46
|
+
const remoteCandidates = uniqueRemoteBranches.map(branchName => ({
|
|
43
47
|
branchName,
|
|
44
48
|
checkoutRef: `origin/${branchName}`,
|
|
45
|
-
createLocalBranch:
|
|
49
|
+
createLocalBranch: !localSet.has(branchName),
|
|
46
50
|
source: 'remote',
|
|
51
|
+
attachedBranchName: localSet.has(branchName) ? undefined : branchName,
|
|
52
|
+
sourceLabel: localSet.has(branchName) ? 'remote tip, detached' : 'remote, creates local branch',
|
|
47
53
|
}));
|
|
48
54
|
return [...localCandidates, ...remoteCandidates]
|
|
49
|
-
.sort((a, b) => a.branchName.localeCompare(b.branchName)
|
|
55
|
+
.sort((a, b) => a.branchName.localeCompare(b.branchName) ||
|
|
56
|
+
a.checkoutRef.localeCompare(b.checkoutRef));
|
|
50
57
|
}
|
|
51
58
|
function resolveCandidateFromRef(ref, candidates) {
|
|
52
59
|
const trimmedRef = ref.trim();
|
|
@@ -54,18 +61,23 @@ function resolveCandidateFromRef(ref, candidates) {
|
|
|
54
61
|
return undefined;
|
|
55
62
|
if (trimmedRef.startsWith('origin/')) {
|
|
56
63
|
const branchName = trimmedRef.replace(/^origin\//, '');
|
|
57
|
-
return candidates.find(candidate => candidate.branchName === branchName
|
|
64
|
+
return candidates.find(candidate => candidate.branchName === branchName &&
|
|
65
|
+
candidate.checkoutRef === trimmedRef);
|
|
58
66
|
}
|
|
59
67
|
return candidates.find(candidate => candidate.branchName === trimmedRef ||
|
|
60
68
|
candidate.checkoutRef === trimmedRef);
|
|
61
69
|
}
|
|
70
|
+
export function findExistingBranchWorktree(worktrees, branchName) {
|
|
71
|
+
if (!branchName)
|
|
72
|
+
return undefined;
|
|
73
|
+
return worktrees.find(wt => wt.branch === branchName &&
|
|
74
|
+
!wt.prunable &&
|
|
75
|
+
fs.pathExistsSync(wt.path));
|
|
76
|
+
}
|
|
62
77
|
export async function createCommand(options) {
|
|
63
78
|
try {
|
|
64
|
-
const repoRoot = await
|
|
79
|
+
const repoRoot = await ensureRepoContext();
|
|
65
80
|
log.info(`Repo: ${chalk.dim(repoRoot)}`);
|
|
66
|
-
if (options.enter !== undefined) {
|
|
67
|
-
log.warning('`--enter` / `--no-enter` is deprecated. Use `--open` / `--no-open` instead.');
|
|
68
|
-
}
|
|
69
81
|
// 1. Load branches
|
|
70
82
|
const loadingSpinner = createSpinner('Fetching branches...').start();
|
|
71
83
|
await fetchAll();
|
|
@@ -96,10 +108,11 @@ export async function createCommand(options) {
|
|
|
96
108
|
source: async (_answers, input = '') => {
|
|
97
109
|
const term = input.trim().toLowerCase();
|
|
98
110
|
const filtered = term
|
|
99
|
-
? candidates.filter(candidate => candidate.branchName.toLowerCase().includes(term)
|
|
111
|
+
? candidates.filter(candidate => candidate.branchName.toLowerCase().includes(term) ||
|
|
112
|
+
candidate.checkoutRef.toLowerCase().includes(term))
|
|
100
113
|
: candidates;
|
|
101
114
|
return filtered.map(candidate => ({
|
|
102
|
-
name: `${chalk.yellow(candidate.
|
|
115
|
+
name: `${chalk.yellow(candidate.checkoutRef)} ${chalk.dim(`(${candidate.sourceLabel})`)}`,
|
|
103
116
|
value: candidate,
|
|
104
117
|
}));
|
|
105
118
|
},
|
|
@@ -113,39 +126,54 @@ export async function createCommand(options) {
|
|
|
113
126
|
return;
|
|
114
127
|
}
|
|
115
128
|
const existingWorktrees = await listWorktrees();
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
129
|
+
const existingBranchWorktree = findExistingBranchWorktree(existingWorktrees, selectedBranch.attachedBranchName);
|
|
130
|
+
const shouldEnterShell = options.enter !== false;
|
|
131
|
+
if (existingBranchWorktree) {
|
|
132
|
+
log.info(`Branch ${chalk.cyan(selectedBranch.branchName)} is already active in ${ui.path(existingBranchWorktree.path)}.`);
|
|
119
133
|
if (options.exec && options.exec.trim()) {
|
|
120
134
|
log.info('Reusing the existing worktree and running the requested command...');
|
|
121
|
-
await enterCommand(
|
|
135
|
+
await enterCommand(existingBranchWorktree.path, { exec: options.exec });
|
|
122
136
|
return;
|
|
123
137
|
}
|
|
124
|
-
const shouldOpenExisting = options.
|
|
125
|
-
?
|
|
126
|
-
: options.
|
|
127
|
-
? options.
|
|
138
|
+
const shouldOpenExisting = options.tool
|
|
139
|
+
? true
|
|
140
|
+
: options.open !== undefined
|
|
141
|
+
? options.open
|
|
128
142
|
: (await inquirer.prompt([
|
|
129
143
|
{
|
|
130
144
|
type: 'confirm',
|
|
131
145
|
name: 'shouldOpenTool',
|
|
132
|
-
message:
|
|
146
|
+
message: shouldEnterShell
|
|
147
|
+
? 'Open anything before entering the existing worktree shell?'
|
|
148
|
+
: 'Open anything in the existing worktree?',
|
|
133
149
|
default: true,
|
|
134
150
|
},
|
|
135
151
|
])).shouldOpenTool;
|
|
136
152
|
if (!shouldOpenExisting) {
|
|
137
|
-
|
|
153
|
+
if (shouldEnterShell) {
|
|
154
|
+
await enterCommand(existingBranchWorktree.path);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
log.header(`cd "${existingBranchWorktree.path}"`);
|
|
138
158
|
return;
|
|
139
159
|
}
|
|
140
160
|
const installedTools = await detectInstalledOpenTools();
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
161
|
+
log.info('Opening existing worktree instead of creating a duplicate...');
|
|
162
|
+
const actions = options.tool
|
|
163
|
+
? [await resolveOpenToolAction(options.tool, installedTools)].filter((action) => Boolean(action))
|
|
164
|
+
: await promptOpenActions(installedTools, {
|
|
165
|
+
allowShellAction: shouldEnterShell,
|
|
166
|
+
message: shouldEnterShell
|
|
167
|
+
? 'Open anything before entering the existing worktree shell?'
|
|
168
|
+
: 'Open anything in the existing worktree?',
|
|
169
|
+
});
|
|
170
|
+
if (options.tool && actions.length === 0) {
|
|
171
|
+
const available = installedTools.map(tool => tool.command).join(', ') || 'none detected';
|
|
172
|
+
log.error(`Tool "${options.tool}" not found.`);
|
|
173
|
+
log.dim(`Detected tool commands: ${available}`);
|
|
144
174
|
return;
|
|
145
175
|
}
|
|
146
|
-
|
|
147
|
-
log.info('Opening existing worktree instead of creating a duplicate...');
|
|
148
|
-
await launchOpenTool(selectedTool, existingManagedWorktree.path);
|
|
176
|
+
await runOpenActions(actions, existingBranchWorktree.path, { enter: shouldEnterShell });
|
|
149
177
|
log.success('Existing worktree opened.');
|
|
150
178
|
return;
|
|
151
179
|
}
|
|
@@ -170,26 +198,40 @@ export async function createCommand(options) {
|
|
|
170
198
|
{
|
|
171
199
|
type: 'confirm',
|
|
172
200
|
name: 'shouldOpenTool',
|
|
173
|
-
message:
|
|
201
|
+
message: shouldEnterShell
|
|
202
|
+
? 'Open anything before entering the worktree shell?'
|
|
203
|
+
: 'Open anything after creation?',
|
|
174
204
|
default: false,
|
|
175
|
-
when: options.exec === undefined && options.open === undefined && options.
|
|
205
|
+
when: options.exec === undefined && options.open === undefined && options.tool === undefined,
|
|
176
206
|
},
|
|
177
207
|
]);
|
|
178
208
|
const name = options.name || answers.name;
|
|
179
209
|
const shouldBootstrap = options.bootstrap !== undefined ? options.bootstrap : answers.bootstrap;
|
|
180
|
-
const shouldOpenTool = options.
|
|
181
|
-
?
|
|
182
|
-
: options.
|
|
183
|
-
? options.
|
|
210
|
+
const shouldOpenTool = options.tool
|
|
211
|
+
? true
|
|
212
|
+
: options.open !== undefined
|
|
213
|
+
? options.open
|
|
184
214
|
: Boolean(answers.shouldOpenTool);
|
|
185
|
-
let
|
|
215
|
+
let openActions = [];
|
|
186
216
|
if (options.exec === undefined && shouldOpenTool) {
|
|
187
217
|
const installedTools = await detectInstalledOpenTools();
|
|
188
|
-
if (
|
|
189
|
-
|
|
218
|
+
if (options.tool) {
|
|
219
|
+
const toolAction = await resolveOpenToolAction(options.tool, installedTools);
|
|
220
|
+
if (!toolAction) {
|
|
221
|
+
const available = installedTools.map(tool => tool.command).join(', ') || 'none detected';
|
|
222
|
+
log.error(`Tool "${options.tool}" not found.`);
|
|
223
|
+
log.dim(`Detected tool commands: ${available}`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
openActions = [toolAction];
|
|
190
227
|
}
|
|
191
228
|
else {
|
|
192
|
-
|
|
229
|
+
openActions = await promptOpenActions(installedTools, {
|
|
230
|
+
allowShellAction: shouldEnterShell,
|
|
231
|
+
message: shouldEnterShell
|
|
232
|
+
? 'Open anything before entering the worktree shell?'
|
|
233
|
+
: 'Open anything after creation?',
|
|
234
|
+
});
|
|
193
235
|
}
|
|
194
236
|
}
|
|
195
237
|
const slug = toSlug(name);
|
|
@@ -223,6 +265,7 @@ export async function createCommand(options) {
|
|
|
223
265
|
]);
|
|
224
266
|
return;
|
|
225
267
|
}
|
|
268
|
+
await promptAndCopyEnvFiles(repoRoot, wtPath);
|
|
226
269
|
if (shouldBootstrap) {
|
|
227
270
|
await runBootstrap(wtPath, repoRoot);
|
|
228
271
|
}
|
|
@@ -243,13 +286,12 @@ export async function createCommand(options) {
|
|
|
243
286
|
]);
|
|
244
287
|
}
|
|
245
288
|
}
|
|
246
|
-
else
|
|
289
|
+
else {
|
|
247
290
|
try {
|
|
248
|
-
|
|
249
|
-
await launchOpenTool(selectedTool, wtPath);
|
|
291
|
+
await runOpenActions(openActions, wtPath, { enter: shouldEnterShell });
|
|
250
292
|
}
|
|
251
293
|
catch (error) {
|
|
252
|
-
log.warning(`Could not
|
|
294
|
+
log.warning(`Could not complete post-checkout action: ${error.message}`);
|
|
253
295
|
}
|
|
254
296
|
}
|
|
255
297
|
// 7. Final Output
|
|
@@ -2,10 +2,10 @@ import inquirer from 'inquirer';
|
|
|
2
2
|
import { spawn } from 'child_process';
|
|
3
3
|
import { execa } from 'execa';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
|
-
import { listWorktrees
|
|
5
|
+
import { listWorktrees } from '../../lib/git.js';
|
|
6
6
|
import { log, ui } from '../../lib/ui.js';
|
|
7
|
+
import { ensureRepoContext } from '../../lib/repo-context.js';
|
|
7
8
|
import { detectWorktreeType, findWorktreeByName, formatWorktreeDisplayPath, formatWorktreeType, getWorktreeBranchName, } from '../../lib/worktree.js';
|
|
8
|
-
import { getValidRegisteredRepos } from '../../lib/registry.js';
|
|
9
9
|
function truncateEnd(value, maxLen) {
|
|
10
10
|
if (maxLen <= 0)
|
|
11
11
|
return '';
|
|
@@ -37,31 +37,7 @@ function formatChoiceLabel(type, branchName, displayPath, terminalColumns) {
|
|
|
37
37
|
}
|
|
38
38
|
export async function enterCommand(wtName, options = {}) {
|
|
39
39
|
try {
|
|
40
|
-
|
|
41
|
-
await getRepoRoot();
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
const validRepos = await getValidRegisteredRepos();
|
|
45
|
-
const repoNames = Object.keys(validRepos);
|
|
46
|
-
if (repoNames.length === 0) {
|
|
47
|
-
log.error('Not inside a git repository and no registered realms found.');
|
|
48
|
-
log.dim('Run `yggtree` inside an existing git project first to register it.');
|
|
49
|
-
process.exit(1);
|
|
50
|
-
}
|
|
51
|
-
console.log(chalk.bold('\n Not inside a realm. Pick a known one:'));
|
|
52
|
-
const { selectedRepoName } = await inquirer.prompt([{
|
|
53
|
-
type: 'list',
|
|
54
|
-
name: 'selectedRepoName',
|
|
55
|
-
message: 'Select a realm:',
|
|
56
|
-
choices: Object.entries(validRepos).map(([name, rPath]) => ({
|
|
57
|
-
name: `${chalk.bold.yellow(name)} ${chalk.dim(formatWorktreeDisplayPath(rPath))}`,
|
|
58
|
-
value: name,
|
|
59
|
-
})),
|
|
60
|
-
pageSize: 10,
|
|
61
|
-
}]);
|
|
62
|
-
const repoPath = validRepos[selectedRepoName];
|
|
63
|
-
process.chdir(repoPath);
|
|
64
|
-
}
|
|
40
|
+
await ensureRepoContext();
|
|
65
41
|
const worktrees = await listWorktrees();
|
|
66
42
|
const mainWorktreePath = worktrees[0]?.path || '';
|
|
67
43
|
if (worktrees.length === 0) {
|
package/dist/commands/wt/open.js
CHANGED
|
@@ -9,20 +9,26 @@ import { log, ui } from '../../lib/ui.js';
|
|
|
9
9
|
import { ensureAutocompletePrompt } from '../../lib/prompt.js';
|
|
10
10
|
import { detectWorktreeType, findWorktreeByName, formatWorktreeDisplayPath, formatWorktreeType, getWorktreeBranchName, } from '../../lib/worktree.js';
|
|
11
11
|
import { enterCommand } from './enter.js';
|
|
12
|
-
const OPEN_TOOL_CANDIDATES = [
|
|
13
|
-
{ id: 'cursor', name: 'Cursor', command: 'cursor', kind: '
|
|
14
|
-
{ id: 'code', name: 'VS Code', command: 'code', kind: '
|
|
15
|
-
{ id: 'windsurf', name: 'Windsurf', command: 'windsurf', kind: '
|
|
16
|
-
{ id: 'zed', name: 'Zed', command: 'zed', kind: '
|
|
17
|
-
{ id: '
|
|
18
|
-
{ id: '
|
|
19
|
-
{ id: '
|
|
20
|
-
{
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
12
|
+
export const OPEN_TOOL_CANDIDATES = [
|
|
13
|
+
{ id: 'cursor', name: 'Cursor', command: 'cursor', kind: 'editor', aliases: ['cursor'] },
|
|
14
|
+
{ id: 'code', name: 'VS Code', command: 'code', kind: 'editor', aliases: ['vscode', 'code'] },
|
|
15
|
+
{ id: 'windsurf', name: 'Windsurf', command: 'windsurf', kind: 'editor', aliases: ['windsurf'] },
|
|
16
|
+
{ id: 'zed', name: 'Zed', command: 'zed', kind: 'editor', aliases: ['zed'] },
|
|
17
|
+
{ id: 'idea', name: 'IntelliJ IDEA', command: 'idea', kind: 'editor', aliases: ['idea', 'intellij'] },
|
|
18
|
+
{ id: 'webstorm', name: 'WebStorm', command: 'webstorm', kind: 'editor', aliases: ['webstorm'] },
|
|
19
|
+
{ id: 'subl', name: 'Sublime Text', command: 'subl', kind: 'editor', aliases: ['subl', 'sublime'] },
|
|
20
|
+
{
|
|
21
|
+
id: 'codex-app',
|
|
22
|
+
name: 'Codex App',
|
|
23
|
+
command: 'codex-app',
|
|
24
|
+
kind: 'app',
|
|
25
|
+
aliases: ['codex', 'codex-app'],
|
|
26
|
+
bundleId: 'com.openai.codex',
|
|
27
|
+
},
|
|
25
28
|
];
|
|
29
|
+
const OTHER_COMMAND_ACTION = '__other_command__';
|
|
30
|
+
const NO_OPEN_ACTION = '__no_open__';
|
|
31
|
+
const yggtreeAccent = chalk.hex('#DEADED');
|
|
26
32
|
function truncateEnd(value, maxLen) {
|
|
27
33
|
if (maxLen <= 0)
|
|
28
34
|
return '';
|
|
@@ -52,6 +58,29 @@ function formatWorktreeChoiceLabel(type, branchName, displayPath, terminalColumn
|
|
|
52
58
|
const typeText = formatWorktreeType(type);
|
|
53
59
|
return `${typeText} ${chalk.yellow(branchText)} ${chalk.cyan(pathText)}`;
|
|
54
60
|
}
|
|
61
|
+
function openRail() {
|
|
62
|
+
return yggtreeAccent('│');
|
|
63
|
+
}
|
|
64
|
+
function openSectionLabel(label) {
|
|
65
|
+
return `${yggtreeAccent('┌')} ${chalk.bold(label)}`;
|
|
66
|
+
}
|
|
67
|
+
function formatOpenColumns(name, command, detail) {
|
|
68
|
+
const paddedName = name.padEnd(16);
|
|
69
|
+
const paddedCommand = command.padEnd(13);
|
|
70
|
+
const row = `${openRail()} ${chalk.bold(paddedName)} ${chalk.cyan(paddedCommand)}`;
|
|
71
|
+
return detail ? `${row} ${chalk.dim(detail)}` : row;
|
|
72
|
+
}
|
|
73
|
+
export function formatOpenToolChoice(tool) {
|
|
74
|
+
return formatOpenColumns(tool.name, tool.command, '');
|
|
75
|
+
}
|
|
76
|
+
export function formatOpenSpecialChoice(value, allowShellAction) {
|
|
77
|
+
if (value === OTHER_COMMAND_ACTION) {
|
|
78
|
+
return formatOpenColumns('Other command', 'custom', 'run first, then stay in shell');
|
|
79
|
+
}
|
|
80
|
+
return allowShellAction
|
|
81
|
+
? formatOpenColumns('Nothing', 'skip', 'just enter the shell')
|
|
82
|
+
: formatOpenColumns('Do not open', 'skip', 'return after selection');
|
|
83
|
+
}
|
|
55
84
|
export async function commandExists(command) {
|
|
56
85
|
if (!command)
|
|
57
86
|
return false;
|
|
@@ -80,10 +109,29 @@ export async function commandExists(command) {
|
|
|
80
109
|
}
|
|
81
110
|
return false;
|
|
82
111
|
}
|
|
112
|
+
export async function macOSAppBundleExists(bundleId) {
|
|
113
|
+
if (process.platform !== 'darwin')
|
|
114
|
+
return false;
|
|
115
|
+
try {
|
|
116
|
+
const result = await execa('/usr/bin/mdfind', [`kMDItemCFBundleIdentifier == '${bundleId}'`]);
|
|
117
|
+
if (result.stdout.trim().length > 0)
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Fall back to common application roots when Spotlight is unavailable.
|
|
122
|
+
}
|
|
123
|
+
const commonAppPaths = [
|
|
124
|
+
'/Applications/Codex.app',
|
|
125
|
+
path.join(process.env.HOME || '', 'Applications', 'Codex.app'),
|
|
126
|
+
];
|
|
127
|
+
return commonAppPaths.some(appPath => fs.existsSync(appPath));
|
|
128
|
+
}
|
|
83
129
|
export async function detectInstalledOpenTools() {
|
|
84
130
|
const checks = await Promise.all(OPEN_TOOL_CANDIDATES.map(async (tool) => ({
|
|
85
131
|
tool,
|
|
86
|
-
exists:
|
|
132
|
+
exists: tool.bundleId
|
|
133
|
+
? await macOSAppBundleExists(tool.bundleId)
|
|
134
|
+
: await commandExists(tool.command),
|
|
87
135
|
})));
|
|
88
136
|
return checks
|
|
89
137
|
.filter(check => check.exists)
|
|
@@ -98,35 +146,39 @@ export function resolveOpenToolOption(input, installed) {
|
|
|
98
146
|
tool.command.toLowerCase() === normalized ||
|
|
99
147
|
tool.aliases?.some(alias => alias.toLowerCase() === normalized));
|
|
100
148
|
}
|
|
101
|
-
export function
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
149
|
+
export async function resolveOpenToolAction(input, installed) {
|
|
150
|
+
let chosenTool = resolveOpenToolOption(input, installed);
|
|
151
|
+
if (!chosenTool && await commandExists(input)) {
|
|
152
|
+
chosenTool = {
|
|
153
|
+
id: 'custom',
|
|
154
|
+
name: input,
|
|
155
|
+
command: input,
|
|
156
|
+
kind: 'editor',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return chosenTool ? { type: 'tool', tool: chosenTool } : undefined;
|
|
106
160
|
}
|
|
107
161
|
export async function promptOpenToolSelection(installedTools, message = 'Select tool to open:') {
|
|
108
162
|
const ideChoices = installedTools
|
|
109
|
-
.filter(tool => tool.kind === '
|
|
163
|
+
.filter(tool => tool.kind === 'editor')
|
|
110
164
|
.map(tool => ({
|
|
111
|
-
name:
|
|
165
|
+
name: formatOpenToolChoice(tool),
|
|
112
166
|
value: tool,
|
|
113
167
|
}));
|
|
114
|
-
const
|
|
115
|
-
.filter(tool => tool.kind === '
|
|
168
|
+
const appChoices = installedTools
|
|
169
|
+
.filter(tool => tool.kind === 'app')
|
|
116
170
|
.map(tool => ({
|
|
117
|
-
name:
|
|
171
|
+
name: formatOpenToolChoice(tool),
|
|
118
172
|
value: tool,
|
|
119
173
|
}));
|
|
120
174
|
const choices = [];
|
|
121
175
|
if (ideChoices.length > 0) {
|
|
122
|
-
choices.push(new inquirer.Separator(
|
|
176
|
+
choices.push(new inquirer.Separator(openSectionLabel('Editors & IDEs')));
|
|
123
177
|
choices.push(...ideChoices);
|
|
124
178
|
}
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
choices.push(new inquirer.Separator(chalk.dim('── Agent CLIs ──')));
|
|
129
|
-
choices.push(...agentChoices);
|
|
179
|
+
if (appChoices.length > 0) {
|
|
180
|
+
choices.push(new inquirer.Separator(openSectionLabel('Apps')));
|
|
181
|
+
choices.push(...appChoices);
|
|
130
182
|
}
|
|
131
183
|
const { selectedTool } = await inquirer.prompt([
|
|
132
184
|
{
|
|
@@ -140,16 +192,121 @@ export async function promptOpenToolSelection(installedTools, message = 'Select
|
|
|
140
192
|
]);
|
|
141
193
|
return selectedTool;
|
|
142
194
|
}
|
|
143
|
-
export
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
|
|
195
|
+
export function buildOpenActionsFromSelection(selectedValues, installedTools, otherCommand) {
|
|
196
|
+
if (selectedValues.includes(NO_OPEN_ACTION))
|
|
197
|
+
return [];
|
|
198
|
+
const actions = selectedValues
|
|
199
|
+
.filter(value => value.startsWith('tool:'))
|
|
200
|
+
.map(value => installedTools.find(tool => `tool:${tool.id}` === value))
|
|
201
|
+
.filter((tool) => Boolean(tool))
|
|
202
|
+
.map(tool => ({ type: 'tool', tool }));
|
|
203
|
+
if (selectedValues.includes(OTHER_COMMAND_ACTION) && otherCommand?.trim()) {
|
|
204
|
+
actions.push({ type: 'other-command', command: otherCommand.trim() });
|
|
205
|
+
}
|
|
206
|
+
return actions;
|
|
207
|
+
}
|
|
208
|
+
export function validateOpenActionSelection(selectedValues) {
|
|
209
|
+
if (selectedValues.includes(NO_OPEN_ACTION) && selectedValues.length > 1) {
|
|
210
|
+
return 'Choose either "Nothing" or one or more actions, not both.';
|
|
211
|
+
}
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
export async function promptOpenActions(installedTools, options = {}) {
|
|
215
|
+
const allowShellAction = options.allowShellAction ?? true;
|
|
216
|
+
const choices = [];
|
|
217
|
+
if (installedTools.length > 0) {
|
|
218
|
+
const editorTools = installedTools.filter(tool => tool.kind === 'editor');
|
|
219
|
+
const appTools = installedTools.filter(tool => tool.kind === 'app');
|
|
220
|
+
if (editorTools.length > 0) {
|
|
221
|
+
choices.push(new inquirer.Separator(openSectionLabel('Editors & IDEs')));
|
|
222
|
+
choices.push(...editorTools.map(tool => ({
|
|
223
|
+
name: formatOpenToolChoice(tool),
|
|
224
|
+
value: `tool:${tool.id}`,
|
|
225
|
+
})));
|
|
226
|
+
}
|
|
227
|
+
if (appTools.length > 0) {
|
|
228
|
+
choices.push(new inquirer.Separator(openSectionLabel('Apps')));
|
|
229
|
+
choices.push(...appTools.map(tool => ({
|
|
230
|
+
name: formatOpenToolChoice(tool),
|
|
231
|
+
value: `tool:${tool.id}`,
|
|
232
|
+
})));
|
|
233
|
+
}
|
|
147
234
|
}
|
|
148
|
-
|
|
235
|
+
if (allowShellAction) {
|
|
236
|
+
if (choices.length > 0)
|
|
237
|
+
choices.push(new inquirer.Separator(' '));
|
|
238
|
+
choices.push(new inquirer.Separator(openSectionLabel('Shell')));
|
|
239
|
+
choices.push({
|
|
240
|
+
name: formatOpenSpecialChoice(OTHER_COMMAND_ACTION, allowShellAction),
|
|
241
|
+
value: OTHER_COMMAND_ACTION,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
if (choices.length > 0)
|
|
245
|
+
choices.push(new inquirer.Separator(' '));
|
|
246
|
+
choices.push({
|
|
247
|
+
name: formatOpenSpecialChoice(NO_OPEN_ACTION, allowShellAction),
|
|
248
|
+
value: NO_OPEN_ACTION,
|
|
249
|
+
});
|
|
250
|
+
const { selectedValues } = await inquirer.prompt([
|
|
251
|
+
{
|
|
252
|
+
type: 'checkbox',
|
|
253
|
+
name: 'selectedValues',
|
|
254
|
+
message: options.message || 'Open anything before entering the shell?',
|
|
255
|
+
choices,
|
|
256
|
+
loop: false,
|
|
257
|
+
pageSize: 10,
|
|
258
|
+
validate: validateOpenActionSelection,
|
|
259
|
+
},
|
|
260
|
+
]);
|
|
261
|
+
let otherCommand;
|
|
262
|
+
if (selectedValues.includes(OTHER_COMMAND_ACTION)) {
|
|
263
|
+
const answer = await inquirer.prompt([
|
|
264
|
+
{
|
|
265
|
+
type: 'input',
|
|
266
|
+
name: 'otherCommand',
|
|
267
|
+
message: 'Command to run in the worktree shell:',
|
|
268
|
+
validate: (input) => input.trim().length > 0 || 'Command is required',
|
|
269
|
+
},
|
|
270
|
+
]);
|
|
271
|
+
otherCommand = answer.otherCommand;
|
|
272
|
+
}
|
|
273
|
+
return buildOpenActionsFromSelection(selectedValues, installedTools, otherCommand);
|
|
274
|
+
}
|
|
275
|
+
export async function launchOpenTool(tool, wtPath) {
|
|
276
|
+
const { executable, args } = buildOpenToolLaunchCommand(tool, wtPath);
|
|
277
|
+
await execa(executable, args, {
|
|
149
278
|
cwd: wtPath,
|
|
150
279
|
stdio: 'ignore',
|
|
151
280
|
});
|
|
152
281
|
}
|
|
282
|
+
export function buildOpenToolLaunchCommand(tool, wtPath) {
|
|
283
|
+
if (tool.bundleId) {
|
|
284
|
+
return {
|
|
285
|
+
executable: '/usr/bin/open',
|
|
286
|
+
args: ['-b', tool.bundleId, wtPath],
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
executable: tool.command,
|
|
291
|
+
args: [wtPath],
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
export async function runOpenActions(actions, wtPath, options = {}) {
|
|
295
|
+
for (const action of actions) {
|
|
296
|
+
if (action.type === 'tool' && action.tool) {
|
|
297
|
+
log.info(`Opening ${ui.path(wtPath)} in ${chalk.cyan(action.tool.name)}...`);
|
|
298
|
+
await launchOpenTool(action.tool, wtPath);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const shellAction = actions.find(action => action.type === 'other-command');
|
|
302
|
+
if (shellAction?.command) {
|
|
303
|
+
await enterCommand(wtPath, { exec: shellAction.command });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (options.enter !== false) {
|
|
307
|
+
await enterCommand(wtPath);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
153
310
|
export async function openCommand(wtName, options = {}) {
|
|
154
311
|
try {
|
|
155
312
|
await getRepoRoot();
|
|
@@ -208,36 +365,33 @@ export async function openCommand(wtName, options = {}) {
|
|
|
208
365
|
return;
|
|
209
366
|
}
|
|
210
367
|
const installedTools = await detectInstalledOpenTools();
|
|
211
|
-
let chosenTool;
|
|
212
368
|
const requestedTool = options.tool;
|
|
369
|
+
const shouldEnter = options.enter === true;
|
|
213
370
|
if (requestedTool) {
|
|
214
|
-
|
|
215
|
-
if (!
|
|
216
|
-
chosenTool = {
|
|
217
|
-
id: 'custom',
|
|
218
|
-
name: requestedTool,
|
|
219
|
-
command: requestedTool,
|
|
220
|
-
kind: 'ide',
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
if (!chosenTool) {
|
|
371
|
+
const toolAction = await resolveOpenToolAction(requestedTool, installedTools);
|
|
372
|
+
if (!toolAction) {
|
|
224
373
|
const available = installedTools.map(tool => tool.command).join(', ') || 'none detected';
|
|
225
374
|
log.error(`Tool "${requestedTool}" not found.`);
|
|
226
375
|
log.dim(`Detected tool commands: ${available}`);
|
|
227
376
|
return;
|
|
228
377
|
}
|
|
378
|
+
await runOpenActions([toolAction], targetWt.path, { enter: shouldEnter });
|
|
379
|
+
log.success('Worktree opened.');
|
|
380
|
+
return;
|
|
229
381
|
}
|
|
230
382
|
else {
|
|
231
|
-
if (installedTools.length === 0) {
|
|
232
|
-
log.error('No supported
|
|
233
|
-
log.dim('Try: yggtree
|
|
383
|
+
if (installedTools.length === 0 && !shouldEnter) {
|
|
384
|
+
log.error('No supported editor command or app was detected.');
|
|
385
|
+
log.dim('Try: yggtree open --tool <command> (e.g. --tool cursor or --tool codex-app), or yggtree open --enter');
|
|
234
386
|
return;
|
|
235
387
|
}
|
|
236
|
-
|
|
388
|
+
const actions = await promptOpenActions(installedTools, {
|
|
389
|
+
allowShellAction: shouldEnter,
|
|
390
|
+
message: shouldEnter ? 'Open anything before entering the shell?' : 'Open anything?',
|
|
391
|
+
});
|
|
392
|
+
await runOpenActions(actions, targetWt.path, { enter: shouldEnter });
|
|
237
393
|
}
|
|
238
|
-
log.
|
|
239
|
-
await launchOpenTool(chosenTool, targetWt.path);
|
|
240
|
-
log.success(isAgentTool(chosenTool) ? 'Agent launched.' : 'IDE launched.');
|
|
394
|
+
log.success('Worktree opened.');
|
|
241
395
|
}
|
|
242
396
|
catch (error) {
|
|
243
397
|
log.error(error.message);
|