quickclaude 1.0.8 → 1.1.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/.github/workflows/publish.yml +7 -6
- package/CLAUDE.md +27 -0
- package/README.md +33 -10
- package/bin/quickclaude.js +121 -32
- package/docs/superpowers/plans/2026-04-04-quickclaude-improvements.md +641 -0
- package/docs/superpowers/specs/2026-04-04-quickclaude-improvements-design.md +68 -0
- package/package.json +5 -4
- package/test/getProjects.test.js +36 -0
- package/test/resolvePath.test.js +56 -0
- package/test/timeAgo.test.js +42 -0
|
@@ -2,8 +2,8 @@ name: Publish to npm
|
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
4
|
push:
|
|
5
|
-
|
|
6
|
-
-
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
7
|
|
|
8
8
|
jobs:
|
|
9
9
|
publish:
|
|
@@ -12,11 +12,12 @@ jobs:
|
|
|
12
12
|
contents: read
|
|
13
13
|
id-token: write
|
|
14
14
|
steps:
|
|
15
|
-
- uses: actions/checkout@
|
|
16
|
-
- uses: actions/setup-node@
|
|
15
|
+
- uses: actions/checkout@v5
|
|
16
|
+
- uses: actions/setup-node@v5
|
|
17
17
|
with:
|
|
18
18
|
node-version: 22
|
|
19
|
-
|
|
20
|
-
- run: npm --version
|
|
19
|
+
registry-url: https://registry.npmjs.org
|
|
21
20
|
- run: npm ci
|
|
22
21
|
- run: npm publish --provenance --access public
|
|
22
|
+
env:
|
|
23
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Project Overview
|
|
6
|
+
|
|
7
|
+
quickclaude is a CLI launcher for Claude Code. It scans `~/.claude/projects/` for project directories, presents an interactive selection menu, and spawns `claude` in the chosen directory.
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
- **Run**: `node bin/quickclaude.js` or `npm start`
|
|
12
|
+
- **Install globally (for testing)**: `npm install -g .`
|
|
13
|
+
- **No build step or test suite** — this is a single-file Node.js CLI tool
|
|
14
|
+
|
|
15
|
+
## Architecture
|
|
16
|
+
|
|
17
|
+
The entire tool lives in `bin/quickclaude.js` (ESM, `#!/usr/bin/env node`):
|
|
18
|
+
|
|
19
|
+
- **`resolvePath(encoded)`** — Converts Claude's encoded project directory names (e.g., `-Users-dongwoo-projects-my-app`) back to real filesystem paths. Uses greedy longest-match against the filesystem, trying both `-` and space as segment joiners. Handles Windows drive letters (`C--Users-...`).
|
|
20
|
+
- **`getProjects()`** — Reads `~/.claude/projects/`, resolves each subdirectory name via `resolvePath`, filters out worktree dirs and non-existent paths.
|
|
21
|
+
- **`main()`** — Uses `@clack/prompts` for the interactive select UI, then spawns `claude` with `stdio: "inherit"` in the selected directory.
|
|
22
|
+
|
|
23
|
+
## Key Details
|
|
24
|
+
|
|
25
|
+
- Single dependency: `@clack/prompts` for terminal UI
|
|
26
|
+
- Published to npm as `quickclaude` (global install / npx)
|
|
27
|
+
- Requires Node.js 18+ and Claude Code installed
|
package/README.md
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
# quickclaude
|
|
2
2
|
|
|
3
|
-
CLI launcher for Claude Code.
|
|
3
|
+
CLI launcher for [Claude Code](https://docs.anthropic.com/en/docs/claude-code).
|
|
4
4
|
Quickly launch Claude Code in your project directories.
|
|
5
|
-
<img width="762" height="383" alt="Screenshot 2026-03-28 at 6 25 10 PM" src="https://github.com/user-attachments/assets/4f368300-9748-4190-87c1-f2a1fcfd8a63" />
|
|
6
|
-
|
|
7
5
|
|
|
8
6
|
```
|
|
9
7
|
$ quickclaude
|
|
@@ -11,12 +9,37 @@ $ quickclaude
|
|
|
11
9
|
┌ quickclaude
|
|
12
10
|
│
|
|
13
11
|
◆ Select a project
|
|
14
|
-
│ ● ~/Documents/projects/my-app1
|
|
15
|
-
│ ○ ~/Documents/projects/my-app2
|
|
16
|
-
│ ○ ~/Documents/projects/my-app3
|
|
12
|
+
│ ● 2h ago · ~/Documents/projects/my-app1
|
|
13
|
+
│ ○ 3d ago · ~/Documents/projects/my-app2
|
|
14
|
+
│ ○ 1w ago · ~/Documents/projects/my-app3
|
|
17
15
|
└
|
|
18
16
|
```
|
|
19
17
|
|
|
18
|
+
## How it works
|
|
19
|
+
|
|
20
|
+
Claude Code stores [auto memory](https://docs.anthropic.com/en/docs/claude-code/memory) for each project under `~/.claude/projects/`. Every time you run Claude Code in a directory, it creates a subdirectory there to save project-specific memory like build commands, debugging patterns, and architecture notes.
|
|
21
|
+
|
|
22
|
+
quickclaude uses this directory structure in reverse — it scans `~/.claude/projects/` to build a list of projects you've used with Claude Code, then lets you pick one and launch `claude` right there.
|
|
23
|
+
|
|
24
|
+
1. Scans `~/.claude/projects/` to discover your Claude Code projects
|
|
25
|
+
2. Sorts by most recently used, with relative timestamps (e.g. `2h ago`)
|
|
26
|
+
3. Shows an interactive selection menu with fuzzy search
|
|
27
|
+
4. Launches `claude` in the selected directory
|
|
28
|
+
|
|
29
|
+
Any CLI arguments are forwarded to `claude`:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
quickclaude --resume
|
|
33
|
+
# equivalent to: cd <selected-project> && claude --resume
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Features
|
|
37
|
+
|
|
38
|
+
- **Fuzzy search** — Type to filter projects instantly (start typing to search)
|
|
39
|
+
- **Smart sorting** — Projects sorted by most recently used, with relative timestamps
|
|
40
|
+
- **Path resolution** — Accurately resolves encoded project directory names back to real paths
|
|
41
|
+
- **Argument forwarding** — Any CLI arguments are passed through to `claude`
|
|
42
|
+
|
|
20
43
|
## Install
|
|
21
44
|
|
|
22
45
|
```bash
|
|
@@ -29,11 +52,11 @@ Or run without installing:
|
|
|
29
52
|
npx quickclaude
|
|
30
53
|
```
|
|
31
54
|
|
|
32
|
-
##
|
|
55
|
+
## Update
|
|
33
56
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
57
|
+
```bash
|
|
58
|
+
npm update -g quickclaude
|
|
59
|
+
```
|
|
37
60
|
|
|
38
61
|
## Requirements
|
|
39
62
|
|
package/bin/quickclaude.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import { readdirSync, existsSync } from "fs";
|
|
3
|
+
import prompts from "prompts";
|
|
4
|
+
import { readdirSync, existsSync, statSync, realpathSync } from "fs";
|
|
5
5
|
import { join, sep } from "path";
|
|
6
6
|
import { homedir } from "os";
|
|
7
|
-
import {
|
|
7
|
+
import { execFileSync, spawn } from "child_process";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
8
9
|
|
|
9
10
|
const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
|
|
10
11
|
|
|
@@ -12,31 +13,36 @@ const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
|
|
|
12
13
|
// "-Users-seunghyunhong-Documents-projects-mcp-overwatch" 같은 경우
|
|
13
14
|
// -를 /로 바꾸면 mcp/overwatch가 되어 틀려짐
|
|
14
15
|
// → 파일시스템을 실제로 탐색하며 매칭
|
|
15
|
-
function resolvePath(encoded) {
|
|
16
|
+
function resolvePath(encoded, root = sep) {
|
|
16
17
|
const parts = encoded.replace(/^-/, "").split("-");
|
|
17
|
-
|
|
18
|
+
if (parts.length === 0 || (parts.length === 1 && parts[0] === "")) return null;
|
|
19
|
+
let current = root;
|
|
18
20
|
let i = 0;
|
|
19
21
|
|
|
20
22
|
// Windows: "C--Users-..." → drive letter "C:" 처리
|
|
21
|
-
if (parts.length >= 1 && /^[A-Za-z]$/.test(parts[0])) {
|
|
23
|
+
if (root === sep && parts.length >= 1 && /^[A-Za-z]$/.test(parts[0])) {
|
|
22
24
|
const drive = parts[0].toUpperCase() + ":\\";
|
|
23
25
|
if (existsSync(drive)) {
|
|
24
26
|
current = drive;
|
|
25
27
|
i = 1;
|
|
26
28
|
}
|
|
27
29
|
}
|
|
30
|
+
|
|
28
31
|
while (i < parts.length) {
|
|
32
|
+
let entries;
|
|
33
|
+
try {
|
|
34
|
+
entries = new Set(readdirSync(current));
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
29
39
|
let matched = false;
|
|
30
|
-
// 긴 조합부터 시도 (mcp-overwatch, Unreal Projects 등)
|
|
31
40
|
for (let len = parts.length - i; len >= 1; len--) {
|
|
32
41
|
const segment = parts.slice(i, i + len);
|
|
33
|
-
|
|
34
|
-
const separators = ["-", " "];
|
|
35
|
-
for (const joiner of separators) {
|
|
42
|
+
for (const joiner of ["-", " "]) {
|
|
36
43
|
const candidate = segment.join(joiner);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
current = fullPath;
|
|
44
|
+
if (entries.has(candidate)) {
|
|
45
|
+
current = join(current, candidate);
|
|
40
46
|
i += len;
|
|
41
47
|
matched = true;
|
|
42
48
|
break;
|
|
@@ -50,6 +56,25 @@ function resolvePath(encoded) {
|
|
|
50
56
|
return current;
|
|
51
57
|
}
|
|
52
58
|
|
|
59
|
+
function getLatestMtime(dirPath) {
|
|
60
|
+
try {
|
|
61
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
62
|
+
let latest = 0;
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
if (!entry.isFile()) continue;
|
|
65
|
+
const mtime = statSync(join(dirPath, entry.name)).mtimeMs;
|
|
66
|
+
if (mtime > latest) latest = mtime;
|
|
67
|
+
}
|
|
68
|
+
return latest || statSync(dirPath).mtimeMs;
|
|
69
|
+
} catch {
|
|
70
|
+
try {
|
|
71
|
+
return statSync(dirPath).mtimeMs;
|
|
72
|
+
} catch {
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
53
78
|
function getProjects() {
|
|
54
79
|
if (!existsSync(CLAUDE_PROJECTS_DIR)) {
|
|
55
80
|
return [];
|
|
@@ -68,50 +93,114 @@ function getProjects() {
|
|
|
68
93
|
if (p.path.includes(`claude${sep}worktrees`)) return false;
|
|
69
94
|
return existsSync(p.path);
|
|
70
95
|
})
|
|
71
|
-
.
|
|
96
|
+
.map((p) => {
|
|
97
|
+
const projectDir = join(CLAUDE_PROJECTS_DIR, p.dirName);
|
|
98
|
+
const mtime = getLatestMtime(projectDir);
|
|
99
|
+
return { ...p, mtime };
|
|
100
|
+
})
|
|
101
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function timeAgo(mtimeMs) {
|
|
105
|
+
const seconds = Math.floor((Date.now() - mtimeMs) / 1000);
|
|
106
|
+
if (seconds < 60) return "just now";
|
|
107
|
+
const minutes = Math.floor(seconds / 60);
|
|
108
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
109
|
+
const hours = Math.floor(minutes / 60);
|
|
110
|
+
if (hours < 24) return `${hours}h ago`;
|
|
111
|
+
const days = Math.floor(hours / 24);
|
|
112
|
+
if (days < 7) return `${days}d ago`;
|
|
113
|
+
const weeks = Math.floor(days / 7);
|
|
114
|
+
if (weeks < 4) return `${weeks}w ago`;
|
|
115
|
+
const months = Math.floor(days / 30);
|
|
116
|
+
return `${months}mo ago`;
|
|
72
117
|
}
|
|
73
118
|
|
|
74
|
-
function getProjectLabel(path) {
|
|
119
|
+
function getProjectLabel(path, mtimeMs) {
|
|
75
120
|
const home = homedir();
|
|
76
121
|
const display = path.startsWith(home) ? "~" + path.slice(home.length) : path;
|
|
77
|
-
return display
|
|
122
|
+
return `${timeAgo(mtimeMs)} · ${display}`;
|
|
78
123
|
}
|
|
79
124
|
|
|
80
125
|
async function main() {
|
|
81
|
-
|
|
126
|
+
console.log("\n quickclaude\n");
|
|
82
127
|
|
|
83
128
|
const projects = getProjects();
|
|
84
129
|
|
|
85
130
|
if (projects.length === 0) {
|
|
86
|
-
|
|
131
|
+
console.error(" No Claude projects found.\n");
|
|
87
132
|
process.exit(1);
|
|
88
133
|
}
|
|
89
134
|
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
135
|
+
const choices = projects.map((proj) => ({
|
|
136
|
+
title: getProjectLabel(proj.path, proj.mtime),
|
|
137
|
+
value: proj.path,
|
|
138
|
+
}));
|
|
139
|
+
|
|
140
|
+
const response = await prompts({
|
|
141
|
+
type: "autocomplete",
|
|
142
|
+
name: "project",
|
|
143
|
+
message: "Select a project (type to search)",
|
|
144
|
+
choices,
|
|
145
|
+
suggest: (input, choices) => {
|
|
146
|
+
if (!input) return Promise.resolve(choices);
|
|
147
|
+
const lower = input.toLowerCase();
|
|
148
|
+
return Promise.resolve(
|
|
149
|
+
choices.filter((c) => {
|
|
150
|
+
const title = c.title.toLowerCase();
|
|
151
|
+
let j = 0;
|
|
152
|
+
for (let i = 0; i < title.length && j < lower.length; i++) {
|
|
153
|
+
if (title[i] === lower[j]) j++;
|
|
154
|
+
}
|
|
155
|
+
return j === lower.length;
|
|
156
|
+
})
|
|
157
|
+
);
|
|
158
|
+
},
|
|
96
159
|
});
|
|
97
160
|
|
|
98
|
-
if (
|
|
99
|
-
|
|
161
|
+
if (!response.project) {
|
|
162
|
+
console.log(" Cancelled\n");
|
|
100
163
|
process.exit(0);
|
|
101
164
|
}
|
|
102
165
|
|
|
103
|
-
|
|
166
|
+
const args = process.argv.slice(2);
|
|
104
167
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
168
|
+
const whichCmd = process.platform === "win32" ? "where" : "which";
|
|
169
|
+
try {
|
|
170
|
+
execFileSync(whichCmd, ["claude"], { stdio: "ignore" });
|
|
171
|
+
} catch {
|
|
172
|
+
console.error(
|
|
173
|
+
"Error: Claude Code is not installed.\n" +
|
|
174
|
+
"Install it with: npm install -g @anthropic-ai/claude-code"
|
|
175
|
+
);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
console.log(` Launching Claude in ${response.project}\n`);
|
|
180
|
+
|
|
181
|
+
const child = spawn("claude", args, {
|
|
182
|
+
cwd: response.project,
|
|
108
183
|
stdio: "inherit",
|
|
109
|
-
shell: true,
|
|
110
184
|
});
|
|
111
185
|
|
|
112
186
|
child.on("exit", (code) => {
|
|
113
187
|
process.exit(code ?? 0);
|
|
114
188
|
});
|
|
189
|
+
|
|
190
|
+
child.on("error", (err) => {
|
|
191
|
+
console.error(`Failed to launch Claude: ${err.message}`);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
});
|
|
115
194
|
}
|
|
116
195
|
|
|
117
|
-
|
|
196
|
+
export { resolvePath, timeAgo, getProjectLabel, getProjects, getLatestMtime };
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
if (realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
200
|
+
main();
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
204
|
+
main();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
# quickclaude Improvements Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Improve quickclaude across 7 areas: performance, reliability, testing, UX, and CI.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Single-file CLI stays in `bin/quickclaude.js` with named exports for testability. Functions are guarded by `import.meta.url` check so the file works as both a CLI entry point and an importable module. Tests use `node:test` with temp directories.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node.js 18+, `node:test`, `prompts` (replaces `@clack/prompts`), GitHub Actions
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## File Structure
|
|
14
|
+
|
|
15
|
+
| File | Action | Responsibility |
|
|
16
|
+
|------|--------|----------------|
|
|
17
|
+
| `bin/quickclaude.js` | Modify | CLI entry point + all functions (add exports, optimize resolvePath, mtime, error handling, prompts migration, remove shell:true) |
|
|
18
|
+
| `test/resolvePath.test.js` | Create | Unit tests for resolvePath with temp directory fixtures |
|
|
19
|
+
| `test/timeAgo.test.js` | Create | Unit tests for timeAgo boundary values |
|
|
20
|
+
| `test/getProjects.test.js` | Create | Integration tests for getProjects with temp directory fixtures |
|
|
21
|
+
| `package.json` | Modify | Replace `@clack/prompts` with `prompts`, add `test` script |
|
|
22
|
+
| `.github/workflows/publish.yml` | Modify | Tag-based trigger |
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
### Task 1: Module restructure + test infrastructure
|
|
27
|
+
|
|
28
|
+
**Files:**
|
|
29
|
+
- Modify: `bin/quickclaude.js:1-139`
|
|
30
|
+
- Modify: `package.json`
|
|
31
|
+
|
|
32
|
+
- [ ] **Step 1: Add exports and main guard to quickclaude.js**
|
|
33
|
+
|
|
34
|
+
Add `fileURLToPath` import at line 5, add named exports, and guard `main()`:
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
// Add to imports at top:
|
|
38
|
+
import { fileURLToPath } from "url";
|
|
39
|
+
|
|
40
|
+
// Replace the bare `main();` at line 139 with:
|
|
41
|
+
export { resolvePath, timeAgo, getProjectLabel, getProjects };
|
|
42
|
+
|
|
43
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
44
|
+
main();
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- [ ] **Step 2: Add test script to package.json**
|
|
49
|
+
|
|
50
|
+
Add to the `"scripts"` section:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
"scripts": {
|
|
54
|
+
"start": "node bin/quickclaude.js",
|
|
55
|
+
"test": "node --test test/"
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
- [ ] **Step 3: Create a smoke test to verify the setup**
|
|
60
|
+
|
|
61
|
+
Create `test/timeAgo.test.js` with one basic test:
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
import { describe, it } from "node:test";
|
|
65
|
+
import assert from "node:assert/strict";
|
|
66
|
+
import { timeAgo } from "../bin/quickclaude.js";
|
|
67
|
+
|
|
68
|
+
describe("timeAgo", () => {
|
|
69
|
+
it("returns 'just now' for current time", () => {
|
|
70
|
+
assert.equal(timeAgo(Date.now()), "just now");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
- [ ] **Step 4: Run the test**
|
|
76
|
+
|
|
77
|
+
Run: `npm test`
|
|
78
|
+
Expected: 1 test passes
|
|
79
|
+
|
|
80
|
+
- [ ] **Step 5: Verify CLI still works**
|
|
81
|
+
|
|
82
|
+
Run: `node bin/quickclaude.js --help` (or just run and Ctrl+C)
|
|
83
|
+
Expected: Interactive menu appears normally
|
|
84
|
+
|
|
85
|
+
- [ ] **Step 6: Commit**
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
git add bin/quickclaude.js package.json test/timeAgo.test.js
|
|
89
|
+
git commit -m "refactor: add exports and test infrastructure"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
### Task 2: timeAgo tests
|
|
95
|
+
|
|
96
|
+
**Files:**
|
|
97
|
+
- Modify: `test/timeAgo.test.js`
|
|
98
|
+
|
|
99
|
+
- [ ] **Step 1: Write comprehensive timeAgo tests**
|
|
100
|
+
|
|
101
|
+
Replace `test/timeAgo.test.js` with:
|
|
102
|
+
|
|
103
|
+
```js
|
|
104
|
+
import { describe, it } from "node:test";
|
|
105
|
+
import assert from "node:assert/strict";
|
|
106
|
+
import { timeAgo } from "../bin/quickclaude.js";
|
|
107
|
+
|
|
108
|
+
describe("timeAgo", () => {
|
|
109
|
+
function msAgo(ms) {
|
|
110
|
+
return Date.now() - ms;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
it("returns 'just now' for <60 seconds ago", () => {
|
|
114
|
+
assert.equal(timeAgo(msAgo(0)), "just now");
|
|
115
|
+
assert.equal(timeAgo(msAgo(30_000)), "just now");
|
|
116
|
+
assert.equal(timeAgo(msAgo(59_000)), "just now");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("returns minutes for 1-59 minutes ago", () => {
|
|
120
|
+
assert.equal(timeAgo(msAgo(60_000)), "1m ago");
|
|
121
|
+
assert.equal(timeAgo(msAgo(30 * 60_000)), "30m ago");
|
|
122
|
+
assert.equal(timeAgo(msAgo(59 * 60_000)), "59m ago");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("returns hours for 1-23 hours ago", () => {
|
|
126
|
+
assert.equal(timeAgo(msAgo(60 * 60_000)), "1h ago");
|
|
127
|
+
assert.equal(timeAgo(msAgo(12 * 60 * 60_000)), "12h ago");
|
|
128
|
+
assert.equal(timeAgo(msAgo(23 * 60 * 60_000)), "23h ago");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("returns days for 1-6 days ago", () => {
|
|
132
|
+
assert.equal(timeAgo(msAgo(24 * 60 * 60_000)), "1d ago");
|
|
133
|
+
assert.equal(timeAgo(msAgo(6 * 24 * 60 * 60_000)), "6d ago");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("returns weeks for 7-27 days ago", () => {
|
|
137
|
+
assert.equal(timeAgo(msAgo(7 * 24 * 60 * 60_000)), "1w ago");
|
|
138
|
+
assert.equal(timeAgo(msAgo(21 * 24 * 60 * 60_000)), "3w ago");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("returns months for 28+ days ago", () => {
|
|
142
|
+
assert.equal(timeAgo(msAgo(30 * 24 * 60 * 60_000)), "1mo ago");
|
|
143
|
+
assert.equal(timeAgo(msAgo(90 * 24 * 60 * 60_000)), "3mo ago");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
- [ ] **Step 2: Run tests**
|
|
149
|
+
|
|
150
|
+
Run: `npm test`
|
|
151
|
+
Expected: All tests pass
|
|
152
|
+
|
|
153
|
+
- [ ] **Step 3: Commit**
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
git add test/timeAgo.test.js
|
|
157
|
+
git commit -m "test: add comprehensive timeAgo unit tests"
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
### Task 3: resolvePath performance optimization + tests
|
|
163
|
+
|
|
164
|
+
**Files:**
|
|
165
|
+
- Modify: `bin/quickclaude.js:15-51` (resolvePath function)
|
|
166
|
+
- Create: `test/resolvePath.test.js`
|
|
167
|
+
|
|
168
|
+
- [ ] **Step 1: Write resolvePath tests**
|
|
169
|
+
|
|
170
|
+
Create `test/resolvePath.test.js`:
|
|
171
|
+
|
|
172
|
+
```js
|
|
173
|
+
import { describe, it, before, after } from "node:test";
|
|
174
|
+
import assert from "node:assert/strict";
|
|
175
|
+
import { mkdtempSync, mkdirSync, rmSync } from "fs";
|
|
176
|
+
import { join } from "path";
|
|
177
|
+
import { tmpdir } from "os";
|
|
178
|
+
import { resolvePath } from "../bin/quickclaude.js";
|
|
179
|
+
|
|
180
|
+
describe("resolvePath", () => {
|
|
181
|
+
let root;
|
|
182
|
+
|
|
183
|
+
before(() => {
|
|
184
|
+
root = mkdtempSync(join(tmpdir(), "qc-test-"));
|
|
185
|
+
// Create directory structure:
|
|
186
|
+
// root/Users/test/projects/my-app/
|
|
187
|
+
// root/Users/test/projects/mcp-overwatch/
|
|
188
|
+
// root/Users/test/My Documents/
|
|
189
|
+
mkdirSync(join(root, "Users", "test", "projects", "my-app"), { recursive: true });
|
|
190
|
+
mkdirSync(join(root, "Users", "test", "projects", "mcp-overwatch"), { recursive: true });
|
|
191
|
+
mkdirSync(join(root, "Users", "test", "My Documents"), { recursive: true });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
after(() => {
|
|
195
|
+
rmSync(root, { recursive: true, force: true });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("resolves a simple path", () => {
|
|
199
|
+
const encoded = "-Users-test-projects-my-app";
|
|
200
|
+
assert.equal(resolvePath(encoded, root), join(root, "Users", "test", "projects", "my-app"));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("resolves hyphenated directory names", () => {
|
|
204
|
+
const encoded = "-Users-test-projects-mcp-overwatch";
|
|
205
|
+
assert.equal(resolvePath(encoded, root), join(root, "Users", "test", "projects", "mcp-overwatch"));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("resolves space-separated directory names", () => {
|
|
209
|
+
const encoded = "-Users-test-My-Documents";
|
|
210
|
+
assert.equal(resolvePath(encoded, root), join(root, "Users", "test", "My Documents"));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("returns null for non-existent paths", () => {
|
|
214
|
+
const encoded = "-Users-test-nonexistent";
|
|
215
|
+
assert.equal(resolvePath(encoded, root), null);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("returns null for empty input", () => {
|
|
219
|
+
assert.equal(resolvePath("", root), null);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
- [ ] **Step 2: Add `root` parameter to resolvePath**
|
|
225
|
+
|
|
226
|
+
In `bin/quickclaude.js`, change the function signature:
|
|
227
|
+
|
|
228
|
+
```js
|
|
229
|
+
function resolvePath(encoded, root = sep) {
|
|
230
|
+
const parts = encoded.replace(/^-/, "").split("-");
|
|
231
|
+
if (parts.length === 0 || (parts.length === 1 && parts[0] === "")) return null;
|
|
232
|
+
let current = root;
|
|
233
|
+
let i = 0;
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Also change the Windows drive check to only apply when using default root:
|
|
237
|
+
|
|
238
|
+
```js
|
|
239
|
+
// Windows: "C--Users-..." → drive letter "C:" 처리
|
|
240
|
+
if (root === sep && parts.length >= 1 && /^[A-Za-z]$/.test(parts[0])) {
|
|
241
|
+
const drive = parts[0].toUpperCase() + ":\\";
|
|
242
|
+
if (existsSync(drive)) {
|
|
243
|
+
current = drive;
|
|
244
|
+
i = 1;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
- [ ] **Step 3: Run tests to verify they pass**
|
|
250
|
+
|
|
251
|
+
Run: `npm test`
|
|
252
|
+
Expected: resolvePath tests pass (the logic is already correct, we just made it testable)
|
|
253
|
+
|
|
254
|
+
- [ ] **Step 4: Optimize resolvePath with readdirSync**
|
|
255
|
+
|
|
256
|
+
Replace the inner while loop in `resolvePath`. Instead of calling `existsSync` for each candidate, read the directory once and check against a Set:
|
|
257
|
+
|
|
258
|
+
```js
|
|
259
|
+
while (i < parts.length) {
|
|
260
|
+
let entries;
|
|
261
|
+
try {
|
|
262
|
+
entries = new Set(readdirSync(current));
|
|
263
|
+
} catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let matched = false;
|
|
268
|
+
for (let len = parts.length - i; len >= 1; len--) {
|
|
269
|
+
const segment = parts.slice(i, i + len);
|
|
270
|
+
for (const joiner of ["-", " "]) {
|
|
271
|
+
const candidate = segment.join(joiner);
|
|
272
|
+
if (entries.has(candidate)) {
|
|
273
|
+
current = join(current, candidate);
|
|
274
|
+
i += len;
|
|
275
|
+
matched = true;
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (matched) break;
|
|
280
|
+
}
|
|
281
|
+
if (!matched) return null;
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Note: also add `readdirSync` to the import if not already present (it is — line 4 already imports it).
|
|
286
|
+
|
|
287
|
+
- [ ] **Step 5: Run tests again**
|
|
288
|
+
|
|
289
|
+
Run: `npm test`
|
|
290
|
+
Expected: All tests still pass
|
|
291
|
+
|
|
292
|
+
- [ ] **Step 6: Commit**
|
|
293
|
+
|
|
294
|
+
```bash
|
|
295
|
+
git add bin/quickclaude.js test/resolvePath.test.js
|
|
296
|
+
git commit -m "perf: optimize resolvePath with readdirSync + add tests"
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
### Task 4: mtime accuracy improvement
|
|
302
|
+
|
|
303
|
+
**Files:**
|
|
304
|
+
- Modify: `bin/quickclaude.js:53-77` (getProjects function)
|
|
305
|
+
|
|
306
|
+
- [ ] **Step 1: Write test for getLatestMtime**
|
|
307
|
+
|
|
308
|
+
Add to `test/getProjects.test.js`:
|
|
309
|
+
|
|
310
|
+
```js
|
|
311
|
+
import { describe, it, before, after } from "node:test";
|
|
312
|
+
import assert from "node:assert/strict";
|
|
313
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "fs";
|
|
314
|
+
import { join } from "path";
|
|
315
|
+
import { tmpdir } from "os";
|
|
316
|
+
import { getLatestMtime } from "../bin/quickclaude.js";
|
|
317
|
+
|
|
318
|
+
describe("getLatestMtime", () => {
|
|
319
|
+
let dir;
|
|
320
|
+
|
|
321
|
+
before(() => {
|
|
322
|
+
dir = mkdtempSync(join(tmpdir(), "qc-mtime-"));
|
|
323
|
+
// Create files with different mtimes
|
|
324
|
+
writeFileSync(join(dir, "old.md"), "old");
|
|
325
|
+
writeFileSync(join(dir, "new.md"), "new");
|
|
326
|
+
// Set old.md to 1 hour ago
|
|
327
|
+
const oneHourAgo = new Date(Date.now() - 3600_000);
|
|
328
|
+
utimesSync(join(dir, "old.md"), oneHourAgo, oneHourAgo);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
after(() => {
|
|
332
|
+
rmSync(dir, { recursive: true, force: true });
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("returns the most recent file mtime in a directory", () => {
|
|
336
|
+
const latest = getLatestMtime(dir);
|
|
337
|
+
// new.md was just created, so latest should be very recent
|
|
338
|
+
assert.ok(Date.now() - latest < 5000);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("ignores subdirectories, only checks direct files", () => {
|
|
342
|
+
mkdirSync(join(dir, "subdir"));
|
|
343
|
+
const latest = getLatestMtime(dir);
|
|
344
|
+
assert.ok(latest > 0);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
350
|
+
|
|
351
|
+
Run: `npm test`
|
|
352
|
+
Expected: FAIL — `getLatestMtime` is not exported / doesn't exist
|
|
353
|
+
|
|
354
|
+
- [ ] **Step 3: Implement getLatestMtime and update getProjects**
|
|
355
|
+
|
|
356
|
+
Add the new function in `bin/quickclaude.js` (before `getProjects`):
|
|
357
|
+
|
|
358
|
+
```js
|
|
359
|
+
function getLatestMtime(dirPath) {
|
|
360
|
+
try {
|
|
361
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
362
|
+
let latest = 0;
|
|
363
|
+
for (const entry of entries) {
|
|
364
|
+
if (!entry.isFile()) continue;
|
|
365
|
+
const mtime = statSync(join(dirPath, entry.name)).mtimeMs;
|
|
366
|
+
if (mtime > latest) latest = mtime;
|
|
367
|
+
}
|
|
368
|
+
return latest || statSync(dirPath).mtimeMs;
|
|
369
|
+
} catch {
|
|
370
|
+
return statSync(dirPath).mtimeMs;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
Update `getProjects` to use it. Replace the mtime line:
|
|
376
|
+
|
|
377
|
+
```js
|
|
378
|
+
.map((p) => {
|
|
379
|
+
const projectDir = join(CLAUDE_PROJECTS_DIR, p.dirName);
|
|
380
|
+
const mtime = getLatestMtime(projectDir);
|
|
381
|
+
return { ...p, mtime };
|
|
382
|
+
})
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
Add `getLatestMtime` to the export statement:
|
|
386
|
+
|
|
387
|
+
```js
|
|
388
|
+
export { resolvePath, timeAgo, getProjectLabel, getProjects, getLatestMtime };
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
- [ ] **Step 4: Run tests**
|
|
392
|
+
|
|
393
|
+
Run: `npm test`
|
|
394
|
+
Expected: All tests pass
|
|
395
|
+
|
|
396
|
+
- [ ] **Step 5: Commit**
|
|
397
|
+
|
|
398
|
+
```bash
|
|
399
|
+
git add bin/quickclaude.js test/getProjects.test.js
|
|
400
|
+
git commit -m "fix: use file mtime for accurate project recency"
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
---
|
|
404
|
+
|
|
405
|
+
### Task 5: Error handling
|
|
406
|
+
|
|
407
|
+
**Files:**
|
|
408
|
+
- Modify: `bin/quickclaude.js:100-137` (main function)
|
|
409
|
+
|
|
410
|
+
- [ ] **Step 1: Add claude existence check before spawn**
|
|
411
|
+
|
|
412
|
+
In the `main()` function, after the `p.outro(...)` line (or its replacement) and before the `spawn(...)` call, add:
|
|
413
|
+
|
|
414
|
+
```js
|
|
415
|
+
const whichCmd = process.platform === "win32" ? "where" : "which";
|
|
416
|
+
try {
|
|
417
|
+
execFileSync(whichCmd, ["claude"], { stdio: "ignore" });
|
|
418
|
+
} catch {
|
|
419
|
+
console.error(
|
|
420
|
+
"Error: Claude Code is not installed.\n" +
|
|
421
|
+
"Install it with: npm install -g @anthropic-ai/claude-code"
|
|
422
|
+
);
|
|
423
|
+
process.exit(1);
|
|
424
|
+
}
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
Add `execFileSync` to the imports (replace `execSync` since it's unused):
|
|
428
|
+
|
|
429
|
+
```js
|
|
430
|
+
import { execFileSync, spawn } from "child_process";
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
- [ ] **Step 2: Add spawn error handler**
|
|
434
|
+
|
|
435
|
+
After the existing `child.on("exit", ...)`, add:
|
|
436
|
+
|
|
437
|
+
```js
|
|
438
|
+
child.on("error", (err) => {
|
|
439
|
+
console.error(`Failed to launch Claude: ${err.message}`);
|
|
440
|
+
process.exit(1);
|
|
441
|
+
});
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
- [ ] **Step 3: Test manually**
|
|
445
|
+
|
|
446
|
+
Run: `node bin/quickclaude.js` — should work normally.
|
|
447
|
+
Temporarily rename `claude` or test with a non-existent command to verify the error message.
|
|
448
|
+
|
|
449
|
+
- [ ] **Step 4: Commit**
|
|
450
|
+
|
|
451
|
+
```bash
|
|
452
|
+
git add bin/quickclaude.js
|
|
453
|
+
git commit -m "feat: add error handling for missing claude installation"
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
---
|
|
457
|
+
|
|
458
|
+
### Task 6: Replace @clack/prompts with prompts
|
|
459
|
+
|
|
460
|
+
**Files:**
|
|
461
|
+
- Modify: `bin/quickclaude.js:1-3, 100-125` (imports and main function)
|
|
462
|
+
- Modify: `package.json`
|
|
463
|
+
|
|
464
|
+
- [ ] **Step 1: Swap dependencies**
|
|
465
|
+
|
|
466
|
+
```bash
|
|
467
|
+
npm uninstall @clack/prompts
|
|
468
|
+
npm install prompts
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
- [ ] **Step 2: Update imports in quickclaude.js**
|
|
472
|
+
|
|
473
|
+
Replace line 3:
|
|
474
|
+
|
|
475
|
+
```js
|
|
476
|
+
// Remove: import * as p from "@clack/prompts";
|
|
477
|
+
// Add:
|
|
478
|
+
import prompts from "prompts";
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
- [ ] **Step 3: Rewrite the main function UI**
|
|
482
|
+
|
|
483
|
+
Replace the entire `main()` function body with:
|
|
484
|
+
|
|
485
|
+
```js
|
|
486
|
+
async function main() {
|
|
487
|
+
console.log("\n quickclaude\n");
|
|
488
|
+
|
|
489
|
+
const projects = getProjects();
|
|
490
|
+
|
|
491
|
+
if (projects.length === 0) {
|
|
492
|
+
console.error(" No Claude projects found.\n");
|
|
493
|
+
process.exit(1);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const choices = projects.map((proj) => ({
|
|
497
|
+
title: getProjectLabel(proj.path, proj.mtime),
|
|
498
|
+
value: proj.path,
|
|
499
|
+
}));
|
|
500
|
+
|
|
501
|
+
const response = await prompts({
|
|
502
|
+
type: "autocomplete",
|
|
503
|
+
name: "project",
|
|
504
|
+
message: "Select a project",
|
|
505
|
+
choices,
|
|
506
|
+
suggest: (input, choices) =>
|
|
507
|
+
Promise.resolve(
|
|
508
|
+
choices.filter((c) =>
|
|
509
|
+
c.title.toLowerCase().includes(input.toLowerCase())
|
|
510
|
+
)
|
|
511
|
+
),
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
if (!response.project) {
|
|
515
|
+
console.log(" Cancelled\n");
|
|
516
|
+
process.exit(0);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const args = process.argv.slice(2);
|
|
520
|
+
|
|
521
|
+
const whichCmd = process.platform === "win32" ? "where" : "which";
|
|
522
|
+
try {
|
|
523
|
+
execFileSync(whichCmd, ["claude"], { stdio: "ignore" });
|
|
524
|
+
} catch {
|
|
525
|
+
console.error(
|
|
526
|
+
"Error: Claude Code is not installed.\n" +
|
|
527
|
+
"Install it with: npm install -g @anthropic-ai/claude-code"
|
|
528
|
+
);
|
|
529
|
+
process.exit(1);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
console.log(` Launching Claude in ${response.project}\n`);
|
|
533
|
+
|
|
534
|
+
const child = spawn("claude", args, {
|
|
535
|
+
cwd: response.project,
|
|
536
|
+
stdio: "inherit",
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
child.on("exit", (code) => {
|
|
540
|
+
process.exit(code ?? 0);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
child.on("error", (err) => {
|
|
544
|
+
console.error(`Failed to launch Claude: ${err.message}`);
|
|
545
|
+
process.exit(1);
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
Note: this step also includes Task 5 error handling and Task 7 `shell: true` removal (since we're rewriting the function anyway).
|
|
551
|
+
|
|
552
|
+
- [ ] **Step 4: Run and verify**
|
|
553
|
+
|
|
554
|
+
Run: `node bin/quickclaude.js`
|
|
555
|
+
Expected: Interactive autocomplete menu appears. Type to filter projects. Select one and claude launches.
|
|
556
|
+
|
|
557
|
+
- [ ] **Step 5: Commit**
|
|
558
|
+
|
|
559
|
+
```bash
|
|
560
|
+
git add bin/quickclaude.js package.json package-lock.json
|
|
561
|
+
git commit -m "feat: replace @clack/prompts with prompts for search support
|
|
562
|
+
|
|
563
|
+
Adds type-to-filter autocomplete when selecting projects.
|
|
564
|
+
Also removes shell:true from spawn for safety."
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
---
|
|
568
|
+
|
|
569
|
+
### Task 7: Tag-based publish workflow
|
|
570
|
+
|
|
571
|
+
**Files:**
|
|
572
|
+
- Modify: `.github/workflows/publish.yml`
|
|
573
|
+
|
|
574
|
+
- [ ] **Step 1: Update workflow trigger**
|
|
575
|
+
|
|
576
|
+
Replace the entire `.github/workflows/publish.yml` with:
|
|
577
|
+
|
|
578
|
+
```yaml
|
|
579
|
+
name: Publish to npm
|
|
580
|
+
|
|
581
|
+
on:
|
|
582
|
+
push:
|
|
583
|
+
tags:
|
|
584
|
+
- 'v*'
|
|
585
|
+
|
|
586
|
+
jobs:
|
|
587
|
+
publish:
|
|
588
|
+
runs-on: ubuntu-latest
|
|
589
|
+
permissions:
|
|
590
|
+
contents: read
|
|
591
|
+
id-token: write
|
|
592
|
+
steps:
|
|
593
|
+
- uses: actions/checkout@v5
|
|
594
|
+
- uses: actions/setup-node@v5
|
|
595
|
+
with:
|
|
596
|
+
node-version: 22
|
|
597
|
+
registry-url: https://registry.npmjs.org
|
|
598
|
+
- run: npm install -g npm@latest
|
|
599
|
+
- run: npm --version
|
|
600
|
+
- run: npm ci
|
|
601
|
+
- run: npm publish --provenance --access public
|
|
602
|
+
env:
|
|
603
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
- [ ] **Step 2: Commit**
|
|
607
|
+
|
|
608
|
+
```bash
|
|
609
|
+
git add .github/workflows/publish.yml
|
|
610
|
+
git commit -m "ci: switch publish trigger from push-to-main to version tags"
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
---
|
|
614
|
+
|
|
615
|
+
### Task 8: Final verification
|
|
616
|
+
|
|
617
|
+
- [ ] **Step 1: Run full test suite**
|
|
618
|
+
|
|
619
|
+
Run: `npm test`
|
|
620
|
+
Expected: All tests pass (timeAgo, resolvePath, getProjects/getLatestMtime)
|
|
621
|
+
|
|
622
|
+
- [ ] **Step 2: Run the CLI end-to-end**
|
|
623
|
+
|
|
624
|
+
Run: `node bin/quickclaude.js`
|
|
625
|
+
Expected:
|
|
626
|
+
- Header "quickclaude" prints
|
|
627
|
+
- Autocomplete project list appears, sorted by recent usage
|
|
628
|
+
- Typing filters projects
|
|
629
|
+
- Selecting a project launches claude in that directory
|
|
630
|
+
|
|
631
|
+
- [ ] **Step 3: Verify no leftover @clack/prompts references**
|
|
632
|
+
|
|
633
|
+
Run: `grep -r "clack" bin/ package.json`
|
|
634
|
+
Expected: No matches
|
|
635
|
+
|
|
636
|
+
- [ ] **Step 4: Final commit if any cleanup needed**
|
|
637
|
+
|
|
638
|
+
```bash
|
|
639
|
+
git add -A
|
|
640
|
+
git commit -m "chore: final cleanup after improvements"
|
|
641
|
+
```
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# quickclaude Improvements Design
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Seven improvements to quickclaude addressing performance, reliability, UX, and maintainability.
|
|
6
|
+
|
|
7
|
+
## 1. `resolvePath` Performance
|
|
8
|
+
|
|
9
|
+
**Problem:** Greedy longest-match tries all segment lengths in reverse, calling `existsSync` for each combination. Theoretical worst-case is factorial in segment count.
|
|
10
|
+
|
|
11
|
+
**Solution:** Cache matched path segments to avoid redundant `existsSync` calls. The greedy approach stays the same — it's correct — but repeated lookups for the same prefix are eliminated.
|
|
12
|
+
|
|
13
|
+
## 2. Publish Workflow: Tag-Based Trigger
|
|
14
|
+
|
|
15
|
+
**Problem:** Current workflow runs `npm publish` on every push to `main`. Fails if version hasn't changed.
|
|
16
|
+
|
|
17
|
+
**Solution:** Change trigger from `on: push → branches: main` to `on: push → tags: 'v*'`. Publish only runs when a version tag is pushed (e.g., `git tag v1.2.0 && git push --tags`).
|
|
18
|
+
|
|
19
|
+
## 3. Error Handling
|
|
20
|
+
|
|
21
|
+
**Problem:** No user-facing error messages when `claude` is not installed or spawn fails.
|
|
22
|
+
|
|
23
|
+
**Solution:**
|
|
24
|
+
- Before spawning, check if `claude` exists on PATH using `execFileSync("which", ["claude"])` (or platform equivalent)
|
|
25
|
+
- If not found, print a clear message: "Claude Code is not installed" with install instructions
|
|
26
|
+
- Add `error` event handler on the spawned child process
|
|
27
|
+
|
|
28
|
+
## 4. Tests with `node:test`
|
|
29
|
+
|
|
30
|
+
**Problem:** No tests exist. `resolvePath` has many edge cases that are easy to break.
|
|
31
|
+
|
|
32
|
+
**Solution:** Use Node.js built-in `node:test` (zero dependencies, Node 18+).
|
|
33
|
+
|
|
34
|
+
**Test files:**
|
|
35
|
+
- `test/resolvePath.test.js` — normal paths, hyphenated dirs, spaces, Windows drives, non-existent paths
|
|
36
|
+
- `test/timeAgo.test.js` — boundary values for each time unit
|
|
37
|
+
- `test/getProjects.test.js` — filtering logic (worktrees, non-existent paths)
|
|
38
|
+
|
|
39
|
+
**Module restructure:** Extract `resolvePath`, `timeAgo`, `getProjectLabel`, `getProjects` into importable functions. Keep `main()` as the CLI entry point. The simplest approach: add named exports alongside the existing CLI execution, guarded by an `import.meta.url` check.
|
|
40
|
+
|
|
41
|
+
## 5. Replace `@clack/prompts` with `prompts`
|
|
42
|
+
|
|
43
|
+
**Problem:** `@clack/prompts` `select` does not support type-to-filter. With many projects, finding the right one is slow.
|
|
44
|
+
|
|
45
|
+
**Solution:** Switch to `prompts` library, using its `autocomplete` prompt type. User types to filter projects by path. Display format (relative time + path) stays the same.
|
|
46
|
+
|
|
47
|
+
## 6. Remove `shell: true`
|
|
48
|
+
|
|
49
|
+
**Problem:** `spawn("claude", args, { shell: true })` passes arguments through the shell, risking unintended shell expansion.
|
|
50
|
+
|
|
51
|
+
**Solution:** Remove `shell: true`. Use `spawn("claude", args, { cwd: selected, stdio: "inherit" })`.
|
|
52
|
+
|
|
53
|
+
## 7. Improved mtime Accuracy
|
|
54
|
+
|
|
55
|
+
**Problem:** Using the project directory's own `mtime` which only updates when directory entries change, not when files inside are modified.
|
|
56
|
+
|
|
57
|
+
**Solution:** Read files inside `~/.claude/projects/<dir>/` and use the most recent file `mtime` as the project's last-used time. This reflects actual Claude Code usage since Claude updates files in this directory during sessions.
|
|
58
|
+
|
|
59
|
+
## Scope
|
|
60
|
+
|
|
61
|
+
All changes are within a single file (`bin/quickclaude.js`) plus new test files and workflow update. No new runtime dependencies beyond replacing `@clack/prompts` with `prompts`.
|
|
62
|
+
|
|
63
|
+
## Testing Strategy
|
|
64
|
+
|
|
65
|
+
- Unit tests for pure functions (`resolvePath`, `timeAgo`) with mock filesystem where needed
|
|
66
|
+
- Integration test for `getProjects` with a temporary directory structure
|
|
67
|
+
- Manual verification of `autocomplete` UX
|
|
68
|
+
- `npm test` script added to `package.json`
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "quickclaude",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Quickly launch Claude Code in your project directories",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"quickclaude": "bin/quickclaude.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"start": "node bin/quickclaude.js"
|
|
10
|
+
"start": "node bin/quickclaude.js",
|
|
11
|
+
"test": "node --test test/*.test.js"
|
|
11
12
|
},
|
|
12
13
|
"keywords": [
|
|
13
14
|
"claude",
|
|
@@ -16,11 +17,11 @@
|
|
|
16
17
|
],
|
|
17
18
|
"repository": {
|
|
18
19
|
"type": "git",
|
|
19
|
-
"url": "git+https://github.com/
|
|
20
|
+
"url": "git+https://github.com/onsenhyo/quickclaude.git"
|
|
20
21
|
},
|
|
21
22
|
"author": "",
|
|
22
23
|
"license": "MIT",
|
|
23
24
|
"dependencies": {
|
|
24
|
-
"
|
|
25
|
+
"prompts": "^2.4.2"
|
|
25
26
|
}
|
|
26
27
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { tmpdir } from "os";
|
|
6
|
+
import { getLatestMtime } from "../bin/quickclaude.js";
|
|
7
|
+
|
|
8
|
+
describe("getLatestMtime", () => {
|
|
9
|
+
let dir;
|
|
10
|
+
|
|
11
|
+
before(() => {
|
|
12
|
+
dir = mkdtempSync(join(tmpdir(), "qc-mtime-"));
|
|
13
|
+
// Create files with different mtimes
|
|
14
|
+
writeFileSync(join(dir, "old.md"), "old");
|
|
15
|
+
writeFileSync(join(dir, "new.md"), "new");
|
|
16
|
+
// Set old.md to 1 hour ago
|
|
17
|
+
const oneHourAgo = new Date(Date.now() - 3600_000);
|
|
18
|
+
utimesSync(join(dir, "old.md"), oneHourAgo, oneHourAgo);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
after(() => {
|
|
22
|
+
rmSync(dir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns the most recent file mtime in a directory", () => {
|
|
26
|
+
const latest = getLatestMtime(dir);
|
|
27
|
+
// new.md was just created, so latest should be very recent
|
|
28
|
+
assert.ok(Date.now() - latest < 5000);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("ignores subdirectories, only checks direct files", () => {
|
|
32
|
+
mkdirSync(join(dir, "subdir"));
|
|
33
|
+
const latest = getLatestMtime(dir);
|
|
34
|
+
assert.ok(latest > 0);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, mkdirSync, rmSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { tmpdir } from "os";
|
|
6
|
+
import { resolvePath } from "../bin/quickclaude.js";
|
|
7
|
+
|
|
8
|
+
describe("resolvePath", () => {
|
|
9
|
+
let root;
|
|
10
|
+
|
|
11
|
+
before(() => {
|
|
12
|
+
root = mkdtempSync(join(tmpdir(), "qc-test-"));
|
|
13
|
+
// Create directory structure:
|
|
14
|
+
// root/Users/test/projects/my-app/
|
|
15
|
+
// root/Users/test/projects/mcp-overwatch/
|
|
16
|
+
// root/Users/test/My Documents/
|
|
17
|
+
mkdirSync(join(root, "Users", "test", "projects", "my-app"), { recursive: true });
|
|
18
|
+
mkdirSync(join(root, "Users", "test", "projects", "mcp-overwatch"), { recursive: true });
|
|
19
|
+
mkdirSync(join(root, "Users", "test", "My Documents"), { recursive: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
after(() => {
|
|
23
|
+
rmSync(root, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("resolves a simple path", () => {
|
|
27
|
+
const encoded = "-Users-test-projects-my-app";
|
|
28
|
+
assert.equal(resolvePath(encoded, root), join(root, "Users", "test", "projects", "my-app"));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("resolves hyphenated directory names", () => {
|
|
32
|
+
const encoded = "-Users-test-projects-mcp-overwatch";
|
|
33
|
+
assert.equal(resolvePath(encoded, root), join(root, "Users", "test", "projects", "mcp-overwatch"));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("resolves space-separated directory names", () => {
|
|
37
|
+
const encoded = "-Users-test-My-Documents";
|
|
38
|
+
assert.equal(resolvePath(encoded, root), join(root, "Users", "test", "My Documents"));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns null for non-existent paths", () => {
|
|
42
|
+
const encoded = "-Users-test-nonexistent";
|
|
43
|
+
assert.equal(resolvePath(encoded, root), null);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns null for empty input", () => {
|
|
47
|
+
assert.equal(resolvePath("", root), null);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("skips Windows drive logic when custom root is provided", () => {
|
|
51
|
+
// "C" as first part should not be treated as drive letter when root is custom
|
|
52
|
+
mkdirSync(join(root, "C", "stuff"), { recursive: true });
|
|
53
|
+
const encoded = "-C-stuff";
|
|
54
|
+
assert.equal(resolvePath(encoded, root), join(root, "C", "stuff"));
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { timeAgo } from "../bin/quickclaude.js";
|
|
4
|
+
|
|
5
|
+
describe("timeAgo", () => {
|
|
6
|
+
function msAgo(ms) {
|
|
7
|
+
return Date.now() - ms;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
it("returns 'just now' for <60 seconds ago", () => {
|
|
11
|
+
assert.equal(timeAgo(msAgo(0)), "just now");
|
|
12
|
+
assert.equal(timeAgo(msAgo(30_000)), "just now");
|
|
13
|
+
assert.equal(timeAgo(msAgo(59_000)), "just now");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns minutes for 1-59 minutes ago", () => {
|
|
17
|
+
assert.equal(timeAgo(msAgo(60_000)), "1m ago");
|
|
18
|
+
assert.equal(timeAgo(msAgo(30 * 60_000)), "30m ago");
|
|
19
|
+
assert.equal(timeAgo(msAgo(59 * 60_000)), "59m ago");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns hours for 1-23 hours ago", () => {
|
|
23
|
+
assert.equal(timeAgo(msAgo(60 * 60_000)), "1h ago");
|
|
24
|
+
assert.equal(timeAgo(msAgo(12 * 60 * 60_000)), "12h ago");
|
|
25
|
+
assert.equal(timeAgo(msAgo(23 * 60 * 60_000)), "23h ago");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns days for 1-6 days ago", () => {
|
|
29
|
+
assert.equal(timeAgo(msAgo(24 * 60 * 60_000)), "1d ago");
|
|
30
|
+
assert.equal(timeAgo(msAgo(6 * 24 * 60 * 60_000)), "6d ago");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns weeks for 7-27 days ago", () => {
|
|
34
|
+
assert.equal(timeAgo(msAgo(7 * 24 * 60 * 60_000)), "1w ago");
|
|
35
|
+
assert.equal(timeAgo(msAgo(21 * 24 * 60 * 60_000)), "3w ago");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns months for 28+ days ago", () => {
|
|
39
|
+
assert.equal(timeAgo(msAgo(30 * 24 * 60 * 60_000)), "1mo ago");
|
|
40
|
+
assert.equal(timeAgo(msAgo(90 * 24 * 60 * 60_000)), "3mo ago");
|
|
41
|
+
});
|
|
42
|
+
});
|