@automagik/genie 0.260201.2240
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 +26 -0
- package/.worktrees/.metadata.json +3 -0
- package/README.md +532 -0
- package/bun.lock +101 -0
- package/dist/claudio.js +76 -0
- package/dist/genie.js +201 -0
- package/dist/term.js +136 -0
- package/install.sh +351 -0
- package/package.json +37 -0
- package/scripts/version.ts +48 -0
- package/src/claudio.ts +128 -0
- package/src/commands/launch.ts +245 -0
- package/src/commands/models.ts +43 -0
- package/src/commands/profiles.ts +95 -0
- package/src/commands/setup.ts +5 -0
- package/src/genie-commands/hooks.ts +317 -0
- package/src/genie-commands/install.ts +351 -0
- package/src/genie-commands/setup.ts +282 -0
- package/src/genie-commands/shortcuts.ts +62 -0
- package/src/genie-commands/update.ts +228 -0
- package/src/genie.ts +106 -0
- package/src/lib/api-client.ts +109 -0
- package/src/lib/claude-settings.ts +252 -0
- package/src/lib/config.ts +109 -0
- package/src/lib/genie-config.ts +164 -0
- package/src/lib/hook-manager.ts +130 -0
- package/src/lib/hook-script.ts +256 -0
- package/src/lib/hooks/compose.ts +72 -0
- package/src/lib/hooks/index.ts +163 -0
- package/src/lib/hooks/presets/audited.ts +191 -0
- package/src/lib/hooks/presets/collaborative.ts +143 -0
- package/src/lib/hooks/presets/sandboxed.ts +153 -0
- package/src/lib/hooks/presets/supervised.ts +66 -0
- package/src/lib/hooks/utils/escape.ts +46 -0
- package/src/lib/log-reader.ts +213 -0
- package/src/lib/picker.ts +62 -0
- package/src/lib/session-metadata.ts +58 -0
- package/src/lib/system-detect.ts +185 -0
- package/src/lib/tmux.ts +410 -0
- package/src/lib/version.ts +15 -0
- package/src/lib/wizard.ts +104 -0
- package/src/lib/worktree.ts +362 -0
- package/src/term-commands/attach.ts +23 -0
- package/src/term-commands/exec.ts +34 -0
- package/src/term-commands/hook.ts +42 -0
- package/src/term-commands/ls.ts +33 -0
- package/src/term-commands/new.ts +73 -0
- package/src/term-commands/pane.ts +81 -0
- package/src/term-commands/read.ts +70 -0
- package/src/term-commands/rm.ts +47 -0
- package/src/term-commands/send.ts +34 -0
- package/src/term-commands/shortcuts.ts +355 -0
- package/src/term-commands/split.ts +87 -0
- package/src/term-commands/status.ts +116 -0
- package/src/term-commands/window.ts +72 -0
- package/src/term.ts +192 -0
- package/src/types/config.ts +17 -0
- package/src/types/genie-config.ts +104 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { $ } from "bun";
|
|
2
|
+
import { mkdir, rm, access, readFile, writeFile, stat } from "fs/promises";
|
|
3
|
+
import { join, basename } from "path";
|
|
4
|
+
|
|
5
|
+
export interface WorktreeInfo {
|
|
6
|
+
path: string;
|
|
7
|
+
branch: string;
|
|
8
|
+
commitHash?: string;
|
|
9
|
+
createdAt?: Date;
|
|
10
|
+
size?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface WorktreeMetadata {
|
|
14
|
+
worktrees: {
|
|
15
|
+
[sanitizedName: string]: {
|
|
16
|
+
branch: string;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface WorktreeManagerConfig {
|
|
23
|
+
/** Base directory for worktrees (default: .worktrees) */
|
|
24
|
+
baseDir: string;
|
|
25
|
+
/** Path to the main git repository */
|
|
26
|
+
repoPath: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Sanitizes a branch name for use as a directory name.
|
|
31
|
+
* Converts feature/auth to feature-auth, etc.
|
|
32
|
+
*/
|
|
33
|
+
export function sanitizeBranchName(branchName: string): string {
|
|
34
|
+
return branchName
|
|
35
|
+
.replace(/\//g, "-")
|
|
36
|
+
.replace(/\\/g, "-")
|
|
37
|
+
.replace(/[^a-zA-Z0-9-_.]/g, "-")
|
|
38
|
+
.replace(/-+/g, "-")
|
|
39
|
+
.replace(/^-|-$/g, "");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parses a timeframe string like "7d", "2w", "1m", "24h" into milliseconds.
|
|
44
|
+
*/
|
|
45
|
+
export function parseTimeframe(timeframe: string): number {
|
|
46
|
+
const match = timeframe.match(/^(\d+)([hdwm])$/i);
|
|
47
|
+
if (!match) {
|
|
48
|
+
throw new Error(`Invalid timeframe format: ${timeframe}. Use format like 7d, 2w, 1m, 24h`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const value = parseInt(match[1], 10);
|
|
52
|
+
const unit = match[2].toLowerCase();
|
|
53
|
+
|
|
54
|
+
const multipliers: Record<string, number> = {
|
|
55
|
+
h: 60 * 60 * 1000, // hours
|
|
56
|
+
d: 24 * 60 * 60 * 1000, // days
|
|
57
|
+
w: 7 * 24 * 60 * 60 * 1000, // weeks
|
|
58
|
+
m: 30 * 24 * 60 * 60 * 1000, // months (approximate)
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return value * multipliers[unit];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Formats a duration in milliseconds to human-readable format.
|
|
66
|
+
*/
|
|
67
|
+
export function formatAge(ms: number): string {
|
|
68
|
+
const hours = Math.floor(ms / (60 * 60 * 1000));
|
|
69
|
+
const days = Math.floor(hours / 24);
|
|
70
|
+
const remainingHours = hours % 24;
|
|
71
|
+
|
|
72
|
+
if (days > 0) {
|
|
73
|
+
return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`;
|
|
74
|
+
}
|
|
75
|
+
return `${hours}h`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Formats bytes to human-readable size.
|
|
80
|
+
*/
|
|
81
|
+
export function formatSize(bytes: number): string {
|
|
82
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
83
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
84
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
|
|
85
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Manages Git worktrees for branch isolation.
|
|
90
|
+
*/
|
|
91
|
+
export class WorktreeManager {
|
|
92
|
+
private baseDir: string;
|
|
93
|
+
private repoPath: string;
|
|
94
|
+
private metadataPath: string;
|
|
95
|
+
|
|
96
|
+
constructor(config: WorktreeManagerConfig) {
|
|
97
|
+
this.baseDir = config.baseDir;
|
|
98
|
+
this.repoPath = config.repoPath;
|
|
99
|
+
this.metadataPath = join(this.baseDir, ".metadata.json");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Loads worktree metadata from disk.
|
|
104
|
+
*/
|
|
105
|
+
private async loadMetadata(): Promise<WorktreeMetadata> {
|
|
106
|
+
try {
|
|
107
|
+
const content = await readFile(this.metadataPath, "utf-8");
|
|
108
|
+
return JSON.parse(content);
|
|
109
|
+
} catch {
|
|
110
|
+
return { worktrees: {} };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Saves worktree metadata to disk.
|
|
116
|
+
*/
|
|
117
|
+
private async saveMetadata(metadata: WorktreeMetadata): Promise<void> {
|
|
118
|
+
await mkdir(this.baseDir, { recursive: true });
|
|
119
|
+
await writeFile(this.metadataPath, JSON.stringify(metadata, null, 2));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Creates a new worktree for a branch.
|
|
124
|
+
* @param branchName - Git branch name
|
|
125
|
+
* @param createBranch - If true, create a new branch
|
|
126
|
+
* @param baseBranch - Base branch for new branch (only with createBranch)
|
|
127
|
+
*/
|
|
128
|
+
async createWorktree(
|
|
129
|
+
branchName: string,
|
|
130
|
+
createBranch: boolean = false,
|
|
131
|
+
baseBranch?: string
|
|
132
|
+
): Promise<WorktreeInfo> {
|
|
133
|
+
const sanitizedName = sanitizeBranchName(branchName);
|
|
134
|
+
const worktreePath = join(this.baseDir, sanitizedName);
|
|
135
|
+
|
|
136
|
+
// Ensure base directory exists
|
|
137
|
+
await mkdir(this.baseDir, { recursive: true });
|
|
138
|
+
|
|
139
|
+
// Check if worktree already exists
|
|
140
|
+
try {
|
|
141
|
+
await access(worktreePath);
|
|
142
|
+
throw new Error(`Worktree already exists at ${worktreePath}`);
|
|
143
|
+
} catch (e: any) {
|
|
144
|
+
if (e.code !== "ENOENT") throw e;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (createBranch) {
|
|
148
|
+
if (baseBranch) {
|
|
149
|
+
// Create new branch from base and add worktree
|
|
150
|
+
await $`git -C ${this.repoPath} worktree add -b ${branchName} ${worktreePath} ${baseBranch}`.quiet();
|
|
151
|
+
} else {
|
|
152
|
+
// Create new branch from HEAD and add worktree
|
|
153
|
+
await $`git -C ${this.repoPath} worktree add -b ${branchName} ${worktreePath}`.quiet();
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
// Check if branch exists
|
|
157
|
+
const branchExists = await this.branchExists(branchName);
|
|
158
|
+
if (!branchExists) {
|
|
159
|
+
throw new Error(`Branch '${branchName}' does not exist. Use -b to create a new branch.`);
|
|
160
|
+
}
|
|
161
|
+
// Add worktree for existing branch
|
|
162
|
+
await $`git -C ${this.repoPath} worktree add ${worktreePath} ${branchName}`.quiet();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Save metadata
|
|
166
|
+
const metadata = await this.loadMetadata();
|
|
167
|
+
metadata.worktrees[sanitizedName] = {
|
|
168
|
+
branch: branchName,
|
|
169
|
+
createdAt: new Date().toISOString(),
|
|
170
|
+
};
|
|
171
|
+
await this.saveMetadata(metadata);
|
|
172
|
+
|
|
173
|
+
// Get commit hash
|
|
174
|
+
const commitHash = await this.getCommitHash(worktreePath);
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
path: worktreePath,
|
|
178
|
+
branch: branchName,
|
|
179
|
+
commitHash,
|
|
180
|
+
createdAt: new Date(),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Removes a worktree by branch name.
|
|
186
|
+
*/
|
|
187
|
+
async removeWorktree(branchName: string): Promise<void> {
|
|
188
|
+
const sanitizedName = sanitizeBranchName(branchName);
|
|
189
|
+
const worktreePath = join(this.baseDir, sanitizedName);
|
|
190
|
+
|
|
191
|
+
// Remove from git worktree list
|
|
192
|
+
try {
|
|
193
|
+
await $`git -C ${this.repoPath} worktree remove ${worktreePath} --force`.quiet();
|
|
194
|
+
} catch {
|
|
195
|
+
// If git worktree remove fails, try removing the directory manually
|
|
196
|
+
try {
|
|
197
|
+
await rm(worktreePath, { recursive: true, force: true });
|
|
198
|
+
} catch {
|
|
199
|
+
// Directory doesn't exist
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Ensure directory is gone
|
|
204
|
+
try {
|
|
205
|
+
await access(worktreePath);
|
|
206
|
+
await rm(worktreePath, { recursive: true, force: true });
|
|
207
|
+
} catch {
|
|
208
|
+
// Directory doesn't exist, which is expected
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Remove from metadata
|
|
212
|
+
const metadata = await this.loadMetadata();
|
|
213
|
+
delete metadata.worktrees[sanitizedName];
|
|
214
|
+
await this.saveMetadata(metadata);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Lists all worktrees in the base directory.
|
|
219
|
+
*/
|
|
220
|
+
async listWorktrees(): Promise<WorktreeInfo[]> {
|
|
221
|
+
const result = await $`git -C ${this.repoPath} worktree list --porcelain`.quiet();
|
|
222
|
+
const output = result.stdout.toString();
|
|
223
|
+
|
|
224
|
+
const worktrees: WorktreeInfo[] = [];
|
|
225
|
+
let current: Partial<WorktreeInfo> = {};
|
|
226
|
+
|
|
227
|
+
for (const line of output.split("\n")) {
|
|
228
|
+
if (line.startsWith("worktree ")) {
|
|
229
|
+
if (current.path) {
|
|
230
|
+
worktrees.push(current as WorktreeInfo);
|
|
231
|
+
}
|
|
232
|
+
current = { path: line.slice(9) };
|
|
233
|
+
} else if (line.startsWith("HEAD ")) {
|
|
234
|
+
current.commitHash = line.slice(5);
|
|
235
|
+
} else if (line.startsWith("branch ")) {
|
|
236
|
+
current.branch = line.slice(7).replace("refs/heads/", "");
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (current.path) {
|
|
241
|
+
worktrees.push(current as WorktreeInfo);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Filter to only include worktrees in our base directory
|
|
245
|
+
const filtered = worktrees.filter((wt) => wt.path.startsWith(this.baseDir));
|
|
246
|
+
|
|
247
|
+
// Load metadata for creation times
|
|
248
|
+
const metadata = await this.loadMetadata();
|
|
249
|
+
|
|
250
|
+
// Enrich with metadata and size
|
|
251
|
+
for (const wt of filtered) {
|
|
252
|
+
const sanitizedName = basename(wt.path);
|
|
253
|
+
const meta = metadata.worktrees[sanitizedName];
|
|
254
|
+
if (meta) {
|
|
255
|
+
wt.createdAt = new Date(meta.createdAt);
|
|
256
|
+
}
|
|
257
|
+
wt.size = await this.getDiskUsage(wt.path);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return filtered;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Cleans up worktrees older than the specified timeframe.
|
|
265
|
+
* @param timeframe - Timeframe string like "7d", "2w", "1m"
|
|
266
|
+
* @param dryRun - If true, only report what would be removed
|
|
267
|
+
* @returns List of removed (or would-be-removed) worktrees
|
|
268
|
+
*/
|
|
269
|
+
async cleanup(timeframe: string, dryRun: boolean = false): Promise<WorktreeInfo[]> {
|
|
270
|
+
const maxAge = parseTimeframe(timeframe);
|
|
271
|
+
const now = Date.now();
|
|
272
|
+
const worktrees = await this.listWorktrees();
|
|
273
|
+
|
|
274
|
+
const toRemove: WorktreeInfo[] = [];
|
|
275
|
+
|
|
276
|
+
for (const wt of worktrees) {
|
|
277
|
+
if (wt.createdAt) {
|
|
278
|
+
const age = now - wt.createdAt.getTime();
|
|
279
|
+
if (age > maxAge) {
|
|
280
|
+
toRemove.push(wt);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (!dryRun) {
|
|
286
|
+
for (const wt of toRemove) {
|
|
287
|
+
await this.removeWorktree(wt.branch);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return toRemove;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Prunes stale worktree references.
|
|
296
|
+
*/
|
|
297
|
+
async prune(): Promise<void> {
|
|
298
|
+
await $`git -C ${this.repoPath} worktree prune`.quiet();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Gets disk usage for a directory.
|
|
303
|
+
*/
|
|
304
|
+
async getDiskUsage(dirPath: string): Promise<number> {
|
|
305
|
+
try {
|
|
306
|
+
const result = await $`du -sb ${dirPath}`.quiet();
|
|
307
|
+
const output = result.stdout.toString().trim();
|
|
308
|
+
const size = parseInt(output.split("\t")[0], 10);
|
|
309
|
+
return isNaN(size) ? 0 : size;
|
|
310
|
+
} catch {
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Gets the path for a worktree by branch name.
|
|
317
|
+
*/
|
|
318
|
+
getWorktreePath(branchName: string): string {
|
|
319
|
+
const sanitizedName = sanitizeBranchName(branchName);
|
|
320
|
+
return join(this.baseDir, sanitizedName);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Checks if a worktree exists for a branch.
|
|
325
|
+
*/
|
|
326
|
+
async worktreeExists(branchName: string): Promise<boolean> {
|
|
327
|
+
const sanitizedName = sanitizeBranchName(branchName);
|
|
328
|
+
const worktreePath = join(this.baseDir, sanitizedName);
|
|
329
|
+
try {
|
|
330
|
+
await access(worktreePath);
|
|
331
|
+
return true;
|
|
332
|
+
} catch {
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private async branchExists(branchName: string): Promise<boolean> {
|
|
338
|
+
try {
|
|
339
|
+
await $`git -C ${this.repoPath} rev-parse --verify ${branchName}`.quiet();
|
|
340
|
+
return true;
|
|
341
|
+
} catch {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private async getCommitHash(worktreePath: string): Promise<string> {
|
|
347
|
+
const result = await $`git -C ${worktreePath} rev-parse HEAD`.quiet();
|
|
348
|
+
return result.stdout.toString().trim();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Creates a WorktreeManager with default configuration.
|
|
354
|
+
*/
|
|
355
|
+
export function createWorktreeManager(
|
|
356
|
+
options?: Partial<WorktreeManagerConfig>
|
|
357
|
+
): WorktreeManager {
|
|
358
|
+
const baseDir = options?.baseDir || join(process.cwd(), ".worktrees");
|
|
359
|
+
const repoPath = options?.repoPath || process.cwd();
|
|
360
|
+
|
|
361
|
+
return new WorktreeManager({ baseDir, repoPath });
|
|
362
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { exec as execCallback } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import * as tmux from '../lib/tmux.js';
|
|
4
|
+
|
|
5
|
+
const exec = promisify(execCallback);
|
|
6
|
+
|
|
7
|
+
export async function attachToSession(name: string): Promise<void> {
|
|
8
|
+
try {
|
|
9
|
+
// Check if session exists
|
|
10
|
+
const session = await tmux.findSessionByName(name);
|
|
11
|
+
if (!session) {
|
|
12
|
+
console.error(`❌ Session "${name}" not found`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Attach to session
|
|
17
|
+
console.log(`📎 Attaching to session "${name}"...`);
|
|
18
|
+
await exec(`tmux attach -t "${name}"`);
|
|
19
|
+
} catch (error: any) {
|
|
20
|
+
console.error(`❌ Error attaching to session: ${error.message}`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as tmux from '../lib/tmux.js';
|
|
2
|
+
|
|
3
|
+
export async function executeInSession(sessionName: string, command: string): Promise<void> {
|
|
4
|
+
try {
|
|
5
|
+
// Find session
|
|
6
|
+
const session = await tmux.findSessionByName(sessionName);
|
|
7
|
+
if (!session) {
|
|
8
|
+
console.error(`❌ Session "${sessionName}" not found`);
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Get first window and pane
|
|
13
|
+
const windows = await tmux.listWindows(session.id);
|
|
14
|
+
if (!windows || windows.length === 0) {
|
|
15
|
+
console.error(`❌ No windows found in session "${sessionName}"`);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const panes = await tmux.listPanes(windows[0].id);
|
|
20
|
+
if (!panes || panes.length === 0) {
|
|
21
|
+
console.error(`❌ No panes found in session "${sessionName}"`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const paneId = panes[0].id;
|
|
26
|
+
|
|
27
|
+
// Execute command
|
|
28
|
+
await tmux.executeCommand(paneId, command);
|
|
29
|
+
console.log(`✅ Command sent to session "${sessionName}"`);
|
|
30
|
+
} catch (error: any) {
|
|
31
|
+
console.error(`❌ Error executing command: ${error.message}`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as hookManager from '../lib/hook-manager.js';
|
|
2
|
+
|
|
3
|
+
export async function setHook(event: string, command: string): Promise<void> {
|
|
4
|
+
try {
|
|
5
|
+
await hookManager.setHook(event, command);
|
|
6
|
+
console.log(`✅ Hook set: ${event} → ${command}`);
|
|
7
|
+
} catch (error: any) {
|
|
8
|
+
console.error(`❌ Error setting hook: ${error.message}`);
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function listHooks(): Promise<void> {
|
|
14
|
+
try {
|
|
15
|
+
const hooks = await hookManager.listHooks();
|
|
16
|
+
|
|
17
|
+
if (hooks.length === 0) {
|
|
18
|
+
console.log('No hooks configured');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
console.log('EVENT\t\t\t\tCOMMAND');
|
|
23
|
+
console.log('─'.repeat(80));
|
|
24
|
+
|
|
25
|
+
for (const hook of hooks) {
|
|
26
|
+
console.log(`${hook.event}\t\t${hook.command}`);
|
|
27
|
+
}
|
|
28
|
+
} catch (error: any) {
|
|
29
|
+
console.error(`❌ Error listing hooks: ${error.message}`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function removeHook(event: string): Promise<void> {
|
|
35
|
+
try {
|
|
36
|
+
await hookManager.removeHook(event);
|
|
37
|
+
console.log(`✅ Hook removed: ${event}`);
|
|
38
|
+
} catch (error: any) {
|
|
39
|
+
console.error(`❌ Error removing hook: ${error.message}`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as tmux from '../lib/tmux.js';
|
|
2
|
+
|
|
3
|
+
export interface ListOptions {
|
|
4
|
+
json?: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function listAllSessions(options: ListOptions = {}): Promise<void> {
|
|
8
|
+
try {
|
|
9
|
+
const sessions = await tmux.listSessions();
|
|
10
|
+
|
|
11
|
+
if (options.json) {
|
|
12
|
+
console.log(JSON.stringify(sessions, null, 2));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (sessions.length === 0) {
|
|
17
|
+
console.log('No tmux sessions found');
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Docker ps style output
|
|
22
|
+
console.log('SESSION ID\t\tNAME\t\t\tWINDOWS\t\tATTACHED');
|
|
23
|
+
console.log('─'.repeat(80));
|
|
24
|
+
|
|
25
|
+
for (const session of sessions) {
|
|
26
|
+
const attached = session.attached ? 'yes' : 'no';
|
|
27
|
+
console.log(`${session.id}\t${session.name}\t\t${session.windows}\t\t${attached}`);
|
|
28
|
+
}
|
|
29
|
+
} catch (error: any) {
|
|
30
|
+
console.error(`Error listing sessions: ${error.message}`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import * as tmux from '../lib/tmux.js';
|
|
3
|
+
import { createWorktreeManager } from '../lib/worktree.js';
|
|
4
|
+
import { saveSessionMetadata } from '../lib/session-metadata.js';
|
|
5
|
+
|
|
6
|
+
export interface CreateSessionOptions {
|
|
7
|
+
workspace?: string;
|
|
8
|
+
worktree?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function createNewSession(name: string, options: CreateSessionOptions = {}): Promise<void> {
|
|
12
|
+
try {
|
|
13
|
+
// Check if session already exists
|
|
14
|
+
const existing = await tmux.findSessionByName(name);
|
|
15
|
+
if (existing) {
|
|
16
|
+
console.error(`❌ Session "${name}" already exists`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let workingDir = options.workspace || process.cwd();
|
|
21
|
+
let worktreePath: string | undefined;
|
|
22
|
+
|
|
23
|
+
if (options.worktree) {
|
|
24
|
+
if (!options.workspace) {
|
|
25
|
+
console.error('❌ --worktree requires --workspace');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const manager = createWorktreeManager({
|
|
30
|
+
baseDir: join(options.workspace, '.worktrees'),
|
|
31
|
+
repoPath: options.workspace
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Check if worktree already exists
|
|
35
|
+
const exists = await manager.worktreeExists(name);
|
|
36
|
+
if (exists) {
|
|
37
|
+
console.error(`❌ Worktree for "${name}" already exists`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Create worktree with auto-create branch
|
|
42
|
+
const wt = await manager.createWorktree(name, true);
|
|
43
|
+
worktreePath = wt.path;
|
|
44
|
+
workingDir = wt.path;
|
|
45
|
+
|
|
46
|
+
console.log(`✅ Worktree created at ${worktreePath}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Create session
|
|
50
|
+
const session = await tmux.createSession(name);
|
|
51
|
+
if (!session) {
|
|
52
|
+
console.error(`❌ Failed to create session "${name}"`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Change to working directory
|
|
57
|
+
await tmux.executeTmux(`send-keys -t '${name}' 'cd ${workingDir}' Enter`);
|
|
58
|
+
|
|
59
|
+
// Save metadata for cleanup (if worktree was created)
|
|
60
|
+
if (worktreePath) {
|
|
61
|
+
await saveSessionMetadata(name, { worktreePath, workspace: options.workspace });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log(`✅ Session "${name}" created`);
|
|
65
|
+
if (workingDir !== process.cwd()) {
|
|
66
|
+
console.log(` Working directory: ${workingDir}`);
|
|
67
|
+
}
|
|
68
|
+
console.log(`\nTo attach: term attach ${name}`);
|
|
69
|
+
} catch (error: any) {
|
|
70
|
+
console.error(`❌ Error creating session: ${error.message}`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import * as tmux from '../lib/tmux.js';
|
|
2
|
+
|
|
3
|
+
export interface PaneListOptions {
|
|
4
|
+
json?: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function listPanes(session: string, options: PaneListOptions = {}): Promise<void> {
|
|
8
|
+
try {
|
|
9
|
+
// Find session by name first
|
|
10
|
+
const sessionObj = await tmux.findSessionByName(session);
|
|
11
|
+
if (!sessionObj) {
|
|
12
|
+
console.error(`Session "${session}" not found`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Get all windows in session
|
|
17
|
+
const windows = await tmux.listWindows(sessionObj.id);
|
|
18
|
+
if (windows.length === 0) {
|
|
19
|
+
if (options.json) {
|
|
20
|
+
console.log('[]');
|
|
21
|
+
} else {
|
|
22
|
+
console.log('No panes found');
|
|
23
|
+
}
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Collect all panes from all windows
|
|
28
|
+
const allPanes: Array<{
|
|
29
|
+
id: string;
|
|
30
|
+
windowId: string;
|
|
31
|
+
windowName: string;
|
|
32
|
+
title: string;
|
|
33
|
+
active: boolean;
|
|
34
|
+
}> = [];
|
|
35
|
+
|
|
36
|
+
for (const window of windows) {
|
|
37
|
+
const panes = await tmux.listPanes(window.id);
|
|
38
|
+
for (const pane of panes) {
|
|
39
|
+
allPanes.push({
|
|
40
|
+
id: pane.id,
|
|
41
|
+
windowId: window.id,
|
|
42
|
+
windowName: window.name,
|
|
43
|
+
title: pane.title,
|
|
44
|
+
active: pane.active,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (options.json) {
|
|
50
|
+
console.log(JSON.stringify(allPanes, null, 2));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (allPanes.length === 0) {
|
|
55
|
+
console.log('No panes found');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log('PANE ID\t\tWINDOW\t\t\tTITLE\t\t\tACTIVE');
|
|
60
|
+
console.log('─'.repeat(80));
|
|
61
|
+
|
|
62
|
+
for (const pane of allPanes) {
|
|
63
|
+
const active = pane.active ? 'yes' : 'no';
|
|
64
|
+
const title = pane.title || '-';
|
|
65
|
+
console.log(`${pane.id}\t\t${pane.windowName}\t\t\t${title}\t\t\t${active}`);
|
|
66
|
+
}
|
|
67
|
+
} catch (error: any) {
|
|
68
|
+
console.error(`Error listing panes: ${error.message}`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function removePane(paneId: string): Promise<void> {
|
|
74
|
+
try {
|
|
75
|
+
await tmux.killPane(paneId);
|
|
76
|
+
console.log(`Pane removed: ${paneId}`);
|
|
77
|
+
} catch (error: any) {
|
|
78
|
+
console.error(`Error removing pane: ${error.message}`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
}
|