hsh19900502 1.0.24 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CLAUDE.md +2 -2
  2. package/PRD.md +147 -0
  3. package/README.md +2 -1
  4. package/dist/commands/claude.d.ts +14 -0
  5. package/dist/commands/claude.js +267 -0
  6. package/dist/commands/ide/index.d.ts +1 -0
  7. package/dist/commands/ide/index.js +117 -18
  8. package/dist/commands/url.d.ts +2 -1
  9. package/dist/commands/url.js +87 -6
  10. package/dist/commands/window.d.ts +28 -0
  11. package/dist/commands/window.js +250 -0
  12. package/dist/hsh.js +64 -9
  13. package/dist/types/index.d.ts +17 -0
  14. package/dist/util.d.ts +13 -0
  15. package/dist/util.js +95 -10
  16. package/package.json +1 -1
  17. package/requirements/10.search-working-directory.md +268 -0
  18. package/requirements/9.claude-hooks.md +191 -0
  19. package/requirements/claude-session-end-hook.sh +60 -0
  20. package/space_manager.sh +244 -0
  21. package/specs/001-cloud-login-feature/contracts/config-service.ts +1 -1
  22. package/specs/001-cloud-login-feature/data-model.md +3 -3
  23. package/specs/001-cloud-login-feature/plan.md +2 -2
  24. package/specs/001-cloud-login-feature/quickstart.md +2 -2
  25. package/specs/001-cloud-login-feature/spec.md +2 -2
  26. package/specs/001-cloud-scp-command/contracts/cloud-scp-api.ts +2 -2
  27. package/specs/001-cloud-scp-command/data-model.md +2 -2
  28. package/specs/001-cloud-scp-command/plan.md +1 -1
  29. package/specs/001-cloud-scp-command/quickstart.md +4 -4
  30. package/specs/001-cloud-scp-command/spec.md +1 -1
  31. package/src/commands/claude.ts +308 -0
  32. package/src/commands/ide/index.ts +142 -18
  33. package/src/commands/url.ts +103 -7
  34. package/src/commands/window.ts +288 -0
  35. package/src/hsh.ts +72 -9
  36. package/src/types/index.ts +20 -0
  37. package/src/util.ts +124 -10
  38. package/docs/FUZZY_SEARCH_FEATURE.md +0 -240
package/CLAUDE.md CHANGED
@@ -68,7 +68,7 @@ The CLI follows a modular command structure using Commander.js:
68
68
  - **ESM modules**: Uses ES module imports/exports throughout
69
69
  - **Async/await**: All shell operations are async with proper error handling
70
70
  - **Interactive CLI**: Extensive use of inquirer prompts with autocomplete
71
- - **Configuration-based**: IDE command uses `~/hsh.config.json` for project paths
71
+ - **Configuration-based**: IDE command uses `~/.hsh/config.json` for project paths
72
72
  - **Monorepo aware**: Special handling for multi-repository workflows
73
73
  - **TypeScript strict mode**: Full type safety with comprehensive type checking
74
74
 
@@ -103,7 +103,7 @@ The CLI follows a modular command structure using Commander.js:
103
103
 
104
104
  ### IDE Configuration
105
105
 
106
- The `cursor` and `claude` commands require a configuration file at `~/hsh.config.json`:
106
+ The `cursor` and `claude` commands require a configuration file at `~/.hsh/config.json`:
107
107
 
