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.
- package/CLAUDE.md +2 -2
- package/PRD.md +147 -0
- package/README.md +2 -1
- package/dist/commands/claude.d.ts +14 -0
- package/dist/commands/claude.js +267 -0
- package/dist/commands/ide/index.d.ts +1 -0
- package/dist/commands/ide/index.js +117 -18
- package/dist/commands/url.d.ts +2 -1
- package/dist/commands/url.js +87 -6
- package/dist/commands/window.d.ts +28 -0
- package/dist/commands/window.js +250 -0
- package/dist/hsh.js +64 -9
- package/dist/types/index.d.ts +17 -0
- package/dist/util.d.ts +13 -0
- package/dist/util.js +95 -10
- package/package.json +1 -1
- package/requirements/10.search-working-directory.md +268 -0
- package/requirements/9.claude-hooks.md +191 -0
- package/requirements/claude-session-end-hook.sh +60 -0
- package/space_manager.sh +244 -0
- package/specs/001-cloud-login-feature/contracts/config-service.ts +1 -1
- package/specs/001-cloud-login-feature/data-model.md +3 -3
- package/specs/001-cloud-login-feature/plan.md +2 -2
- package/specs/001-cloud-login-feature/quickstart.md +2 -2
- package/specs/001-cloud-login-feature/spec.md +2 -2
- package/specs/001-cloud-scp-command/contracts/cloud-scp-api.ts +2 -2
- package/specs/001-cloud-scp-command/data-model.md +2 -2
- package/specs/001-cloud-scp-command/plan.md +1 -1
- package/specs/001-cloud-scp-command/quickstart.md +4 -4
- package/specs/001-cloud-scp-command/spec.md +1 -1
- package/src/commands/claude.ts +308 -0
- package/src/commands/ide/index.ts +142 -18
- package/src/commands/url.ts +103 -7
- package/src/commands/window.ts +288 -0
- package/src/hsh.ts +72 -9
- package/src/types/index.ts +20 -0
- package/src/util.ts +124 -10
- 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
|
|
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
|
|
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
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
12
|
+
let workingDirectory;
|
|
13
|
+
let autoDiscoveredRepos = [];
|
|
14
|
+
async function loadConfig(options) {
|
|
12
15
|
const config = readConfig();
|
|
13
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
+
};
|
package/dist/commands/url.d.ts
CHANGED
|
@@ -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>;
|