yggtree 1.4.2 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,20 +9,30 @@ 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
+ },
28
+ { id: 'cmux', name: 'Cmux Panel', command: 'cmux', kind: 'terminal', aliases: ['cmux', 'cmux-panel'] },
29
+ { id: 'tmux', name: 'Tmux Session', command: 'tmux', kind: 'terminal', aliases: ['tmux', 'tmux-session'] },
25
30
  ];
31
+ const OTHER_COMMAND_ACTION = '__other_command__';
32
+ const NO_OPEN_ACTION = '__no_open__';
33
+ const OPEN_ACTION_PAGE_SIZE = 16;
34
+ const CMUX_TERMINAL_STARTUP_DELAY_MS = 350;
35
+ const yggtreeAccent = chalk.hex('#DEADED');
26
36
  function truncateEnd(value, maxLen) {
27
37
  if (maxLen <= 0)
28
38
  return '';
@@ -52,6 +62,71 @@ function formatWorktreeChoiceLabel(type, branchName, displayPath, terminalColumn
52
62
  const typeText = formatWorktreeType(type);
53
63
  return `${typeText} ${chalk.yellow(branchText)} ${chalk.cyan(pathText)}`;
54
64
  }
65
+ function openRail() {
66
+ return yggtreeAccent('│');
67
+ }
68
+ function formatOpenColumns(name, command, detail) {
69
+ const paddedName = name.padEnd(16);
70
+ const paddedCommand = command.padEnd(13);
71
+ const row = `${openRail()} ${chalk.bold(paddedName)} ${chalk.cyan(paddedCommand)}`;
72
+ return detail ? `${row} ${chalk.dim(detail)}` : row;
73
+ }
74
+ export function formatOpenToolChoice(tool) {
75
+ return formatOpenColumns(tool.name, tool.command, '');
76
+ }
77
+ function shellQuote(value) {
78
+ return `'${value.replace(/'/g, `'\\''`)}'`;
79
+ }
80
+ function formatTerminalSessionName(wtPath) {
81
+ const basename = path.basename(wtPath) || 'worktree';
82
+ const slug = basename.toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
83
+ return `yggtree-${slug || 'worktree'}`.slice(0, 80);
84
+ }
85
+ export function buildTmuxLaunchCommand(wtPath, env = process.env) {
86
+ const sessionName = formatTerminalSessionName(wtPath);
87
+ if (env.TMUX) {
88
+ return {
89
+ executable: 'tmux',
90
+ args: ['new-window', '-c', wtPath, '-n', sessionName],
91
+ };
92
+ }
93
+ return {
94
+ executable: 'tmux',
95
+ args: ['new-session', '-A', '-s', sessionName, '-c', wtPath],
96
+ };
97
+ }
98
+ export function buildCmuxNewPaneCommand() {
99
+ return {
100
+ executable: 'cmux',
101
+ args: ['new-pane', '--type', 'terminal', '--direction', 'right', '--focus', 'true'],
102
+ };
103
+ }
104
+ export function parseCmuxSurfaceRef(output) {
105
+ return output.match(/\bsurface:\S+/)?.[0];
106
+ }
107
+ function delay(ms) {
108
+ return new Promise(resolve => setTimeout(resolve, ms));
109
+ }
110
+ export function buildCmuxSendCommand(surfaceRef, wtPath) {
111
+ return {
112
+ executable: 'cmux',
113
+ args: ['send', '--surface', surfaceRef, `cd ${shellQuote(wtPath)}`],
114
+ };
115
+ }
116
+ export function buildCmuxEnterCommand(surfaceRef) {
117
+ return {
118
+ executable: 'cmux',
119
+ args: ['send-key', '--surface', surfaceRef, 'Enter'],
120
+ };
121
+ }
122
+ export function formatOpenSpecialChoice(value, allowShellAction) {
123
+ if (value === OTHER_COMMAND_ACTION) {
124
+ return formatOpenColumns('Other command', 'custom', 'run first, then stay in shell');
125
+ }
126
+ return allowShellAction
127
+ ? formatOpenColumns('Nothing', 'skip', 'just enter the shell')
128
+ : formatOpenColumns('Do not open', 'skip', 'return after selection');
129
+ }
55
130
  export async function commandExists(command) {
56
131
  if (!command)
57
132
  return false;
@@ -80,10 +155,29 @@ export async function commandExists(command) {
80
155
  }
81
156
  return false;
82
157
  }
158
+ export async function macOSAppBundleExists(bundleId) {
159
+ if (process.platform !== 'darwin')
160
+ return false;
161
+ try {
162
+ const result = await execa('/usr/bin/mdfind', [`kMDItemCFBundleIdentifier == '${bundleId}'`]);
163
+ if (result.stdout.trim().length > 0)
164
+ return true;
165
+ }
166
+ catch {
167
+ // Fall back to common application roots when Spotlight is unavailable.
168
+ }
169
+ const commonAppPaths = [
170
+ '/Applications/Codex.app',
171
+ path.join(process.env.HOME || '', 'Applications', 'Codex.app'),
172
+ ];
173
+ return commonAppPaths.some(appPath => fs.existsSync(appPath));
174
+ }
83
175
  export async function detectInstalledOpenTools() {
84
176
  const checks = await Promise.all(OPEN_TOOL_CANDIDATES.map(async (tool) => ({
85
177
  tool,
86
- exists: await commandExists(tool.command),
178
+ exists: tool.bundleId
179
+ ? await macOSAppBundleExists(tool.bundleId)
180
+ : await commandExists(tool.command),
87
181
  })));
88
182
  return checks
89
183
  .filter(check => check.exists)
@@ -98,36 +192,39 @@ export function resolveOpenToolOption(input, installed) {
98
192
  tool.command.toLowerCase() === normalized ||
99
193
  tool.aliases?.some(alias => alias.toLowerCase() === normalized));
100
194
  }
101
- export function isAgentTool(tool) {
102
- return tool.kind === 'agent';
195
+ export function resolveOpenToolCandidate(input) {
196
+ const normalized = input.trim().toLowerCase();
197
+ return OPEN_TOOL_CANDIDATES.find(tool => tool.id === normalized ||
198
+ tool.command.toLowerCase() === normalized ||
199
+ tool.aliases?.some(alias => alias.toLowerCase() === normalized));
103
200
  }
104
- export function buildAgentExecCommand(tool) {
105
- return tool.command;
201
+ async function resolveKnownOpenToolOption(input) {
202
+ const candidate = resolveOpenToolCandidate(input);
203
+ if (!candidate)
204
+ return undefined;
205
+ const exists = candidate.bundleId
206
+ ? await macOSAppBundleExists(candidate.bundleId)
207
+ : await commandExists(candidate.command);
208
+ return exists ? candidate : undefined;
209
+ }
210
+ export async function resolveOpenToolAction(input, installed) {
211
+ let chosenTool = resolveOpenToolOption(input, installed) || await resolveKnownOpenToolOption(input);
212
+ if (!chosenTool && await commandExists(input)) {
213
+ chosenTool = {
214
+ id: 'custom',
215
+ name: input,
216
+ command: input,
217
+ kind: 'editor',
218
+ };
219
+ }
220
+ return chosenTool ? { type: 'tool', tool: chosenTool } : undefined;
106
221
  }
107
222
  export async function promptOpenToolSelection(installedTools, message = 'Select tool to open:') {
108
- const ideChoices = installedTools
109
- .filter(tool => tool.kind === 'ide')
223
+ const choices = installedTools
110
224
  .map(tool => ({
111
- name: `${tool.name} ${chalk.dim(`(${tool.command})`)}`,
225
+ name: formatOpenToolChoice(tool),
112
226
  value: tool,
113
227
  }));
114
- const agentChoices = installedTools
115
- .filter(tool => tool.kind === 'agent')
116
- .map(tool => ({
117
- name: `${tool.name} ${chalk.dim(`(${tool.command})`)}`,
118
- value: tool,
119
- }));
120
- const choices = [];
121
- if (ideChoices.length > 0) {
122
- choices.push(new inquirer.Separator(chalk.dim('── IDEs ──')));
123
- choices.push(...ideChoices);
124
- }
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);
130
- }
131
228
  const { selectedTool } = await inquirer.prompt([
132
229
  {
133
230
  type: 'list',
@@ -135,21 +232,136 @@ export async function promptOpenToolSelection(installedTools, message = 'Select
135
232
  message,
136
233
  choices,
137
234
  loop: false,
138
- pageSize: 10,
235
+ pageSize: OPEN_ACTION_PAGE_SIZE,
139
236
  },
140
237
  ]);
141
238
  return selectedTool;
142
239
  }
240
+ export function buildOpenActionsFromSelection(selectedValue, installedTools, otherCommand) {
241
+ if (selectedValue === NO_OPEN_ACTION)
242
+ return [];
243
+ if (selectedValue.startsWith('tool:')) {
244
+ const tool = installedTools.find(candidate => `tool:${candidate.id}` === selectedValue);
245
+ return tool ? [{ type: 'tool', tool }] : [];
246
+ }
247
+ if (selectedValue === OTHER_COMMAND_ACTION && otherCommand?.trim()) {
248
+ return [{ type: 'other-command', command: otherCommand.trim() }];
249
+ }
250
+ return [];
251
+ }
252
+ export function buildOpenActionChoices(installedTools, allowShellAction) {
253
+ const choices = installedTools.map(tool => ({
254
+ name: formatOpenToolChoice(tool),
255
+ value: `tool:${tool.id}`,
256
+ }));
257
+ if (allowShellAction) {
258
+ choices.push({
259
+ name: formatOpenSpecialChoice(OTHER_COMMAND_ACTION, allowShellAction),
260
+ value: OTHER_COMMAND_ACTION,
261
+ });
262
+ }
263
+ choices.push({
264
+ name: formatOpenSpecialChoice(NO_OPEN_ACTION, allowShellAction),
265
+ value: NO_OPEN_ACTION,
266
+ });
267
+ return choices;
268
+ }
269
+ export async function promptOpenActions(installedTools, options = {}) {
270
+ const allowShellAction = options.allowShellAction ?? true;
271
+ const choices = buildOpenActionChoices(installedTools, allowShellAction);
272
+ const { selectedValue } = await inquirer.prompt([
273
+ {
274
+ type: 'list',
275
+ name: 'selectedValue',
276
+ message: options.message || 'Open anything before entering the shell?',
277
+ choices,
278
+ loop: false,
279
+ pageSize: OPEN_ACTION_PAGE_SIZE,
280
+ },
281
+ ]);
282
+ let otherCommand;
283
+ if (selectedValue === OTHER_COMMAND_ACTION) {
284
+ const answer = await inquirer.prompt([
285
+ {
286
+ type: 'input',
287
+ name: 'otherCommand',
288
+ message: 'Command to run in the worktree shell:',
289
+ validate: (input) => input.trim().length > 0 || 'Command is required',
290
+ },
291
+ ]);
292
+ otherCommand = answer.otherCommand;
293
+ }
294
+ return buildOpenActionsFromSelection(selectedValue, installedTools, otherCommand);
295
+ }
143
296
  export async function launchOpenTool(tool, wtPath) {
144
- if (isAgentTool(tool)) {
145
- await enterCommand(wtPath, { exec: buildAgentExecCommand(tool) });
297
+ if (tool.id === 'cmux') {
298
+ const newPaneCommand = buildCmuxNewPaneCommand();
299
+ const newPaneResult = await execa(newPaneCommand.executable, newPaneCommand.args, {
300
+ cwd: wtPath,
301
+ });
302
+ const surfaceRef = parseCmuxSurfaceRef(newPaneResult.stdout);
303
+ if (!surfaceRef) {
304
+ throw new Error(`Cmux did not return a terminal surface handle: ${newPaneResult.stdout || '<empty output>'}`);
305
+ }
306
+ await delay(CMUX_TERMINAL_STARTUP_DELAY_MS);
307
+ const sendCommand = buildCmuxSendCommand(surfaceRef, wtPath);
308
+ await execa(sendCommand.executable, sendCommand.args, {
309
+ cwd: wtPath,
310
+ stdio: 'ignore',
311
+ });
312
+ const enterCommand = buildCmuxEnterCommand(surfaceRef);
313
+ await execa(enterCommand.executable, enterCommand.args, {
314
+ cwd: wtPath,
315
+ stdio: 'ignore',
316
+ });
317
+ return;
318
+ }
319
+ if (tool.id === 'tmux') {
320
+ const { executable, args } = buildTmuxLaunchCommand(wtPath);
321
+ await execa(executable, args, {
322
+ cwd: wtPath,
323
+ stdio: 'inherit',
324
+ });
146
325
  return;
147
326
  }
148
- await execa(tool.command, [wtPath], {
327
+ const { executable, args } = buildOpenToolLaunchCommand(tool, wtPath);
328
+ await execa(executable, args, {
149
329
  cwd: wtPath,
150
330
  stdio: 'ignore',
151
331
  });
152
332
  }
333
+ export function buildOpenToolLaunchCommand(tool, wtPath) {
334
+ if (tool.bundleId) {
335
+ return {
336
+ executable: '/usr/bin/open',
337
+ args: ['-b', tool.bundleId, wtPath],
338
+ };
339
+ }
340
+ return {
341
+ executable: tool.command,
342
+ args: [wtPath],
343
+ };
344
+ }
345
+ export async function runOpenActions(actions, wtPath, options = {}) {
346
+ let openedTerminal = false;
347
+ for (const action of actions) {
348
+ if (action.type === 'tool' && action.tool) {
349
+ log.info(`Opening ${ui.path(wtPath)} in ${chalk.cyan(action.tool.name)}...`);
350
+ await launchOpenTool(action.tool, wtPath);
351
+ if (action.tool.kind === 'terminal') {
352
+ openedTerminal = true;
353
+ }
354
+ }
355
+ }
356
+ const shellAction = actions.find(action => action.type === 'other-command');
357
+ if (shellAction?.command) {
358
+ await enterCommand(wtPath, { exec: shellAction.command });
359
+ return;
360
+ }
361
+ if (options.enter !== false && !openedTerminal) {
362
+ await enterCommand(wtPath);
363
+ }
364
+ }
153
365
  export async function openCommand(wtName, options = {}) {
154
366
  try {
155
367
  await getRepoRoot();
@@ -208,36 +420,33 @@ export async function openCommand(wtName, options = {}) {
208
420
  return;
209
421
  }
210
422
  const installedTools = await detectInstalledOpenTools();
211
- let chosenTool;
212
423
  const requestedTool = options.tool;
424
+ const shouldEnter = options.enter === true;
213
425
  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) {
426
+ const toolAction = await resolveOpenToolAction(requestedTool, installedTools);
427
+ if (!toolAction) {
224
428
  const available = installedTools.map(tool => tool.command).join(', ') || 'none detected';
225
429
  log.error(`Tool "${requestedTool}" not found.`);
226
430
  log.dim(`Detected tool commands: ${available}`);
227
431
  return;
228
432
  }
433
+ await runOpenActions([toolAction], targetWt.path, { enter: shouldEnter });
434
+ log.success('Worktree opened.');
435
+ return;
229
436
  }
230
437
  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)');
438
+ if (installedTools.length === 0 && !shouldEnter) {
439
+ log.error('No supported editor command or app was detected.');
440
+ log.dim('Try: yggtree open --tool <command> (e.g. --tool cursor or --tool codex-app), or yggtree open --enter');
234
441
  return;
235
442
  }
236
- chosenTool = await promptOpenToolSelection(installedTools);
443
+ const actions = await promptOpenActions(installedTools, {
444
+ allowShellAction: shouldEnter,
445
+ message: shouldEnter ? 'Open anything before entering the shell?' : 'Open anything?',
446
+ });
447
+ await runOpenActions(actions, targetWt.path, { enter: shouldEnter });
237
448
  }
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.');
449
+ log.success('Worktree opened.');
241
450
  }
242
451
  catch (error) {
243
452
  log.error(error.message);