108
108
  ```json
109
109
  {
package/PRD.md ADDED
@@ -0,0 +1,147 @@
1
+ # PRD: Window Management Command (`hsh window`)
2
+
3
+ ## Overview
4
+
5
+ A macOS window and space management command that enables fast navigation across all open windows and virtual desktops (Spaces) using fuzzy search. Built on top of Hammerspoon for native macOS window control.
6
+
7
+ ## Problem Statement
8
+
9
+ Navigating between windows and Spaces on macOS is cumbersome:
10
+ - Mission Control requires mouse interaction or memorizing space positions
11
+ - Cmd+Tab only cycles through apps, not individual windows
12
+ - Finding a specific window among dozens requires visual scanning
13
+
14
+ **Goal**: Enable instant window switching via fuzzy search from the terminal.
15
+
16
+ ## Terminology
17
+
18
+ | Term | Definition |
19
+ |------|------------|
20
+ | **Space** | A macOS virtual desktop (also called "Desktop" in Mission Control). Each Space is an independent workspace that can contain multiple windows. Users can have multiple Spaces and switch between them. |
21
+ | **Window** | An individual application window within a Space. One app can have multiple windows, potentially across different Spaces. Each window has a title (e.g., the filename in Cursor, the page title in Chrome). |
22
+ | **Focus** | Making a window the active/selected window that receives keyboard input. |
23
+ | **Raise** | Bringing a window to the front of the window stack so it's fully visible (not obscured by other windows). |
24
+ | **Fullscreen Space** | A special Space type where a single app occupies the entire screen (entered via the green maximize button or Ctrl+Cmd+F). |
25
+
26
+ ## User Stories
27
+
28
+ 1. **As a developer**, I want to quickly jump to a specific Cursor window by searching its project name, so I can context-switch efficiently between projects.
29
+ 2. **As a power user**, I want to see all my windows/spaces in a searchable list, so I can find and focus any window without leaving the keyboard.
30
+
31
+ ## Feature Specification
32
+
33
+ ### Command: `hsh window search`
34
+
35
+ Interactive fuzzy search across all macOS windows and Spaces.
36
+
37
+ #### Behavior
38
+
39
+ 1. **List all spaces**: Query Hammerspoon for all Spaces across all screens (including fullscreen spaces without accessible windows)
40
+ 2. **Display format**: Show searchable list with:
41
+ - Space name (from Mission Control, e.g., "Desktop 1", "LLM", or app name for fullscreen)
42
+ - Space type (`[Desktop]` or `[Fullscreen]`)
43
+ - Main window info if available (app name and title, truncated if needed)
44
+ 3. **Fuzzy search**: Filter spaces as user types using inquirer autocomplete
45
+ 4. **Jump action**: Switch to the selected Space and focus its main window (if available)
46
+
47
+ #### Example Output
48
+
49
+ ```
50
+ ? Select space/window to focus: (Use arrow keys or type to search)
51
+ ❯ Desktop 1 [Desktop] - Slack: awx-i18n-service (Channel)...
52
+ Desktop 2 [Desktop]
53
+ Cursor [Fullscreen] - Cursor: space_manager.sh — macos-manager
54
+ iTerm2 [Fullscreen]
55
+ LLM [Fullscreen]
56
+ Google Chrome [Fullscreen]
57
+ index.tsx — saas-pricing-webapp [Fullscreen]
58
+ ```
59
+
60
+ > **Note**: Fullscreen spaces without accessible windows (e.g., "iTerm2", "LLM") are still listed and can be jumped to. The space name is preserved from Mission Control even if the original window was closed.
61
+
62
+ #### Technical Requirements
63
+
64
+ | Requirement | Details |
65
+ |-------------|---------|
66
+ | **Dependency** | Hammerspoon must be installed and running |
67
+ | **API** | Uses `hs` CLI to execute Lua commands |
68
+ | **Performance** | Window list retrieval should complete in <500ms |
69
+ | **Error handling** | Graceful fallback if Hammerspoon unavailable |
70
+
71
+ ### Future Subcommands (Out of Scope for v1)
72
+
73
+ | Command | Description |
74
+ |---------|-------------|
75
+ | `hsh window list` | List all windows/spaces without interactive search |
76
+ | `hsh window goto <n>` | Jump directly to Space number n |
77
+ | `hsh window close <keyword>` | Close windows matching keyword |
78
+
79
+ ## Technical Design
80
+
81
+ ### Architecture
82
+
83
+ ```
84
+ src/commands/window.ts
85
+ ├── searchWindows() # Main interactive search flow
86
+ ├── getAllSpacesAndWindows() # Query all spaces and their main windows
87
+ ├── focusWindow() # Switch to Space and focus window (if available)
88
+ └── types # SpaceWindowInfo interface
89
+ ```
90
+
91
+ ### Data Structure
92
+
93
+ ```typescript
94
+ interface SpaceWindowInfo {
95
+ spaceId: number;
96
+ spaceName: string; // "Desktop 1", "LLM", or custom name from Mission Control
97
+ spaceType: 'user' | 'fullscreen';
98
+ spaceIndex: number;
99
+ screenIndex: number;
100
+ // Window info (may be empty for spaces without accessible windows)
101
+ appName: string;
102
+ windowTitle: string;
103
+ windowId: number | null;
104
+ }
105
+ ```
106
+
107
+ ### Hammerspoon Integration
108
+
109
+ Execute Lua scripts via `hs -c "<script>"` to:
110
+ 1. Query `hs.spaces.allSpaces()` for all spaces across all screens
111
+ 2. Query `hs.spaces.missionControlSpaceNames()` for space names
112
+ 3. Query `hs.window.allWindows()` and map to spaces via `spaces.windowSpaces()`
113
+ 4. Use `spaces.gotoSpace()` and `win:focus()` for navigation
114
+
115
+ > **Design Note**: Follows the same logic as `space_manager.sh list_spaces()` to ensure consistent behavior.
116
+
117
+ ## Success Metrics
118
+
119
+ - **Adoption**: Used 10+ times per day by active users
120
+ - **Speed**: Average search-to-focus time under 2 seconds
121
+ - **Reliability**: <1% failure rate on supported macOS versions
122
+
123
+ ## Dependencies
124
+
125
+ | Dependency | Purpose | Installation |
126
+ |------------|---------|--------------|
127
+ | [Hammerspoon](https://www.hammerspoon.org/) | macOS automation API | `brew install hammerspoon` |
128
+ | inquirer-autocomplete-prompt | Fuzzy search UI | Already in project |
129
+
130
+ ## Rollout Plan
131
+
132
+ 1. **Phase 1**: ✅ Implement `hsh window search` with basic functionality
133
+ 2. **Phase 2**: Add caching for faster subsequent queries
134
+ 3. **Phase 3**: Consider additional subcommands based on usage patterns
135
+
136
+ ## Open Questions (Resolved)
137
+
138
+ - [x] Should we support filtering by app name only? (e.g., `hsh window search --app cursor`)
139
+ → No need, keyword fuzzy search is more generalized
140
+ - [x] Should window focus also raise the window to front if partially obscured?
141
+ → Yes, always raise — `win:focus()` handles both focus and raise. For spaces without accessible windows, just switch to the space.
142
+ - [x] Consider keyboard shortcut integration via Hammerspoon for even faster access?
143
+ → No need for v1
144
+
145
+ ---
146
+
147
+ *Last updated: January 2026*
package/README.md CHANGED
@@ -116,7 +116,7 @@ Projects updated:
116
116
 
117
117
  ### IDE Configuration
118
118
 
119
- Create `~/hsh.config.json` for IDE project management:
119
+ Create `~/.hsh/config.json` for IDE project management:
120
120
 
121
121
  ```json
