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.
@@ -2,8 +2,8 @@ name: Publish to npm
2
2
 
3
3
  on:
4
4
  push:
5
- tags:
6
- - "v*"
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@v4
16
- - uses: actions/setup-node@v4
15
+ - uses: actions/checkout@v5
16
+ - uses: actions/setup-node@v5
17
17
  with:
18
18
  node-version: 22
19
- - run: npm install -g npm@latest
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
- ## How it works
55
+ ## Update
33
56
 
34
- 1. Scans `~/.claude/projects/` for Claude Code project directories
35
- 2. Shows an interactive list to pick from
36
- 3. Launches `claude` in the selected directory
57
+ ```bash
58
+ npm update -g quickclaude
59
+ ```
37
60
 
38
61
  ## Requirements
39
62
 
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import * as p from "@clack/prompts";
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 { execSync, spawn } from "child_process";
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
- let current = sep;
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
- const fullPath = join(current, candidate);
38
- if (existsSync(fullPath)) {
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
- .sort((a, b) => a.path.localeCompare(b.path));
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
- p.intro("quickclaude");
126
+ console.log("\n quickclaude\n");
82
127
 
83
128
  const projects = getProjects();
84
129
 
85
130
  if (projects.length === 0) {
86
- p.cancel("No Claude projects found.");
131
+ console.error(" No Claude projects found.\n");
87
132
  process.exit(1);
88
133
  }
89
134
 
90
- const selected = await p.select({
91
- message: "Select a project",
92
- options: projects.map((proj) => ({
93
- value: proj.path,
94
- label: getProjectLabel(proj.path),
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 (p.isCancel(selected)) {
99
- p.cancel("Cancelled");
161
+ if (!response.project) {
162
+ console.log(" Cancelled\n");
100
163
  process.exit(0);
101
164
  }
102
165
 
103
- p.outro(`Launching Claude in ${selected}`);
166
+ const args = process.argv.slice(2);
104
167
 
105
- // claude 실행 (현재 터미널에서 interactive하게)
106
- const child = spawn("claude", [], {
107
- cwd: selected,
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
- main();
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.0.8",
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/strurao/quickclaude.git"
20
+ "url": "git+https://github.com/onsenhyo/quickclaude.git"
20
21
  },
21
22
  "author": "",
22
23
  "license": "MIT",
23
24
  "dependencies": {
24
- "@clack/prompts": "^1.1.0"
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
+ });