@atercates/claude-deck 0.2.13 → 0.2.15

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/README.md CHANGED
@@ -7,13 +7,13 @@ Self-hosted web UI for managing Claude Code sessions.
7
7
  ### Quick Install
8
8
 
9
9
  ```bash
10
- curl -fsSL https://raw.githubusercontent.com/ATERCATES/claude-deck/main/scripts/install.sh | bash
10
+ curl -fsSL https://raw.githubusercontent.com/ATERCATES/claude-deck/main/scripts/install.sh -o /tmp/install-claudedeck.sh
11
+ bash /tmp/install-claudedeck.sh
11
12
  ```
12
13
 
13
14
  The installer will:
14
15
 
15
- - Install Node.js 24 if needed (via [n](https://github.com/tj/n))
16
- - Install pnpm
16
+ - Install Node.js 24, pnpm, and tmux if needed
17
17
  - Ask for port, SSH host/port (for VS Code remote button)
18
18
  - Clone, build, and start as a systemd service
19
19
  - First visit prompts you to create an account
@@ -21,7 +21,7 @@ The installer will:
21
21
  ### Non-Interactive
22
22
 
23
23
  ```bash
24
- bash install.sh --port 3011 --ssh-host myserver.com --ssh-port 22 -y
24
+ curl -fsSL https://raw.githubusercontent.com/ATERCATES/claude-deck/main/scripts/install.sh | bash -s -- --port 3011 --ssh-host myserver.com --ssh-port 22 -y
25
25
  ```
26
26
 
27
27
  ### Manual Install
@@ -0,0 +1,29 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { createWorktree } from "@/lib/worktrees";
3
+
4
+ export async function POST(request: NextRequest) {
5
+ try {
6
+ const { projectPath, featureName, baseBranch } = await request.json();
7
+
8
+ if (!projectPath || !featureName) {
9
+ return NextResponse.json(
10
+ { error: "projectPath and featureName are required" },
11
+ { status: 400 }
12
+ );
13
+ }
14
+
15
+ const worktree = await createWorktree({
16
+ projectPath,
17
+ featureName,
18
+ baseBranch,
19
+ });
20
+
21
+ return NextResponse.json(worktree, { status: 201 });
22
+ } catch (error) {
23
+ const message = error instanceof Error ? error.message : "Unknown error";
24
+ return NextResponse.json(
25
+ { error: `Failed to create worktree: ${message}` },
26
+ { status: 400 }
27
+ );
28
+ }
29
+ }
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useState, useEffect, useRef } from "react";
3
+ import { useState, useEffect, useRef, useCallback } from "react";
4
4
  import {
5
5
  Dialog,
6
6
  DialogContent,
@@ -16,7 +16,7 @@ import {
16
16
  SelectTrigger,
17
17
  SelectValue,
18
18
  } from "@/components/ui/select";
19
- import { Sparkles } from "lucide-react";
19
+ import { Sparkles, GitBranch, Loader2 } from "lucide-react";
20
20
  import type { ClaudeProject } from "@/data/claude";
21
21
 
22
22
  const ADJECTIVES = [
@@ -50,6 +50,12 @@ function generateName() {
50
50
  return `${adj}-${noun}`;
51
51
  }
52
52
 
53
+ interface GitInfo {
54
+ isGitRepo: boolean;
55
+ branches: string[];
56
+ defaultBranch: string | null;
57
+ }
58
+
53
59
  interface NewClaudeSessionDialogProps {
54
60
  open: boolean;
55
61
  projectName: string;
@@ -69,10 +75,57 @@ export function NewClaudeSessionDialog({
69
75
  const [selectedProject, setSelectedProject] = useState(projectName);
70
76
  const inputRef = useRef<HTMLInputElement>(null);
71
77
 
78
+ // Worktree state
79
+ const [gitInfo, setGitInfo] = useState<GitInfo | null>(null);
80
+ const [useWorktree, setUseWorktree] = useState(false);
81
+ const [featureName, setFeatureName] = useState("");
82
+ const [baseBranch, setBaseBranch] = useState("main");
83
+ const [creating, setCreating] = useState(false);
84
+
85
+ const cwd = (() => {
86
+ if (selectedProject) {
87
+ const project = projects?.find((p) => p.name === selectedProject);
88
+ return project?.directory || undefined;
89
+ }
90
+ return undefined;
91
+ })();
92
+
93
+ // Check git repo when cwd changes
94
+ useEffect(() => {
95
+ if (!cwd) {
96
+ setGitInfo(null);
97
+ setUseWorktree(false);
98
+ return;
99
+ }
100
+
101
+ let cancelled = false;
102
+ fetch("/api/git/check", {
103
+ method: "POST",
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify({ path: cwd }),
106
+ })
107
+ .then((r) => r.json())
108
+ .then((data) => {
109
+ if (cancelled) return;
110
+ setGitInfo(data);
111
+ if (data.defaultBranch) setBaseBranch(data.defaultBranch);
112
+ })
113
+ .catch(() => {
114
+ if (!cancelled) setGitInfo(null);
115
+ });
116
+
117
+ return () => {
118
+ cancelled = true;
119
+ };
120
+ }, [cwd]);
121
+
72
122
  useEffect(() => {
73
123
  if (open) {
74
124
  setName(generateName());
75
125
  setSelectedProject(projectName);
126
+ setUseWorktree(false);
127
+ setFeatureName("");
128
+ setCreating(false);
76
129
  setTimeout(() => {
77
130
  inputRef.current?.focus();
78
131
  inputRef.current?.select();
@@ -80,20 +133,63 @@ export function NewClaudeSessionDialog({
80
133
  }
81
134
  }, [open, projectName]);
82
135
 
83
- const handleSubmit = (e: React.FormEvent) => {
84
- e.preventDefault();
85
- const project = projects?.find((p) => p.name === selectedProject);
86
- onConfirm(
87
- name.trim() || generateName(),
88
- project?.directory || undefined,
89
- selectedProject || undefined
90
- );
91
- };
136
+ const handleSubmit = useCallback(
137
+ async (e: React.FormEvent) => {
138
+ e.preventDefault();
139
+ const sessionName = name.trim() || generateName();
140
+ let targetCwd = cwd;
141
+
142
+ if (useWorktree && featureName.trim() && cwd) {
143
+ setCreating(true);
144
+ try {
145
+ const res = await fetch("/api/worktrees", {
146
+ method: "POST",
147
+ headers: { "Content-Type": "application/json" },
148
+ body: JSON.stringify({
149
+ projectPath: cwd,
150
+ featureName: featureName.trim(),
151
+ baseBranch,
152
+ }),
153
+ });
154
+ if (!res.ok) {
155
+ const data = await res.json();
156
+ alert(data.error || "Failed to create worktree");
157
+ setCreating(false);
158
+ return;
159
+ }
160
+ const worktree = await res.json();
161
+ targetCwd = worktree.worktreePath;
162
+ } catch {
163
+ alert("Failed to create worktree");
164
+ setCreating(false);
165
+ return;
166
+ }
167
+ setCreating(false);
168
+ }
169
+
170
+ onConfirm(sessionName, targetCwd, selectedProject || undefined);
171
+ },
172
+ [
173
+ name,
174
+ cwd,
175
+ useWorktree,
176
+ featureName,
177
+ baseBranch,
178
+ selectedProject,
179
+ onConfirm,
180
+ ]
181
+ );
92
182
 
93
183
  const showProjectSelector = projects && projects.length > 0 && !projectName;
184
+ const branchPreview = featureName
185
+ .trim()
186
+ .toLowerCase()
187
+ .replace(/[^a-z0-9]+/g, "-")
188
+ .replace(/^-+|-+$/g, "")
189
+ .slice(0, 50);
94
190
 
95
191
  return (
96
- <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
192
+ <Dialog open={open} onOpenChange={(o) => !o && !creating && onClose()}>
97
193
  <DialogContent className="sm:max-w-sm">
98
194
  <DialogHeader>
99
195
  <DialogTitle className="text-base">New session</DialogTitle>
@@ -127,6 +223,7 @@ export function NewClaudeSessionDialog({
127
223
  <p className="text-muted-foreground text-xs">{projectName}</p>
128
224
  )
129
225
  )}
226
+
130
227
  <div>
131
228
  <div className="flex gap-2">
132
229
  <Input
@@ -135,6 +232,7 @@ export function NewClaudeSessionDialog({
135
232
  onChange={(e) => setName(e.target.value)}
136
233
  placeholder="Session name"
137
234
  className="h-9"
235
+ disabled={creating}
138
236
  />
139
237
  <Button
140
238
  type="button"
@@ -142,21 +240,105 @@ export function NewClaudeSessionDialog({
142
240
  size="icon-sm"
143
241
  className="h-9 w-9 shrink-0"
144
242
  onClick={() => setName(generateName())}
243
+ disabled={creating}
145
244
  >
146
245
  <Sparkles className="h-3.5 w-3.5" />
147
246
  </Button>
148
247
  </div>
149
248
  </div>
249
+
250
+ {gitInfo?.isGitRepo && (
251
+ <div className="bg-accent/40 space-y-3 rounded-lg p-3">
252
+ <div className="flex items-center gap-2">
253
+ <input
254
+ type="checkbox"
255
+ id="useWorktree"
256
+ checked={useWorktree}
257
+ onChange={(e) => setUseWorktree(e.target.checked)}
258
+ className="border-border bg-background accent-primary h-4 w-4 rounded"
259
+ disabled={creating}
260
+ />
261
+ <label
262
+ htmlFor="useWorktree"
263
+ className="flex cursor-pointer items-center gap-1.5 text-sm font-medium"
264
+ >
265
+ <GitBranch className="h-3.5 w-3.5" />
266
+ Create isolated worktree
267
+ </label>
268
+ </div>
269
+
270
+ {useWorktree && (
271
+ <div className="space-y-3 pl-6">
272
+ <div className="space-y-1">
273
+ <label className="text-muted-foreground text-xs">
274
+ Feature name
275
+ </label>
276
+ <Input
277
+ value={featureName}
278
+ onChange={(e) => setFeatureName(e.target.value)}
279
+ placeholder="add-dark-mode"
280
+ className="h-8 text-sm"
281
+ disabled={creating}
282
+ />
283
+ {branchPreview && (
284
+ <p className="text-muted-foreground text-xs">
285
+ Branch: feature/{branchPreview}
286
+ </p>
287
+ )}
288
+ </div>
289
+ <div className="space-y-1">
290
+ <label className="text-muted-foreground text-xs">
291
+ Base branch
292
+ </label>
293
+ <Select
294
+ value={baseBranch}
295
+ onValueChange={setBaseBranch}
296
+ disabled={creating}
297
+ >
298
+ <SelectTrigger className="h-8 text-sm">
299
+ <SelectValue />
300
+ </SelectTrigger>
301
+ <SelectContent>
302
+ {gitInfo.branches.map((branch) => (
303
+ <SelectItem key={branch} value={branch}>
304
+ {branch}
305
+ </SelectItem>
306
+ ))}
307
+ </SelectContent>
308
+ </Select>
309
+ </div>
310
+ </div>
311
+ )}
312
+ </div>
313
+ )}
314
+
150
315
  <div className="flex justify-end gap-2">
151
- <Button type="button" variant="ghost" size="sm" onClick={onClose}>
316
+ <Button
317
+ type="button"
318
+ variant="ghost"
319
+ size="sm"
320
+ onClick={onClose}
321
+ disabled={creating}
322
+ >
152
323
  Cancel
153
324
  </Button>
154
325
  <Button
155
326
  type="submit"
156
327
  size="sm"
157
- disabled={showProjectSelector && !selectedProject}
328
+ disabled={
329
+ creating ||
330
+ (showProjectSelector && !selectedProject) ||
331
+ (useWorktree && !featureName.trim())
332
+ }
158
333
  >
159
- Create
334
+ {creating ? (
335
+ <>
336
+ <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
337
+ Creating worktree...
338
+ </>
339
+ ) : (
340
+ "Create"
341
+ )}
160
342
  </Button>
161
343
  </div>
162
344
  </form>
@@ -291,6 +291,17 @@ export function DesktopTabBar({
291
291
 
292
292
  {/* Pane Controls */}
293
293
  <div className="ml-auto flex items-center gap-0.5 px-2">
294
+ {session?.branch_name && (
295
+ <Tooltip>
296
+ <TooltipTrigger asChild>
297
+ <span className="text-muted-foreground flex items-center gap-1 text-[10px]">
298
+ <GitBranch className="h-3 w-3" />
299
+ {session.branch_name}
300
+ </span>
301
+ </TooltipTrigger>
302
+ <TooltipContent>Worktree branch</TooltipContent>
303
+ </Tooltip>
304
+ )}
294
305
  {session?.working_directory && session.working_directory !== "~" && (
295
306
  <OpenInVSCode workingDirectory={session.working_directory} />
296
307
  )}
@@ -122,6 +122,12 @@ export function MobileTabBar({
122
122
  <span className="truncate text-sm font-medium">
123
123
  {session?.name || "No session"}
124
124
  </span>
125
+ {session?.branch_name && (
126
+ <span className="text-muted-foreground flex items-center gap-0.5 text-[10px]">
127
+ <GitBranch className="h-2.5 w-2.5" />
128
+ {session.branch_name}
129
+ </span>
130
+ )}
125
131
  {sessionList.length > 0 && (
126
132
  <ChevronDown className="text-muted-foreground h-3 w-3 shrink-0" />
127
133
  )}
package/lib/worktrees.ts CHANGED
@@ -111,6 +111,15 @@ export async function createWorktree(
111
111
  { timeout: 30000 }
112
112
  );
113
113
  lastError = null;
114
+
115
+ // Init submodules if present
116
+ if (fs.existsSync(path.join(worktreePath, ".gitmodules"))) {
117
+ await execAsync(
118
+ `git -C "${worktreePath}" submodule update --init --recursive`,
119
+ { timeout: 120000 }
120
+ );
121
+ }
122
+
114
123
  break; // Success!
115
124
  } catch (error: unknown) {
116
125
  lastError = error instanceof Error ? error : new Error(String(error));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atercates/claude-deck",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "claude-deck": "./scripts/claude-deck"
@@ -2,14 +2,15 @@
2
2
  #
3
3
  # ClaudeDeck Installer
4
4
  #
5
- # Install:
6
- # curl -fsSL https://raw.githubusercontent.com/ATERCATES/claude-deck/main/scripts/install.sh | bash
5
+ # Install (interactive):
6
+ # curl -fsSL https://raw.githubusercontent.com/ATERCATES/claude-deck/main/scripts/install.sh -o /tmp/install-claudedeck.sh
7
+ # bash /tmp/install-claudedeck.sh
7
8
  #
8
- # Update:
9
- # ~/.claude-deck/install.sh --update
9
+ # Install (non-interactive):
10
+ # curl -fsSL https://raw.githubusercontent.com/ATERCATES/claude-deck/main/scripts/install.sh | bash -s -- --port 3011 --ssh-host myserver.com --ssh-port 22 -y
10
11
  #
11
- # Options:
12
- # --port 3011 --ssh-host myserver.com --ssh-port 22 -y
12
+ # Update:
13
+ # ~/.claude-deck/scripts/install.sh --update
13
14
  #
14
15
 
15
16
  set -e
@@ -0,0 +1,19 @@
1
+ server {
2
+ listen 80;
3
+ server_name claudedeck.example.com;
4
+
5
+ location / {
6
+ proxy_pass http://127.0.0.1:3011;
7
+ proxy_set_header Host $host;
8
+ proxy_set_header X-Real-IP $remote_addr;
9
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
10
+ proxy_set_header X-Forwarded-Proto $scheme;
11
+
12
+ proxy_http_version 1.1;
13
+ proxy_set_header Upgrade $http_upgrade;
14
+ proxy_set_header Connection "upgrade";
15
+
16
+ proxy_read_timeout 86400s;
17
+ proxy_send_timeout 86400s;
18
+ }
19
+ }
@@ -1,69 +0,0 @@
1
- import { ChevronRight } from "lucide-react";
2
- import { CLAUDE_AUTO_APPROVE_FLAG } from "@/lib/providers";
3
-
4
- interface AdvancedSettingsProps {
5
- open: boolean;
6
- onOpenChange: (open: boolean) => void;
7
- useTmux: boolean;
8
- onUseTmuxChange: (checked: boolean) => void;
9
- skipPermissions: boolean;
10
- onSkipPermissionsChange: (checked: boolean) => void;
11
- }
12
-
13
- export function AdvancedSettings({
14
- open,
15
- onOpenChange,
16
- useTmux,
17
- onUseTmuxChange,
18
- skipPermissions,
19
- onSkipPermissionsChange,
20
- }: AdvancedSettingsProps) {
21
- return (
22
- <div className="border-border rounded-lg border">
23
- <button
24
- type="button"
25
- onClick={() => onOpenChange(!open)}
26
- className="text-muted-foreground hover:text-foreground flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors"
27
- >
28
- <ChevronRight
29
- className={`h-4 w-4 transition-transform ${open ? "rotate-90" : ""}`}
30
- />
31
- Advanced Settings
32
- </button>
33
- {open && (
34
- <div className="space-y-3 border-t px-3 py-3">
35
- <div className="flex items-center gap-2">
36
- <input
37
- type="checkbox"
38
- id="useTmux"
39
- checked={useTmux}
40
- onChange={(e) => onUseTmuxChange(e.target.checked)}
41
- className="border-border bg-background accent-primary h-4 w-4 rounded"
42
- />
43
- <label htmlFor="useTmux" className="cursor-pointer text-sm">
44
- Use tmux session
45
- <span className="text-muted-foreground ml-1">
46
- (enables detach/attach)
47
- </span>
48
- </label>
49
- </div>
50
- <div className="flex items-center gap-2">
51
- <input
52
- type="checkbox"
53
- id="skipPermissions"
54
- checked={skipPermissions}
55
- onChange={(e) => onSkipPermissionsChange(e.target.checked)}
56
- className="border-border bg-background accent-primary h-4 w-4 rounded"
57
- />
58
- <label htmlFor="skipPermissions" className="cursor-pointer text-sm">
59
- Auto-approve tool calls
60
- <span className="text-muted-foreground ml-1">
61
- ({CLAUDE_AUTO_APPROVE_FLAG})
62
- </span>
63
- </label>
64
- </div>
65
- </div>
66
- )}
67
- </div>
68
- );
69
- }