122
122
  {
@@ -164,3 +164,4 @@ yarn dev
164
164
  ## License
165
165
 
166
166
  MIT
167
+
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Hook handler for UserPromptSubmit event
3
+ * Tracks task start time and git branch
4
+ */
5
+ export declare function handleUserPromptSubmit(): Promise<void>;
6
+ /**
7
+ * Hook handler for Stop event
8
+ * Calculates duration, sends notification for long tasks, marks for review
9
+ */
10
+ export declare function handleStop(): Promise<void>;
11
+ /**
12
+ * Interactive command to review completed long tasks
13
+ */
14
+ export declare function reviewTasks(): Promise<void>;
@@ -0,0 +1,267 @@
1
+ import { $ } from 'zx';
2
+ import { promises as fs } from 'fs';
3
+ import { homedir } from 'os';
4
+ import path from 'path';
5
+ import inquirer from 'inquirer';
6
+ import autocomplete from 'inquirer-autocomplete-prompt';
7
+ import chalk from 'chalk';
8
+ // Register autocomplete prompt
9
+ inquirer.registerPrompt('autocomplete', autocomplete);
10
+ const TASK_REGISTRY_PATH = path.join(homedir(), '.hsh', 'task-registry.json');
11
+ const CONFIG_PATH = path.join(homedir(), '.hsh', 'config.json');
12
+ const LONG_TASK_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes in milliseconds
13
+ /**
14
+ * Load task registry from disk
15
+ */
16
+ async function loadTaskRegistry() {
17
+ try {
18
+ const data = await fs.readFile(TASK_REGISTRY_PATH, 'utf-8');
19
+ return JSON.parse(data);
20
+ }
21
+ catch {
22
+ // File doesn't exist or is invalid, return empty registry
23
+ return {};
24
+ }
25
+ }
26
+ /**
27
+ * Save task registry to disk
28
+ */
29
+ async function saveTaskRegistry(registry) {
30
+ const dir = path.dirname(TASK_REGISTRY_PATH);
31
+ await fs.mkdir(dir, { recursive: true });
32
+ await fs.writeFile(TASK_REGISTRY_PATH, JSON.stringify(registry, null, 2), 'utf-8');
33
+ }
34
+ /**
35
+ * Get current git branch name
36
+ */
37
+ async function getCurrentBranch() {
38
+ try {
39
+ const result = await $ `git rev-parse --abbrev-ref HEAD`;
40
+ return result.stdout.trim();
41
+ }
42
+ catch {
43
+ return 'unknown';
44
+ }
45
+ }
46
+ /**
47
+ * Format duration in milliseconds to human-readable string
48
+ */
49
+ function formatDuration(ms) {
50
+ const seconds = Math.floor(ms / 1000);
51
+ const minutes = Math.floor(seconds / 60);
52
+ const hours = Math.floor(minutes / 60);
53
+ if (hours > 0) {
54
+ return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
55
+ }
56
+ else if (minutes > 0) {
57
+ return `${minutes}m ${seconds % 60}s`;
58
+ }
59
+ else {
60
+ return `${seconds}s`;
61
+ }
62
+ }
63
+ /**
64
+ * Format time ago (e.g., "2h 34m ago")
65
+ */
66
+ function formatTimeAgo(timestamp) {
67
+ const ms = Date.now() - timestamp;
68
+ const seconds = Math.floor(ms / 1000);
69
+ const minutes = Math.floor(seconds / 60);
70
+ const hours = Math.floor(minutes / 60);
71
+ const days = Math.floor(hours / 24);
72
+ if (days > 0) {
73
+ return `${days}d ${hours % 24}h ago`;
74
+ }
75
+ else if (hours > 0) {
76
+ return `${hours}h ${minutes % 60}m ago`;
77
+ }
78
+ else if (minutes > 0) {
79
+ return `${minutes}m ago`;
80
+ }
81
+ else {
82
+ return `${seconds}s ago`;
83
+ }
84
+ }
85
+ /**
86
+ * Get WeChat webhook URL from environment variable or config file
87
+ */
88
+ async function getWeChatWebhookUrl() {
89
+ // First try environment variable
90
+ const envUrl = process.env.WECHAT_WEBHOOK_URL;
91
+ if (envUrl) {
92
+ return envUrl;
93
+ }
94
+ // Then try config file
95
+ try {
96
+ const configData = await fs.readFile(CONFIG_PATH, 'utf-8');
97
+ const config = JSON.parse(configData);
98
+ return config.wechatWebhookUrl || null;
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
104
+ /**
105
+ * Send WeChat notification
106
+ */
107
+ async function sendWeChatNotification(task) {
108
+ const webhookUrl = await getWeChatWebhookUrl();
109
+ if (!webhookUrl) {
110
+ console.error(chalk.yellow('⚠️ WeChat webhook URL not configured. Set WECHAT_WEBHOOK_URL env var or add wechatWebhookUrl to ~/.hsh/config.json'));
111
+ return;
112
+ }
113
+ const now = new Date().toLocaleString('zh-CN', {
114
+ timeZone: 'Asia/Shanghai',
115
+ year: 'numeric',
116
+ month: '2-digit',
117
+ day: '2-digit',
118
+ hour: '2-digit',
119
+ minute: '2-digit',
120
+ second: '2-digit',
121
+ hour12: false,
122
+ });
123
+ const who = `${process.env.USER || 'unknown'}@${process.env.HOSTNAME || 'unknown'}`;
124
+ const durationStr = task.duration ? formatDuration(task.duration) : 'unknown';
125
+ const message = `Claude Code 任务完成 ✅
126
+ 项目路径: ${task.projectPath} (${task.branch})
127
+ 任务时长: ${durationStr}
128
+ 时间: ${now} +0800
129
+ 执行者: ${who}`;
130
+ const payload = {
131
+ msgtype: 'text',
132
+ text: {
133
+ content: message,
134
+ },
135
+ };
136
+ try {
137
+ const response = await fetch(webhookUrl, {
138
+ method: 'POST',
139
+ headers: {
140
+ 'Content-Type': 'application/json',
141
+ },
142
+ body: JSON.stringify(payload),
143
+ });
144
+ const result = await response.json();
145
+ if (result.errcode !== 0) {
146
+ console.error(chalk.red(`WeChat webhook error: ${result.errmsg}`));
147
+ }
148
+ }
149
+ catch (error) {
150
+ console.error(chalk.red(`Failed to send WeChat notification: ${error}`));
151
+ }
152
+ }
153
+ /**
154
+ * Hook handler for UserPromptSubmit event
155
+ * Tracks task start time and git branch
156
+ */
157
+ export async function handleUserPromptSubmit() {
158
+ const projectPath = process.cwd();
159
+ const branch = await getCurrentBranch();
160
+ const startTime = Date.now();
161
+ const registry = await loadTaskRegistry();
162
+ registry[projectPath] = {
163
+ projectPath,
164
+ branch,
165
+ startTime,
166
+ };
167
+ await saveTaskRegistry(registry);
168
+ // Silent operation - no console output
169
+ }
170
+ /**
171
+ * Hook handler for Stop event
172
+ * Calculates duration, sends notification for long tasks, marks for review
173
+ */
174
+ export async function handleStop() {
175
+ const projectPath = process.cwd();
176
+ const registry = await loadTaskRegistry();
177
+ const task = registry[projectPath];
178
+ if (!task) {
179
+ // No task record found, nothing to do
180
+ return;
181
+ }
182
+ const endTime = Date.now();
183
+ const duration = endTime - task.startTime;
184
+ task.endTime = endTime;
185
+ task.duration = duration;
186
+ if (duration > LONG_TASK_THRESHOLD_MS) {
187
+ // Long task - send notification and mark for review
188
+ await sendWeChatNotification(task);
189
+ task.notified = true;
190
+ task.needsReview = true;
191
+ await saveTaskRegistry(registry);
192
+ }
193
+ else {
194
+ // Short task - remove from registry (immediate cleanup)
195
+ delete registry[projectPath];
196
+ await saveTaskRegistry(registry);
197
+ }
198
+ // Silent operation - no console output
199
+ }
200
+ /**
201
+ * Interactive command to review completed long tasks
202
+ */
203
+ export async function reviewTasks() {
204
+ const registry = await loadTaskRegistry();
205
+ // Filter tasks that need review
206
+ const tasksNeedingReview = Object.values(registry).filter((task) => task.needsReview);
207
+ if (tasksNeedingReview.length === 0) {
208
+ console.log(chalk.green('✅ No completed tasks awaiting review!'));
209
+ return;
210
+ }
211
+ // Sort by completion time (most recent first)
212
+ tasksNeedingReview.sort((a, b) => (b.endTime || 0) - (a.endTime || 0));
213
+ console.log(chalk.bold('\n📋 Completed Long Tasks Awaiting Review:\n'));
214
+ // Display task list
215
+ tasksNeedingReview.forEach((task, index) => {
216
+ const timeAgo = task.endTime ? formatTimeAgo(task.endTime) : 'unknown';
217
+ const durationStr = task.duration ? formatDuration(task.duration) : 'unknown';
218
+ console.log(chalk.cyan(`${index + 1}. [${timeAgo}] ${task.projectPath} (${task.branch})`));
219
+ console.log(chalk.gray(` Duration: ${durationStr}\n`));
220
+ });
221
+ // Create choices for inquirer
222
+ const choices = tasksNeedingReview.map((task) => {
223
+ const timeAgo = task.endTime ? formatTimeAgo(task.endTime) : 'unknown';
224
+ const durationStr = task.duration ? formatDuration(task.duration) : 'unknown';
225
+ return {
226
+ name: `[${timeAgo}] ${task.projectPath} (${task.branch}) - ${durationStr}`,
227
+ value: task.projectPath,
228
+ };
229
+ });
230
+ choices.push({ name: chalk.gray('Cancel'), value: '__cancel__' });
231
+ const { selectedPath } = await inquirer.prompt([
232
+ {
233
+ type: 'list',
234
+ name: 'selectedPath',
235
+ message: 'Select task to review:',
236
+ choices,
237
+ },
238
+ ]);
239
+ if (selectedPath === '__cancel__') {
240
+ console.log(chalk.yellow('Review cancelled.'));
241
+ return;
242
+ }
243
+ const selectedTask = registry[selectedPath];
244
+ if (!selectedTask) {
245
+ console.error(chalk.red('❌ Task not found in registry.'));
246
+ return;
247
+ }
248
+ // Mark as reviewed (remove from registry)
249
+ delete registry[selectedPath];
250
+ await saveTaskRegistry(registry);
251
+ console.log(chalk.green(`\n✅ Task marked as reviewed and removed from queue.`));
252
+ console.log(chalk.cyan(`📂 Project: ${selectedTask.projectPath}`));
253
+ console.log(chalk.cyan(`🌿 Branch: ${selectedTask.branch}`));
254
+ // Output navigation command for shell wrapper to execute
255
+ // The shell wrapper should source this output to actually change directory
256
+ console.log(chalk.bold(`\n💡 To navigate to this project, run:`));
257
+ console.log(chalk.yellow(`cd "${selectedTask.projectPath}"`));
258
+ // Try to open in Cursor IDE if available
259
+ try {
260
+ process.chdir(selectedTask.projectPath);
261
+ await $ `cursor .`;
262
+ console.log(chalk.green('🚀 Opened project in Cursor IDE'));
263
+ }
264
+ catch {
265
+ console.log(chalk.gray('💡 Could not auto-open in Cursor. You can manually open the project or navigate with the cd command above.'));
266
+ }
267
+ }
@@ -1,2 +1,3 @@
1
1
  import 'zx/globals';
2
2
  export declare const openIDE: (ideType: "cursor" | "claude", searchMode?: string) => Promise<void>;
3
+ export declare const refreshIdeReposCache: () => Promise<void>;
@@ -2,15 +2,48 @@ import inquirer from 'inquirer';
2
2
  import 'zx/globals';
3
3
  import chalk from 'chalk';
4
4
  import inquirerAutocomplete from 'inquirer-autocomplete-prompt';
5
- import { readConfig } from '../../util.js';
5
+ import { readConfig, findGitRepositories, readIdeReposCache, writeIdeReposCache, } from '../../util.js';
6
6
  import { spawn } from 'child_process';
7
+ import { existsSync } from 'fs';
7
8
  // Register autocomplete prompt
8
9
  inquirer.registerPrompt('autocomplete', inquirerAutocomplete);
9
10
  let reposConfig;
10
11
  let currentCategory;
11
- async function loadConfig() {
12
+ let workingDirectory;
13
+ let autoDiscoveredRepos = [];
14
+ async function loadConfig(options) {
12
15
  const config = readConfig();
13
- reposConfig = config.repos;
16
+ // Check if workingDirectory is configured
17
+ if (config.workingDirectory) {
18
+ workingDirectory = config.workingDirectory;
19
+ // Validate that workingDirectory exists
20
+ if (!existsSync(workingDirectory)) {
21
+ throw new Error(`Working directory does not exist: ${workingDirectory}`);
22
+ }
23
+ if (options?.refreshReposCache) {
24
+ // Force refresh: rescan and overwrite cache
25
+ autoDiscoveredRepos = findGitRepositories(workingDirectory);
26
+ writeIdeReposCache(workingDirectory, autoDiscoveredRepos);
27
+ }
28
+ else {
29
+ // Default: use cache if possible (fast path)
30
+ const cached = readIdeReposCache(workingDirectory);
31
+ if (cached) {
32
+ autoDiscoveredRepos = cached;
33
+ }
34
+ else {
35
+ // Warm the cache on first run
36
+ autoDiscoveredRepos = findGitRepositories(workingDirectory);
37
+ writeIdeReposCache(workingDirectory, autoDiscoveredRepos);
38
+ }
39
+ }
40
+ // Sort alphabetically by name for better UX
41
+ autoDiscoveredRepos.sort((a, b) => a.name.localeCompare(b.name));
42
+ }
43
+ else {
44
+ // Use manual configuration (existing behavior)
45
+ reposConfig = config.repos;
46
+ }
14
47
  }
15
48
  async function searchCategories(_answers, input = '') {
16
49
  if (!reposConfig) {
@@ -46,6 +79,16 @@ async function searchProjects(_answers, input = '') {
46
79
  }
47
80
  // Flatten all projects with category context for fuzzy search
48
81
  function getAllProjects() {
82
+ // Auto-discovery mode: use discovered Git repos
83
+ if (workingDirectory) {
84
+ return autoDiscoveredRepos.map((repo) => ({
85
+ category: repo.topLevelFolder,
86
+ name: repo.name,
87
+ path: repo.path,
88
+ display: `${repo.name} (${repo.path})`,
89
+ }));
90
+ }
91
+ // Manual configuration mode
49
92
  if (!reposConfig)
50
93
  return [];
51
94
  return Object.entries(reposConfig).flatMap(([category, projects]) => Object.entries(projects).map(([name, path]) => ({
@@ -58,18 +101,60 @@ function getAllProjects() {
58
101
  // Single-keyword fuzzy search across all projects
59
102
  async function searchAllProjects(_answers, input = '') {
60
103
  const allProjects = getAllProjects();
61
- if (!input) {
62
- return allProjects.map((p) => ({ name: p.display, value: p }));
104
+ // Filter projects based on search input
105
+ let filteredProjects = allProjects;
106
+ if (input) {
107
+ const keywords = input.toLowerCase().trim().split(/\s+/);
108
+ filteredProjects = allProjects.filter((project) => {
109
+ const searchText = `${project.name} ${project.category} ${project.path}`.toLowerCase();
110
+ return keywords.every((keyword) => searchText.includes(keyword));
111
+ });
63
112
  }
64
- // Split input by spaces and filter projects matching ALL keywords
65
- const keywords = input.toLowerCase().trim().split(/\s+/);
66
- return allProjects
67
- .filter((project) => {
68
- const searchText = `${project.name} ${project.category} ${project.path}`.toLowerCase();
69
- // Check if all keywords are present in the search text
70
- return keywords.every((keyword) => searchText.includes(keyword));
71
- })
72
- .map((p) => ({ name: p.display, value: p }));
113
+ // If auto-discovery mode, add category separators
114
+ if (workingDirectory) {
115
+ return formatProjectsWithSeparators(filteredProjects);
116
+ }
117
+ // Manual mode: simple display
118
+ return filteredProjects.map((p) => ({ name: p.display, value: p }));
119
+ }
120
+ // Format projects with top-level folder separators (for auto-discovery mode)
121
+ function formatProjectsWithSeparators(projects) {
122
+ // Group projects by category (top-level folder)
123
+ const grouped = new Map();
124
+ projects.forEach((project) => {
125
+ const category = project.category;
126
+ if (!grouped.has(category)) {
127
+ grouped.set(category, []);
128
+ }
129
+ grouped.get(category).push(project);
130
+ });
131
+ // Sort categories alphabetically, but put root (/) last
132
+ const sortedCategories = Array.from(grouped.keys()).sort((a, b) => {
133
+ if (a === '/')
134
+ return 1;
135
+ if (b === '/')
136
+ return 1;
137
+ return a.localeCompare(b);
138
+ });
139
+ // Build result with separators
140
+ const result = [];
141
+ sortedCategories.forEach((category) => {
142
+ const categoryProjects = grouped.get(category);
143
+ // Add category separator
144
+ const categoryLabel = category === '/' ? '/ (root)' : category;
145
+ result.push({
146
+ name: `--------- ${categoryLabel} ---------`,
147
+ disabled: 'separator',
148
+ });
149
+ // Add projects in this category
150
+ categoryProjects.forEach((p) => {
151
+ result.push({
152
+ name: ` ${p.display}`,
153
+ value: p,
154
+ });
155
+ });
156
+ });
157
+ return result;
73
158
  }
74
159
  // Select project using fuzzy search across all categories
75
160
  async function selectProjectWithFuzzySearch(searchMode) {
@@ -134,16 +219,22 @@ async function launchClaude(projectPath) {
134
219
  });
135
220
  });
136
221
  }
137
- // Launch Cursor or other IDE
222
+ // Launch Cursor or other IDE, not include claude
138
223
  async function launchIDE(ideType, projectPath, projectName) {
139
- await $ `${ideType} ${projectPath}`;
224
+ if (ideType === 'cursor') {
225
+ // it could ensure the cursor is brought to front in macos
226
+ await $ `open -a "Cursor" ${projectPath}`;
227
+ }
228
+ else {
229
+ await $ `${ideType} ${projectPath}`;
230
+ }
140
231
  console.log(chalk.green(`Opening ${projectName} in ${ideType}...`));
141
232
  }
142
233
  export const openIDE = async (ideType, searchMode) => {
143
234
  try {
144
235
  await loadConfig();
145
- // Select project using appropriate method
146
- const project = searchMode !== undefined
236
+ // In auto-discovery mode, always use fuzzy search (no categories)
237
+ const project = workingDirectory || searchMode !== undefined
147
238
  ? await selectProjectWithFuzzySearch(searchMode)
148
239
  : await selectProjectWithTwoStep();
149
240
  // Launch IDE based on type
@@ -158,3 +249,11 @@ export const openIDE = async (ideType, searchMode) => {
158
249
  console.error(chalk.red(`Error opening project in ${ideType}:`, error));
159
250
  }
160
251
  };
252
+ export const refreshIdeReposCache = async () => {
253
+ await loadConfig({ refreshReposCache: true });
254
+ if (!workingDirectory) {
255
+ console.log(chalk.yellow('No workingDirectory configured; nothing to refresh.'));
256
+ return;
257
+ }
258
+ console.log(chalk.green('IDE repository cache refreshed.'));
259
+ };
@@ -1,3 +1,4 @@
1
1
  export declare function addUrl(name: string, url: string): Promise<void>;
2
2
  export declare function removeUrl(name?: string): Promise<void>;
3
- export declare function searchAndOpenUrl(): Promise<void>;
3
+ export declare function searchAndOpenUrl(suppress?: boolean): Promise<void>;
4
+ export declare function openUrlGroup(): Promise<void>;