cc-hub-cli 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bing Tong
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1 @@
1
+ # cc-hub
package/dist/index.js ADDED
@@ -0,0 +1,788 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command as Command4 } from "commander";
5
+
6
+ // src/profiles.ts
7
+ import { Command } from "commander";
8
+ import Anthropic from "@anthropic-ai/sdk";
9
+
10
+ // src/config.ts
11
+ import fs from "fs";
12
+ import path from "path";
13
+ import os from "os";
14
+ var CLAUDE_DIR = process.env.CLAUDE_DIR || path.join(os.homedir(), ".claude");
15
+ var PROFILES_FILE = process.env.CLAUDE_PROFILES_FILE || path.join(CLAUDE_DIR, "profiles.json");
16
+ var SETTINGS_FILE = process.env.CLAUDE_SETTINGS_FILE || path.join(CLAUDE_DIR, "settings.json");
17
+ var PROJECTS_DIR = path.join(CLAUDE_DIR, "projects");
18
+ var SESSIONS_DIR = path.join(CLAUDE_DIR, "sessions");
19
+ function ensureFile(filePath, defaultContent) {
20
+ if (!fs.existsSync(filePath)) {
21
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
22
+ fs.writeFileSync(filePath, defaultContent, "utf-8");
23
+ }
24
+ }
25
+ function readJson(filePath) {
26
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
27
+ }
28
+ function writeJson(filePath, data) {
29
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
30
+ }
31
+ function ensureProfilesFile() {
32
+ ensureFile(PROFILES_FILE, '{"profiles":{}}\n');
33
+ }
34
+ function ensureSettingsFile() {
35
+ ensureFile(SETTINGS_FILE, "{}\n");
36
+ }
37
+
38
+ // src/profiles.ts
39
+ function maskToken(token) {
40
+ if (!token) return "(unset)";
41
+ if (token.length <= 12) return token;
42
+ return token.slice(0, 8) + "..." + token.slice(-4);
43
+ }
44
+ function profileCommand() {
45
+ const profile = new Command("profile").description("Manage Claude CLI profiles");
46
+ profile.command("add").description("Add or update a profile").argument("<name>", "Profile name").option("-m, --model <model>", "Model ID (e.g. claude-opus-4-6)").option("-t, --token <token>", "API key / token").option("-u, --url <url>", "Base URL (e.g. https://api.anthropic.com)").action((name, opts) => {
47
+ ensureProfilesFile();
48
+ const data = readJson(PROFILES_FILE);
49
+ const profile2 = data.profiles[name] || {};
50
+ if (opts.model) profile2.model = opts.model;
51
+ if (opts.token) profile2.token = opts.token;
52
+ if (opts.url) profile2.url = opts.url;
53
+ data.profiles[name] = profile2;
54
+ writeJson(PROFILES_FILE, data);
55
+ console.log(`Profile '${name}' saved.`);
56
+ });
57
+ profile.command("update").description("Update fields of an existing profile").argument("<name>", "Profile name (must already exist)").option("-m, --model <model>", "Model ID").option("-t, --token <token>", "API key / token").option("-u, --url <url>", "Base URL").action((name, opts) => {
58
+ ensureProfilesFile();
59
+ const data = readJson(PROFILES_FILE);
60
+ if (!data.profiles[name]) {
61
+ console.error(`Profile '${name}' not found. Use 'profile add' to create it.`);
62
+ process.exit(1);
63
+ }
64
+ const p = data.profiles[name];
65
+ if (opts.model) p.model = opts.model;
66
+ if (opts.token) p.token = opts.token;
67
+ if (opts.url) p.url = opts.url;
68
+ writeJson(PROFILES_FILE, data);
69
+ console.log(`Profile '${name}' updated.`);
70
+ });
71
+ profile.command("list").description("List all profiles").action(() => {
72
+ ensureProfilesFile();
73
+ const data = readJson(PROFILES_FILE);
74
+ const profiles = data.profiles;
75
+ const names = Object.keys(profiles);
76
+ if (names.length === 0) {
77
+ console.log("No profiles defined. Use 'profile add' to create one.");
78
+ return;
79
+ }
80
+ const def = data.default || "";
81
+ const fmt = (marker, name, model, token, url) => `${marker.padEnd(2)} ${name.padEnd(20)} ${model.padEnd(30)} ${token.padEnd(20)} ${url}`;
82
+ console.log(fmt("", "NAME", "MODEL", "TOKEN", "URL"));
83
+ console.log(fmt("", "----", "-----", "-----", "---"));
84
+ for (const name of names) {
85
+ const p = profiles[name];
86
+ const marker = name === def ? "* " : " ";
87
+ console.log(fmt(
88
+ marker,
89
+ name,
90
+ p.model || "(unset)",
91
+ maskToken(p.token || ""),
92
+ p.url || "(default)"
93
+ ));
94
+ }
95
+ });
96
+ profile.command("view").description("View full details of a profile (token unmasked)").argument("<name>", "Profile name").option("-j, --json", "Output as JSON").action((name, opts) => {
97
+ ensureProfilesFile();
98
+ const data = readJson(PROFILES_FILE);
99
+ const p = data.profiles[name];
100
+ if (!p) {
101
+ console.error(`Profile '${name}' not found.`);
102
+ process.exit(1);
103
+ }
104
+ if (opts.json) {
105
+ console.log(JSON.stringify({ name, ...p }, null, 2));
106
+ } else {
107
+ console.log(`Name: ${name}`);
108
+ console.log(`Model: ${p.model || "(unset)"}`);
109
+ console.log(`Token: ${p.token || "(unset)"}`);
110
+ console.log(`URL: ${p.url || "(default)"}`);
111
+ }
112
+ });
113
+ profile.command("remove").description("Remove a profile").argument("<name>", "Profile name").action((name) => {
114
+ ensureProfilesFile();
115
+ const data = readJson(PROFILES_FILE);
116
+ if (!data.profiles[name]) {
117
+ console.error(`Profile '${name}' not found.`);
118
+ process.exit(1);
119
+ }
120
+ delete data.profiles[name];
121
+ writeJson(PROFILES_FILE, data);
122
+ console.log(`Profile '${name}' removed.`);
123
+ });
124
+ profile.command("default").description("Set the default profile").argument("<name>", "Profile name to set as default").action((name) => {
125
+ ensureProfilesFile();
126
+ const data = readJson(PROFILES_FILE);
127
+ if (!data.profiles[name]) {
128
+ console.error(`Profile '${name}' not found.`);
129
+ process.exit(1);
130
+ }
131
+ data.default = name;
132
+ writeJson(PROFILES_FILE, data);
133
+ console.log(`Default profile set to '${name}'.`);
134
+ });
135
+ return profile;
136
+ }
137
+ async function runWithProfile(profileName, prompt) {
138
+ ensureProfilesFile();
139
+ const data = readJson(PROFILES_FILE);
140
+ const p = data.profiles[profileName];
141
+ if (!p) {
142
+ console.error(`Profile '${profileName}' not found.`);
143
+ process.exit(1);
144
+ }
145
+ if (!p.token) {
146
+ console.error(`Profile '${profileName}' has no token configured.`);
147
+ process.exit(1);
148
+ }
149
+ const client = new Anthropic({
150
+ apiKey: p.token,
151
+ ...p.url ? { baseURL: p.url } : {}
152
+ });
153
+ console.error(`Using profile '${profileName}': model=${p.model || "(default)"} url=${p.url || "(default)"}`);
154
+ const stream = client.messages.stream(
155
+ {
156
+ model: p.model || "claude-sonnet-4-20250514",
157
+ max_tokens: 4096,
158
+ messages: [{ role: "user", content: prompt }]
159
+ }
160
+ );
161
+ for await (const event of stream) {
162
+ if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
163
+ process.stdout.write(event.delta.text);
164
+ }
165
+ }
166
+ process.stdout.write("\n");
167
+ }
168
+ function useCommand() {
169
+ return new Command("use").description("Launch a chat session using a saved profile").argument("<name>", "Profile name").argument("[prompt...]", "Prompt text").action(async (name, promptParts) => {
170
+ const prompt = promptParts.join(" ");
171
+ if (!prompt) {
172
+ ensureProfilesFile();
173
+ const data = readJson(PROFILES_FILE);
174
+ if (!data.profiles[name]) {
175
+ console.error(`Profile '${name}' not found.`);
176
+ process.exit(1);
177
+ }
178
+ data.default = name;
179
+ writeJson(PROFILES_FILE, data);
180
+ console.log(`Default profile set to '${name}'.`);
181
+ return;
182
+ }
183
+ await runWithProfile(name, prompt);
184
+ });
185
+ }
186
+ function runCommand() {
187
+ return new Command("run").description("Run a prompt using the default or a specified profile").argument("[args...]", "Optional profile name followed by prompt").action(async (args) => {
188
+ ensureProfilesFile();
189
+ const data = readJson(PROFILES_FILE);
190
+ let profileName = "";
191
+ let promptParts;
192
+ if (args.length > 0 && data.profiles[args[0]]) {
193
+ profileName = args[0];
194
+ promptParts = args.slice(1);
195
+ } else {
196
+ profileName = data.default || "";
197
+ promptParts = args;
198
+ }
199
+ if (!profileName) {
200
+ console.error("No default profile set. Use 'cc-hub use <name>' first.");
201
+ process.exit(1);
202
+ }
203
+ const prompt = promptParts.join(" ");
204
+ if (!prompt) {
205
+ console.error("No prompt provided.");
206
+ process.exit(1);
207
+ }
208
+ await runWithProfile(profileName, prompt);
209
+ });
210
+ }
211
+
212
+ // src/hooks.ts
213
+ import { Command as Command2 } from "commander";
214
+ function buildFlat(data) {
215
+ const rows = [];
216
+ const hooksRoot = data.hooks || {};
217
+ for (const [event, groups] of Object.entries(hooksRoot)) {
218
+ for (let gi = 0; gi < groups.length; gi++) {
219
+ const g = groups[gi];
220
+ for (let hi = 0; hi < g.hooks.length; hi++) {
221
+ const h = g.hooks[hi];
222
+ rows.push({
223
+ seq: h._seq || 0,
224
+ active: true,
225
+ event,
226
+ matcher: g.matcher || "",
227
+ command: h.command,
228
+ gi,
229
+ hi,
230
+ di: -1
231
+ });
232
+ }
233
+ }
234
+ }
235
+ const pool = data._cc_hub_disabled || [];
236
+ for (let di = 0; di < pool.length; di++) {
237
+ const e = pool[di];
238
+ rows.push({
239
+ seq: e._seq || 0,
240
+ active: false,
241
+ event: e.event || "?",
242
+ matcher: e.matcher || "",
243
+ command: e.command,
244
+ gi: -1,
245
+ hi: -1,
246
+ di
247
+ });
248
+ }
249
+ rows.sort((a, b) => a.seq - b.seq);
250
+ return rows;
251
+ }
252
+ function hooksCommand() {
253
+ const hooks = new Command2("hooks").alias("h").description("Manage Claude Code hooks in settings.json");
254
+ hooks.command("list").description("List all hooks").action(() => {
255
+ ensureSettingsFile();
256
+ const data = readJson(SETTINGS_FILE);
257
+ const rows = buildFlat(data);
258
+ if (rows.length === 0) {
259
+ console.log("No hooks defined.");
260
+ return;
261
+ }
262
+ const fmt = (idx, active, event, matcher, cmd) => `${String(idx).padEnd(4)} ${active.padEnd(2)} ${event.padEnd(22)} ${matcher.padEnd(25)} ${cmd}`;
263
+ console.log(fmt(0, "", "EVENT", "MATCHER", "COMMAND").replace(/^IDX/, "IDX").replace(/^0/, "IDX"));
264
+ console.log(fmt(0, "", "-----", "-------", "-------").replace(/^0/, "---"));
265
+ for (let idx = 0; idx < rows.length; idx++) {
266
+ const r = rows[idx];
267
+ const marker = r.active ? " " : "\u2717";
268
+ const matcher = r.matcher || "(any)";
269
+ const cmd = r.command.length > 60 ? r.command.slice(0, 60) + "\u2026" : r.command;
270
+ console.log(fmt(idx, marker, r.event, matcher, cmd));
271
+ }
272
+ });
273
+ hooks.command("add").description("Add a hook to settings.json").requiredOption("-e, --event <event>", "Hook event (PreToolUse|PostToolUse|Notification|Stop|UserPromptSubmit|PermissionRequest)").option("-m, --matcher <matcher>", "Tool name matcher (omit for catch-all)").requiredOption("-c, --command <command>", "Shell command to run").option("-a, --async", "Run the hook asynchronously").action((opts) => {
274
+ ensureSettingsFile();
275
+ const data = readJson(SETTINGS_FILE);
276
+ const hooksRoot = data.hooks || (data.hooks = {});
277
+ const groups = hooksRoot[opts.event] || (hooksRoot[opts.event] = []);
278
+ const matcher = opts.matcher || "";
279
+ let targetGroup = groups.find((g) => (g.matcher || "") === matcher);
280
+ if (!targetGroup) {
281
+ targetGroup = { hooks: [] };
282
+ if (matcher) targetGroup.matcher = matcher;
283
+ groups.push(targetGroup);
284
+ }
285
+ const seq = (data._cc_hub_seq || 0) + 1;
286
+ data._cc_hub_seq = seq;
287
+ const newHook = { type: "command", command: opts.command, _seq: seq };
288
+ if (opts.async) newHook.async = true;
289
+ targetGroup.hooks.push(newHook);
290
+ writeJson(SETTINGS_FILE, data);
291
+ console.log(`Hook added to event '${opts.event}'${matcher ? ` matcher='${matcher}'` : ""}.`);
292
+ });
293
+ hooks.command("remove").description("Remove a hook by its global index (see 'hooks list')").requiredOption("-i, --index <index>", "Global index from 'hooks list'", parseInt).action((opts) => {
294
+ ensureSettingsFile();
295
+ const data = readJson(SETTINGS_FILE);
296
+ const rows = buildFlat(data);
297
+ const target = opts.index;
298
+ if (target < 0 || target >= rows.length) {
299
+ console.error(`Index ${target} out of range (0-${rows.length - 1}).`);
300
+ process.exit(1);
301
+ }
302
+ const r = rows[target];
303
+ if (r.active) {
304
+ const hooksRoot = data.hooks;
305
+ hooksRoot[r.event][r.gi].hooks.splice(r.hi, 1);
306
+ hooksRoot[r.event] = hooksRoot[r.event].filter((g) => g.hooks.length > 0);
307
+ if (hooksRoot[r.event].length === 0) delete hooksRoot[r.event];
308
+ } else {
309
+ const pool = data._cc_hub_disabled;
310
+ pool.splice(r.di, 1);
311
+ if (pool.length === 0) delete data._cc_hub_disabled;
312
+ }
313
+ writeJson(SETTINGS_FILE, data);
314
+ console.log(`Hook ${target} removed.`);
315
+ });
316
+ hooks.command("enable").description("Enable one or more disabled hooks").requiredOption("-i, --index <indexes...>", "Global index from 'hooks list' (repeatable)", (v, prev) => {
317
+ prev = prev || [];
318
+ prev.push(parseInt(v));
319
+ return prev;
320
+ }).action((opts) => {
321
+ ensureSettingsFile();
322
+ const data = readJson(SETTINGS_FILE);
323
+ const rows = buildFlat(data);
324
+ const targets = [...new Set(opts.index)].sort((a, b) => a - b);
325
+ const errors = [];
326
+ for (const t of targets) {
327
+ if (t < 0 || t >= rows.length) errors.push(`Index ${t} out of range (0-${rows.length - 1}).`);
328
+ else if (rows[t].active) errors.push(`Index ${t} is already active.`);
329
+ }
330
+ if (errors.length > 0) {
331
+ for (const e of errors) console.error(e);
332
+ process.exit(1);
333
+ }
334
+ const hooksRoot = data.hooks || (data.hooks = {});
335
+ const pool = data._cc_hub_disabled;
336
+ const seqsToEnable = new Set(targets.map((t) => rows[t].seq));
337
+ const remaining = [];
338
+ const toRestore = [];
339
+ for (const entry of pool) {
340
+ if (seqsToEnable.has(entry._seq || 0)) {
341
+ toRestore.push(entry);
342
+ } else {
343
+ remaining.push(entry);
344
+ }
345
+ }
346
+ for (const entry of toRestore) {
347
+ const event = entry.event;
348
+ const matcher = entry.matcher || "";
349
+ const hook = { type: entry.type, command: entry.command, _seq: entry._seq, ...entry.async ? { async: true } : {} };
350
+ const groups = hooksRoot[event] || (hooksRoot[event] = []);
351
+ let grp = groups.find((g) => (g.matcher || "") === matcher);
352
+ if (!grp) {
353
+ grp = { hooks: [] };
354
+ if (matcher) grp.matcher = matcher;
355
+ groups.push(grp);
356
+ }
357
+ grp.hooks.push(hook);
358
+ const t = rows.findIndex((r) => r.seq === entry._seq);
359
+ console.log(`Hook ${t} (${event}) enabled.`);
360
+ }
361
+ data._cc_hub_disabled = remaining;
362
+ if (remaining.length === 0) delete data._cc_hub_disabled;
363
+ writeJson(SETTINGS_FILE, data);
364
+ });
365
+ hooks.command("disable").description("Disable one or more hooks (removes from active)").requiredOption("-i, --index <indexes...>", "Global index from 'hooks list' (repeatable)", (v, prev) => {
366
+ prev = prev || [];
367
+ prev.push(parseInt(v));
368
+ return prev;
369
+ }).action((opts) => {
370
+ ensureSettingsFile();
371
+ const data = readJson(SETTINGS_FILE);
372
+ const rows = buildFlat(data);
373
+ const targets = [...new Set(opts.index)].sort((a, b) => a - b);
374
+ const errors = [];
375
+ for (const t of targets) {
376
+ if (t < 0 || t >= rows.length) errors.push(`Index ${t} out of range (0-${rows.length - 1}).`);
377
+ else if (!rows[t].active) errors.push(`Index ${t} is already disabled.`);
378
+ }
379
+ if (errors.length > 0) {
380
+ for (const e of errors) console.error(e);
381
+ process.exit(1);
382
+ }
383
+ const hooksRoot = data.hooks;
384
+ const pool = data._cc_hub_disabled || (data._cc_hub_disabled = []);
385
+ for (const t of [...targets].reverse()) {
386
+ const r = rows[t];
387
+ const hook = hooksRoot[r.event][r.gi].hooks[r.hi];
388
+ const entry = {
389
+ event: r.event,
390
+ ...hook
391
+ };
392
+ if (r.matcher) entry.matcher = r.matcher;
393
+ pool.push(entry);
394
+ hooksRoot[r.event][r.gi].hooks.splice(r.hi, 1);
395
+ hooksRoot[r.event] = hooksRoot[r.event].filter((g) => g.hooks.length > 0);
396
+ if (hooksRoot[r.event].length === 0) delete hooksRoot[r.event];
397
+ console.log(`Hook ${t} (${r.event}) disabled.`);
398
+ }
399
+ writeJson(SETTINGS_FILE, data);
400
+ });
401
+ return hooks;
402
+ }
403
+
404
+ // src/sessions.ts
405
+ import { Command as Command3 } from "commander";
406
+ import fs2 from "fs";
407
+ import path2 from "path";
408
+ import { execSync } from "child_process";
409
+ function encodePath(p) {
410
+ return p.replace(/\./g, "DOTMARK").replace(/\//g, "-").replace(/DOTMARK/g, "-");
411
+ }
412
+ function decodePath(encoded) {
413
+ return encoded.replace(/--/g, "/.").replace(/-/g, "/");
414
+ }
415
+ function formatTimestamp(ms) {
416
+ const d = new Date(ms);
417
+ const pad = (n) => String(n).padStart(2, "0");
418
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
419
+ }
420
+ function findProjectDir(query) {
421
+ const encoded = encodePath(query);
422
+ if (fs2.existsSync(path2.join(PROJECTS_DIR, encoded))) return encoded;
423
+ try {
424
+ const dirs = fs2.readdirSync(PROJECTS_DIR);
425
+ const match = dirs.find((d) => d.toLowerCase().includes(query.toLowerCase()));
426
+ return match || null;
427
+ } catch {
428
+ return null;
429
+ }
430
+ }
431
+ function parseSessionMeta(filePath) {
432
+ let started = "?";
433
+ let slug = "";
434
+ let customTitle = "";
435
+ try {
436
+ const lines = fs2.readFileSync(filePath, "utf-8").split("\n");
437
+ for (const line of lines) {
438
+ if (!line.trim()) continue;
439
+ try {
440
+ const d = JSON.parse(line);
441
+ if (started === "?") {
442
+ const ts = d.timestamp;
443
+ if (ts) {
444
+ const dt = typeof ts === "number" ? new Date(ts) : new Date(String(ts).replace("Z", "+00:00"));
445
+ started = formatTimestamp(dt.getTime());
446
+ }
447
+ }
448
+ if (!slug) slug = d.slug || "";
449
+ if (d.type === "custom-title") customTitle = d.customTitle || "";
450
+ } catch {
451
+ }
452
+ }
453
+ } catch {
454
+ }
455
+ return { started, slug: customTitle || slug };
456
+ }
457
+ function extractText(d) {
458
+ const message = d.message;
459
+ let content;
460
+ let role = "";
461
+ if (message) {
462
+ content = message.content;
463
+ role = message.role || "";
464
+ } else {
465
+ content = d.content;
466
+ role = d.type || d.operation || "";
467
+ }
468
+ if (Array.isArray(content)) {
469
+ for (const p of content) {
470
+ if (typeof p === "object" && p !== null && p.type === "text") {
471
+ return { role, text: p.text };
472
+ }
473
+ }
474
+ } else if (typeof content === "string") {
475
+ return { role, text: content };
476
+ }
477
+ return { role: "", text: "" };
478
+ }
479
+ function snippet(text, query, width = 150) {
480
+ const idx = text.toLowerCase().indexOf(query.toLowerCase());
481
+ if (idx === -1) return text.slice(0, width);
482
+ const start = Math.max(0, idx - Math.floor(width / 3));
483
+ const end = Math.min(text.length, start + width);
484
+ const prefix = start > 0 ? "..." : "";
485
+ const suffix = end < text.length ? "..." : "";
486
+ return prefix + text.slice(start, end) + suffix;
487
+ }
488
+ function sessionCommand() {
489
+ const session = new Command3("session").description("Manage Claude Code sessions");
490
+ session.command("list").description("List all Claude Code project sessions").option("-n, --limit <n>", "Max number of projects to show", "30").option("-s, --short", "Show encoded names only (no decoding)").option("-j, --json", "Output as JSON lines").action((opts) => {
491
+ const limit = parseInt(opts.limit, 10);
492
+ let dirs;
493
+ try {
494
+ dirs = fs2.readdirSync(PROJECTS_DIR);
495
+ } catch {
496
+ console.log("No projects directory found.");
497
+ return;
498
+ }
499
+ dirs.sort((a, b) => {
500
+ const statA = fs2.statSync(path2.join(PROJECTS_DIR, a));
501
+ const statB = fs2.statSync(path2.join(PROJECTS_DIR, b));
502
+ return statB.mtimeMs - statA.mtimeMs;
503
+ });
504
+ let count = 0;
505
+ for (const projDir of dirs) {
506
+ if (count >= limit) break;
507
+ const fullPath = path2.join(PROJECTS_DIR, projDir);
508
+ let nSessions = 0;
509
+ try {
510
+ nSessions = fs2.readdirSync(fullPath).filter((f) => f.endsWith(".jsonl")).length;
511
+ } catch {
512
+ }
513
+ const stat = fs2.statSync(fullPath);
514
+ const decoded = decodePath(projDir);
515
+ if (opts.json) {
516
+ console.log(JSON.stringify({ project: decoded, sessions: nSessions, modified: Math.floor(stat.mtimeMs) }));
517
+ } else if (opts.short) {
518
+ console.log(projDir);
519
+ } else {
520
+ console.log(`${decoded.padEnd(55)} ${String(nSessions).padStart(3)} session(s) ${formatTimestamp(stat.mtimeMs)}`);
521
+ }
522
+ count++;
523
+ }
524
+ });
525
+ session.command("show").description("Show session files for a project").argument("<project>", "Project path or encoded name (partial match ok)").option("-v, --verbose", "Show first user message of each session").action((project, opts) => {
526
+ const projDir = findProjectDir(project);
527
+ if (!projDir) {
528
+ console.error(`No project matched: ${project}`);
529
+ process.exit(1);
530
+ }
531
+ const fullPath = path2.join(PROJECTS_DIR, projDir);
532
+ console.log(`Project: ${decodePath(projDir)}`);
533
+ console.log(`Dir: ${fullPath}`);
534
+ console.log("");
535
+ const fmt = (sid, name, started, msgs) => `${sid.padEnd(36)} ${name.padEnd(30)} ${started.padEnd(17)} ${msgs}`;
536
+ console.log(fmt("Session ID", "Name", "Started", "Messages"));
537
+ console.log(fmt("----------", "----", "-------", "--------"));
538
+ let files;
539
+ try {
540
+ files = fs2.readdirSync(fullPath).filter((f) => f.endsWith(".jsonl"));
541
+ } catch {
542
+ return;
543
+ }
544
+ for (const file of files) {
545
+ const filePath = path2.join(fullPath, file);
546
+ const sessionId = file.replace(/\.jsonl$/, "");
547
+ let msgCount = 0;
548
+ try {
549
+ const content = fs2.readFileSync(filePath, "utf-8");
550
+ msgCount = content ? content.split("\n").filter((l) => l.trim()).length : 0;
551
+ } catch {
552
+ }
553
+ const { started, slug } = parseSessionMeta(filePath);
554
+ console.log(fmt(sessionId, slug || "-", started, String(msgCount)));
555
+ if (opts.verbose) {
556
+ try {
557
+ const lines = fs2.readFileSync(filePath, "utf-8").split("\n");
558
+ for (const line of lines) {
559
+ if (!line.trim()) continue;
560
+ try {
561
+ const d = JSON.parse(line);
562
+ if (d.type === "user") {
563
+ const content = d.message?.content;
564
+ if (Array.isArray(content)) {
565
+ for (const part of content) {
566
+ if (typeof part === "object" && part?.type === "text") {
567
+ console.log(` > ${part.text.slice(0, 120)}`);
568
+ break;
569
+ }
570
+ }
571
+ } else if (typeof content === "string") {
572
+ console.log(` > ${content.slice(0, 120)}`);
573
+ }
574
+ break;
575
+ }
576
+ } catch {
577
+ }
578
+ }
579
+ } catch {
580
+ }
581
+ }
582
+ }
583
+ });
584
+ session.command("search").description("Search conversation history across all projects").argument("<query>", "Text to search for").option("-p, --project <project>", "Filter to a specific project (partial match)").option("-n, --limit <n>", "Max number of matching files to show", "20").option("-i, --ignore-case", "Case-insensitive search").action((query, opts) => {
585
+ let searchRoot = PROJECTS_DIR;
586
+ if (opts.project) {
587
+ const projDir = findProjectDir(opts.project);
588
+ if (!projDir) {
589
+ console.error(`No project matched: ${opts.project}`);
590
+ process.exit(1);
591
+ }
592
+ searchRoot = path2.join(PROJECTS_DIR, projDir);
593
+ }
594
+ const limit = parseInt(opts.limit, 10);
595
+ let count = 0;
596
+ function searchDir(dir) {
597
+ if (count >= limit) return;
598
+ let entries;
599
+ try {
600
+ entries = fs2.readdirSync(dir, { withFileTypes: true });
601
+ } catch {
602
+ return;
603
+ }
604
+ for (const entry of entries) {
605
+ if (count >= limit) break;
606
+ const fullPath = path2.join(dir, entry.name);
607
+ if (entry.isDirectory()) {
608
+ searchDir(fullPath);
609
+ } else if (entry.name.endsWith(".jsonl")) {
610
+ try {
611
+ const content = fs2.readFileSync(fullPath, "utf-8");
612
+ const lines = content.split("\n");
613
+ let found = false;
614
+ for (let lineno = 0; lineno < lines.length; lineno++) {
615
+ const line = lines[lineno];
616
+ if (!line.trim()) continue;
617
+ const match = opts.ignoreCase ? line.toLowerCase().includes(query.toLowerCase()) : line.includes(query);
618
+ if (match) {
619
+ if (!found) {
620
+ const relPath = path2.relative(PROJECTS_DIR, fullPath);
621
+ const projEnc = relPath.split("/")[0];
622
+ const sessionId = path2.basename(fullPath, ".jsonl");
623
+ console.log(`[${decodePath(projEnc)} \u2192 ${sessionId}]`);
624
+ found = true;
625
+ count++;
626
+ }
627
+ try {
628
+ const d = JSON.parse(line);
629
+ const { role, text } = extractText(d);
630
+ if (text) {
631
+ console.log(` line ${lineno + 1} [${role}]: ${snippet(text, query)}`);
632
+ } else {
633
+ console.log(` line ${lineno + 1}: ${line.slice(0, 140)}`);
634
+ }
635
+ } catch {
636
+ console.log(` line ${lineno + 1}: ${line.slice(0, 140)}`);
637
+ }
638
+ const matchCount = lines.slice(0, lineno + 1).filter((l, i) => {
639
+ if (i > lineno) return false;
640
+ return opts.ignoreCase ? l.toLowerCase().includes(query.toLowerCase()) : l.includes(query);
641
+ }).length;
642
+ if (matchCount >= 5) break;
643
+ }
644
+ }
645
+ if (found) console.log("");
646
+ } catch {
647
+ }
648
+ }
649
+ }
650
+ }
651
+ searchDir(searchRoot);
652
+ });
653
+ session.command("ps").description("Show active Claude Code processes").action(() => {
654
+ let files;
655
+ try {
656
+ files = fs2.readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".json"));
657
+ } catch {
658
+ console.log("(no session files found)");
659
+ return;
660
+ }
661
+ if (files.length === 0) {
662
+ console.log("(no session files found)");
663
+ return;
664
+ }
665
+ const fmt = (pid, sid, started, cwd, status) => `${pid.padEnd(8)} ${sid.padEnd(40)} ${started.padEnd(20)} ${cwd}${status}`;
666
+ console.log(fmt("PID", "Session ID", "Started", "CWD", ""));
667
+ console.log(fmt("---", "----------", "-------", "---", ""));
668
+ for (const file of files) {
669
+ try {
670
+ const data = JSON.parse(fs2.readFileSync(path2.join(SESSIONS_DIR, file), "utf-8"));
671
+ const pid = String(data.pid || "?");
672
+ const sessionId = data.sessionId || "?";
673
+ const cwd = data.cwd || "?";
674
+ const startedMs = data.startedAt || 0;
675
+ let alive = " [dead]";
676
+ try {
677
+ process.kill(Number(pid), 0);
678
+ alive = " [running]";
679
+ } catch {
680
+ }
681
+ console.log(fmt(pid, sessionId, formatTimestamp(startedMs), cwd, alive));
682
+ } catch {
683
+ }
684
+ }
685
+ });
686
+ session.command("stats").description("Show summary statistics across all Claude Code sessions").action(() => {
687
+ let nProjects = 0;
688
+ let nSessions = 0;
689
+ let totalMsgs = 0;
690
+ let nActive = 0;
691
+ try {
692
+ nProjects = fs2.readdirSync(PROJECTS_DIR).length;
693
+ } catch {
694
+ }
695
+ try {
696
+ const walk = (dir) => {
697
+ const results = [];
698
+ try {
699
+ for (const entry of fs2.readdirSync(dir, { withFileTypes: true })) {
700
+ const fullPath = path2.join(dir, entry.name);
701
+ if (entry.isDirectory()) results.push(...walk(fullPath));
702
+ else if (entry.name.endsWith(".jsonl")) results.push(fullPath);
703
+ }
704
+ } catch {
705
+ }
706
+ return results;
707
+ };
708
+ const sessionFiles = walk(PROJECTS_DIR);
709
+ nSessions = sessionFiles.length;
710
+ for (const f of sessionFiles) {
711
+ try {
712
+ const content = fs2.readFileSync(f, "utf-8");
713
+ totalMsgs += content ? content.split("\n").filter((l) => l.trim()).length : 0;
714
+ } catch {
715
+ }
716
+ }
717
+ } catch {
718
+ }
719
+ try {
720
+ nActive = fs2.readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".json")).length;
721
+ } catch {
722
+ }
723
+ console.log(`Projects: ${nProjects}`);
724
+ console.log(`Sessions: ${nSessions}`);
725
+ console.log(`Total messages: ${totalMsgs}`);
726
+ console.log(`Active procs: ${nActive} (in ${SESSIONS_DIR})`);
727
+ console.log("");
728
+ try {
729
+ const totalSize = execSync(`du -sh "${path2.join(process.env.CLAUDE_DIR || path2.join(process.env.HOME || "", ".claude"))}" 2>/dev/null`, { encoding: "utf-8" }).trim().split(/\s+/)[0];
730
+ const projSize = execSync(`du -sh "${PROJECTS_DIR}" 2>/dev/null`, { encoding: "utf-8" }).trim().split(/\s+/)[0];
731
+ console.log("Storage:");
732
+ console.log(` Total: ${totalSize}`);
733
+ console.log(` Projects: ${projSize}`);
734
+ } catch {
735
+ }
736
+ });
737
+ session.command("clean").description("Delete session JSONL files older than N days").option("-d, --days <n>", "Delete files older than this many days", "30").option("--dry-run", "Show what would be deleted without deleting").action((opts) => {
738
+ const days = parseInt(opts.days, 10);
739
+ const cutoffMs = Date.now() - days * 24 * 60 * 60 * 1e3;
740
+ let deleted = 0;
741
+ let freed = 0;
742
+ const walk = (dir) => {
743
+ let entries;
744
+ try {
745
+ entries = fs2.readdirSync(dir, { withFileTypes: true });
746
+ } catch {
747
+ return;
748
+ }
749
+ for (const entry of entries) {
750
+ const fullPath = path2.join(dir, entry.name);
751
+ if (entry.isDirectory()) {
752
+ walk(fullPath);
753
+ } else if (entry.name.endsWith(".jsonl")) {
754
+ try {
755
+ const stat = fs2.statSync(fullPath);
756
+ if (stat.mtimeMs < cutoffMs) {
757
+ const size = stat.size;
758
+ if (opts.dryRun) {
759
+ console.log(`[dry-run] would delete: ${fullPath} (${Math.floor(size / 1024)}KB)`);
760
+ } else {
761
+ fs2.unlinkSync(fullPath);
762
+ console.log(`Deleted: ${fullPath}`);
763
+ }
764
+ deleted++;
765
+ freed += size;
766
+ }
767
+ } catch {
768
+ }
769
+ }
770
+ }
771
+ };
772
+ walk(PROJECTS_DIR);
773
+ console.log("");
774
+ const verb = opts.dryRun ? "Would delete" : "Deleted";
775
+ console.log(`${verb} ${deleted} file(s) (~${Math.floor(freed / 1024)}KB freed)`);
776
+ });
777
+ return session;
778
+ }
779
+
780
+ // src/index.ts
781
+ var program = new Command4();
782
+ program.name("cc-hub").description("Manage Claude CLI profiles, hooks, and sessions").version("1.0.0");
783
+ program.addCommand(profileCommand());
784
+ program.addCommand(useCommand());
785
+ program.addCommand(runCommand());
786
+ program.addCommand(hooksCommand());
787
+ program.addCommand(sessionCommand());
788
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "cc-hub-cli",
3
+ "version": "1.0.0",
4
+ "description": "Manage Claude CLI profiles, hooks, and sessions",
5
+ "type": "module",
6
+ "bin": {
7
+ "cc-hub": "./dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsup",
11
+ "dev": "tsup --watch",
12
+ "start": "node dist/index.js",
13
+ "prepublishOnly": "npm run build"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/sherocktong/cc-hub.git"
18
+ },
19
+ "keywords": [
20
+ "claude",
21
+ "cli",
22
+ "profiles",
23
+ "hooks",
24
+ "sessions",
25
+ "anthropic"
26
+ ],
27
+ "dependencies": {
28
+ "@anthropic-ai/sdk": "^0.39.0",
29
+ "commander": "^13.1.0"
30
+ },
31
+ "devDependencies": {
32
+ "tsup": "^8.4.0",
33
+ "typescript": "^5.7.0"
34
+ },
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "files": [
39
+ "dist"
40
+ ],
41
+ "license": "MIT"
42
+ }