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.
@@ -1,13 +1,15 @@
1
1
  import chalk from 'chalk';
2
2
  import inquirer from 'inquirer';
3
3
  import path from 'path';
4
- import { getRepoRoot, getRepoName, createWorktree, fetchAll, listWorktrees } from '../../lib/git.js';
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, launchOpenTool, promptOpenToolSelection, } from './open.js';
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 remoteOnly = [...new Set(remoteBranches)].filter(branch => !localSet.has(branch));
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 = remoteOnly.map(branchName => ({
46
+ const remoteCandidates = uniqueRemoteBranches.map(branchName => ({
43
47
  branchName,
44
48
  checkoutRef: `origin/${branchName}`,
45
- createLocalBranch: true,
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 getRepoRoot();
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.branchName)} ${chalk.dim(`(${candidate.source})`)}`,
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 existingManagedWorktree = existingWorktrees.find(wt => wt.branch === selectedBranch.branchName && wt.path.startsWith(WORKTREES_ROOT));
117
- if (existingManagedWorktree) {
118
- log.info(`Branch ${chalk.cyan(selectedBranch.branchName)} is already active in ${ui.path(existingManagedWorktree.path)}.`);
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(existingManagedWorktree.path, { exec: options.exec });
135
+ await enterCommand(existingBranchWorktree.path, { exec: options.exec });
122
136
  return;
123
137
  }
124
- const shouldOpenExisting = options.open !== undefined
125
- ? options.open
126
- : options.enter !== undefined
127
- ? options.enter
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: 'Open a tool in the existing worktree now? (IDE or agent CLI)',
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
- log.header(`cd "${existingManagedWorktree.path}"`);
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
- if (installedTools.length === 0) {
142
- log.warning('No IDE/agent tool detected in PATH. Skipping open step.');
143
- log.header(`cd "${existingManagedWorktree.path}"`);
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
- const selectedTool = await promptOpenToolSelection(installedTools, 'Select tool to open in the existing worktree:');
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: 'Open a tool after creation? (IDE or agent CLI)',
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.enter === undefined,
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.open !== undefined
181
- ? options.open
182
- : options.enter !== undefined
183
- ? options.enter
210
+ const shouldOpenTool = options.tool
211
+ ? true
212
+ : options.open !== undefined
213
+ ? options.open
184
214
  : Boolean(answers.shouldOpenTool);
185
- let selectedTool;
215
+ let openActions = [];
186
216
  if (options.exec === undefined && shouldOpenTool) {
187
217
  const installedTools = await detectInstalledOpenTools();
188
- if (installedTools.length === 0) {
189
- log.warning('No IDE/agent tool detected in PATH. Skipping open step.');
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
- selectedTool = await promptOpenToolSelection(installedTools, 'Select tool to open:');
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 if (selectedTool) {
289
+ else {
247
290
  try {
248
- log.info(`Opening ${ui.path(wtPath)} in ${chalk.cyan(selectedTool.name)}...`);
249
- await launchOpenTool(selectedTool, wtPath);
291
+ await runOpenActions(openActions, wtPath, { enter: shouldEnterShell });
250
292
  }
251
293
  catch (error) {
252
- log.warning(`Could not open ${selectedTool.name}: ${error.message}`);
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, getRepoRoot } from '../../lib/git.js';
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
- try {
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) {
@@ -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: 'ide', aliases: ['cursor'] },
14
- { id: 'code', name: 'VS Code', command: 'code', kind: 'ide', aliases: ['vscode', 'code'] },
15
- { id: 'windsurf', name: 'Windsurf', command: 'windsurf', kind: 'ide', aliases: ['windsurf'] },
16
- { id: 'zed', name: 'Zed', command: 'zed', kind: 'ide', aliases: ['zed'] },
17
- { id: 'agy', name: 'Agy', command: 'agy', kind: 'ide', aliases: ['agy'] },
18
- { id: 'idea', name: 'IntelliJ IDEA', command: 'idea', kind: 'ide', aliases: ['idea', 'intellij'] },
19
- { id: 'webstorm', name: 'WebStorm', command: 'webstorm', kind: 'ide', aliases: ['webstorm'] },
20
- { id: 'subl', name: 'Sublime Text', command: 'subl', kind: 'ide', aliases: ['subl', 'sublime'] },
21
- { id: 'claude', name: 'Claude Code', command: 'claude', kind: 'agent', aliases: ['claude', 'claude-code'] },
22
- { id: 'codex', name: 'Codex', command: 'codex', kind: 'agent', aliases: ['codex'] },
23
- { id: 'gemini', name: 'Gemini CLI', command: 'gemini', kind: 'agent', aliases: ['gemini'] },
24
- { id: 'opencode', name: 'OpenCode', command: 'opencode', kind: 'agent', aliases: ['opencode'] },
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: await commandExists(tool.command),
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 isAgentTool(tool) {
102
- return tool.kind === 'agent';
103
- }
104
- export function buildAgentExecCommand(tool) {
105
- return tool.command;
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 === 'ide')
163
+ .filter(tool => tool.kind === 'editor')
110
164
  .map(tool => ({
111
- name: `${tool.name} ${chalk.dim(`(${tool.command})`)}`,
165
+ name: formatOpenToolChoice(tool),
112
166
  value: tool,
113
167
  }));
114
- const agentChoices = installedTools
115
- .filter(tool => tool.kind === 'agent')
168
+ const appChoices = installedTools
169
+ .filter(tool => tool.kind === 'app')
116
170
  .map(tool => ({
117
- name: `${tool.name} ${chalk.dim(`(${tool.command})`)}`,
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(chalk.dim('── IDEs ──')));
176
+ choices.push(new inquirer.Separator(openSectionLabel('Editors & IDEs')));
123
177
  choices.push(...ideChoices);
124
178
  }
125
- if (agentChoices.length > 0) {
126
- if (choices.length > 0)
127
- choices.push(new inquirer.Separator(" "));
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 async function launchOpenTool(tool, wtPath) {
144
- if (isAgentTool(tool)) {
145
- await enterCommand(wtPath, { exec: buildAgentExecCommand(tool) });
146
- return;
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
- await execa(tool.command, [wtPath], {
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
- chosenTool = resolveOpenToolOption(requestedTool, installedTools);
215
- if (!chosenTool && await commandExists(requestedTool)) {
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 open tool command was detected in PATH.');
233
- log.dim('Try: yggtree wt open --tool <command> (e.g. --tool cursor, --tool claude)');
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
- chosenTool = await promptOpenToolSelection(installedTools);
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.info(`Opening ${ui.path(targetWt.path)} in ${chalk.cyan(chosenTool.name)}...`);
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);