@atercates/claude-deck 0.2.1 → 0.2.3

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.
Files changed (49) hide show
  1. package/README.md +4 -18
  2. package/app/api/auth/login/route.ts +57 -0
  3. package/app/api/auth/logout/route.ts +13 -0
  4. package/app/api/auth/session/route.ts +29 -0
  5. package/app/api/auth/setup/route.ts +67 -0
  6. package/app/api/projects/[id]/dev-servers/[dsId]/route.ts +1 -1
  7. package/app/api/projects/[id]/repositories/[repoId]/route.ts +1 -1
  8. package/app/api/sessions/[id]/fork/route.ts +1 -1
  9. package/app/api/sessions/[id]/pr/route.ts +1 -1
  10. package/app/api/sessions/[id]/preview/route.ts +1 -1
  11. package/app/api/sessions/[id]/route.ts +13 -4
  12. package/app/api/sessions/[id]/send-keys/route.ts +1 -1
  13. package/app/api/sessions/route.ts +2 -2
  14. package/app/login/page.tsx +192 -0
  15. package/app/setup/page.tsx +279 -0
  16. package/components/ConductorPanel.tsx +1 -1
  17. package/components/DevServers/ServerLogsModal.tsx +24 -21
  18. package/components/DiffViewer/DiffModal.tsx +0 -1
  19. package/components/FileExplorer/index.tsx +1 -1
  20. package/components/GitDrawer/FileEditDialog.tsx +1 -1
  21. package/components/GitPanel/FileChanges.tsx +6 -2
  22. package/components/GitPanel/index.tsx +1 -1
  23. package/components/Pane/index.tsx +16 -15
  24. package/components/Projects/ProjectCard.tsx +1 -1
  25. package/components/QuickSwitcher.tsx +1 -0
  26. package/components/SessionList/SessionList.types.ts +1 -1
  27. package/components/SessionList/index.tsx +8 -8
  28. package/components/Terminal/hooks/useTerminalConnection.ts +3 -2
  29. package/components/Terminal/hooks/websocket-connection.ts +1 -0
  30. package/data/git/queries.ts +0 -1
  31. package/lib/auth/index.ts +15 -0
  32. package/lib/auth/password.ts +14 -0
  33. package/lib/auth/rate-limit.ts +40 -0
  34. package/lib/auth/session.ts +83 -0
  35. package/lib/auth/totp.ts +36 -0
  36. package/lib/claude/process-manager.ts +1 -1
  37. package/lib/code-search.ts +5 -5
  38. package/lib/db/index.ts +1 -1
  39. package/lib/db/queries.ts +64 -0
  40. package/lib/db/schema.ts +19 -0
  41. package/lib/db/types.ts +16 -0
  42. package/lib/git-history.ts +1 -1
  43. package/lib/git.ts +0 -1
  44. package/lib/multi-repo-git.ts +0 -1
  45. package/lib/projects.ts +29 -8
  46. package/package.json +8 -4
  47. package/scripts/agent-os +1 -1
  48. package/scripts/install.sh +2 -2
  49. package/server.ts +20 -1
package/README.md CHANGED
@@ -30,13 +30,13 @@ claude-deck start
30
30
  For fresh installs without Node.js:
31
31
 
32
32
  ```bash
33
- curl -fsSL https://raw.githubusercontent.com/atercates/claude-deck/main/scripts/install.sh | bash
33
+ curl -fsSL https://raw.githubusercontent.com/ATERCATES/claude-deck/main/scripts/install.sh | bash
34
34
  claude-deck start
35
35
  ```
36
36
 
37
37
  ### Desktop App
38
38
 
39
- Download native desktop apps from [Releases](https://github.com/atercates/claude-deck/releases):
39
+ Download native desktop apps from [Releases](https://github.com/ATERCATES/claude-deck/releases):
40
40
 
41
41
  - macOS (Apple Silicon): `.dmg`
42
42
  - Linux: `.deb` or `.AppImage`
@@ -48,7 +48,7 @@ Download native desktop apps from [Releases](https://github.com/atercates/claude
48
48
  ### Manual Install
49
49
 
50
50
  ```bash
51
- git clone https://github.com/atercates/claude-deck
51
+ git clone https://github.com/ATERCATES/claude-deck
52
52
  cd claude-deck
53
53
  npm install
54
54
  npm run dev # http://localhost:3011
@@ -61,20 +61,6 @@ npm run dev # http://localhost:3011
61
61
  - [ripgrep](https://github.com/BurntSushi/ripgrep) (for code search - auto-installed by installer script, or run `claude-deck update`)
62
62
  - At least one AI CLI: [Claude Code](https://github.com/anthropics/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://github.com/anomalyco/opencode), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Aider](https://aider.chat/), or [Cursor CLI](https://cursor.com/cli)
63
63
 
64
- ## Supported Agents
65
-
66
- | Agent | Resume | Fork | Auto-Approve |
67
- | ----------- | ------ | ---- | -------------------------------- |
68
- | Claude Code | ✅ | ✅ | `--dangerously-skip-permissions` |
69
- | Codex | ❌ | ❌ | `--approval-mode full-auto` |
70
- | OpenCode | ❌ | ❌ | Config file |
71
- | Gemini CLI | ❌ | ❌ | `--yolomode` |
72
- | Aider | ❌ | ❌ | `--yes` |
73
- | Cursor CLI | ❌ | ❌ | N/A |
74
- | Amp | ❌ | ❌ | `--dangerously-allow-all` |
75
- | Pi | ❌ | ❌ | N/A |
76
- | Oh My Pi | ❌ | ❌ | N/A |
77
-
78
64
  ## Features
79
65
 
80
66
  - **Mobile-first** - Full functionality from your phone, not a dumbed-down responsive view
@@ -113,7 +99,7 @@ For configuration and advanced usage, see the [docs](https://www.runagentos.com/
113
99
 
114
100
  ## Related Projects
115
101
 
116
- - **[aTerm](https://github.com/atercates/aTerm)** - A Tauri-based desktop terminal workspace for AI-assisted coding. While ClaudeDeck is a mobile-first web UI, aTerm is a native desktop app with multi-pane layouts optimized for running AI coding agents (Claude Code, Aider, OpenCode) alongside shells, dev servers, and a built-in git panel. Choose ClaudeDeck for mobile access and browser-based workflows, or aTerm for a native desktop terminal experience.
102
+ - **[aTerm](https://github.com/ATERCATES/aTerm)** - A Tauri-based desktop terminal workspace for AI-assisted coding. While ClaudeDeck is a mobile-first web UI, aTerm is a native desktop app with multi-pane layouts optimized for running AI coding agents (Claude Code, Aider, OpenCode) alongside shells, dev servers, and a built-in git panel. Choose ClaudeDeck for mobile access and browser-based workflows, or aTerm for a native desktop terminal experience.
117
103
  - **[LumifyHub](https://lumifyhub.io)** - Team collaboration platform with real-time chat and structured documentation. Useful alongside ClaudeDeck for coordinating multi-agent work across a team — share session context, document architectural decisions from coding sessions, and track progress across parallel agent workflows.
118
104
 
119
105
  ## License
@@ -0,0 +1,57 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { queries } from "@/lib/db";
3
+ import {
4
+ verifyPassword,
5
+ verifyTotpCode,
6
+ createSession,
7
+ buildSessionCookie,
8
+ checkRateLimit,
9
+ } from "@/lib/auth";
10
+
11
+ export async function POST(request: NextRequest) {
12
+ const ip =
13
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
14
+ request.headers.get("x-real-ip") ||
15
+ "unknown";
16
+
17
+ const rateCheck = checkRateLimit(ip);
18
+ if (!rateCheck.allowed) {
19
+ return NextResponse.json(
20
+ { error: "Too many login attempts. Try again later." },
21
+ {
22
+ status: 429,
23
+ headers: { "Retry-After": String(rateCheck.retryAfterSeconds) },
24
+ }
25
+ );
26
+ }
27
+
28
+ const body = await request.json();
29
+ const { username, password, totpCode } = body;
30
+
31
+ const INVALID = NextResponse.json(
32
+ { error: "Invalid credentials" },
33
+ { status: 401 }
34
+ );
35
+
36
+ if (!username || !password) return INVALID;
37
+
38
+ const user = queries.getUserByUsername(username);
39
+ if (!user) return INVALID;
40
+
41
+ const validPassword = await verifyPassword(password, user.password_hash);
42
+ if (!validPassword) return INVALID;
43
+
44
+ if (user.totp_secret) {
45
+ if (!totpCode) {
46
+ return NextResponse.json({ requiresTotp: true });
47
+ }
48
+ if (!verifyTotpCode(user.totp_secret, totpCode)) {
49
+ return INVALID;
50
+ }
51
+ }
52
+
53
+ const { token } = createSession(user.id);
54
+ const response = NextResponse.json({ ok: true });
55
+ response.headers.set("Set-Cookie", buildSessionCookie(token));
56
+ return response;
57
+ }
@@ -0,0 +1,13 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { deleteSession, buildClearCookie, COOKIE_NAME } from "@/lib/auth";
3
+
4
+ export async function POST(request: NextRequest) {
5
+ const token = request.cookies.get(COOKIE_NAME)?.value;
6
+ if (token) {
7
+ deleteSession(token);
8
+ }
9
+
10
+ const response = NextResponse.json({ ok: true });
11
+ response.headers.set("Set-Cookie", buildClearCookie());
12
+ return response;
13
+ }
@@ -0,0 +1,29 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import {
3
+ validateSession,
4
+ renewSession,
5
+ COOKIE_NAME,
6
+ hasUsers,
7
+ } from "@/lib/auth";
8
+
9
+ export async function GET(request: NextRequest) {
10
+ if (!hasUsers()) {
11
+ return NextResponse.json({ authenticated: false, needsSetup: true });
12
+ }
13
+
14
+ const token = request.cookies.get(COOKIE_NAME)?.value;
15
+ if (!token) {
16
+ return NextResponse.json({ authenticated: false }, { status: 401 });
17
+ }
18
+
19
+ const user = validateSession(token);
20
+ if (!user) {
21
+ return NextResponse.json({ authenticated: false }, { status: 401 });
22
+ }
23
+
24
+ renewSession(token);
25
+ return NextResponse.json({
26
+ authenticated: true,
27
+ username: user.username,
28
+ });
29
+ }
@@ -0,0 +1,67 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { randomBytes } from "crypto";
3
+ import { queries } from "@/lib/db";
4
+ import {
5
+ hashPassword,
6
+ verifyTotpCode,
7
+ createSession,
8
+ buildSessionCookie,
9
+ hasUsers,
10
+ } from "@/lib/auth";
11
+
12
+ export async function POST(request: NextRequest) {
13
+ if (hasUsers()) {
14
+ return NextResponse.json(
15
+ { error: "Setup already completed" },
16
+ { status: 403 }
17
+ );
18
+ }
19
+
20
+ const body = await request.json();
21
+ const { username, password, totpSecret, totpCode } = body;
22
+
23
+ if (
24
+ !username ||
25
+ typeof username !== "string" ||
26
+ username.length < 3 ||
27
+ username.length > 32 ||
28
+ !/^[a-zA-Z0-9_]+$/.test(username)
29
+ ) {
30
+ return NextResponse.json(
31
+ {
32
+ error:
33
+ "Username must be 3-32 characters, alphanumeric and underscore only",
34
+ },
35
+ { status: 400 }
36
+ );
37
+ }
38
+
39
+ if (!password || typeof password !== "string" || password.length < 8) {
40
+ return NextResponse.json(
41
+ { error: "Password must be at least 8 characters" },
42
+ { status: 400 }
43
+ );
44
+ }
45
+
46
+ if (totpSecret) {
47
+ if (!totpCode || !verifyTotpCode(totpSecret, totpCode)) {
48
+ return NextResponse.json(
49
+ {
50
+ error:
51
+ "Invalid TOTP code. Scan the QR code again and enter the current code.",
52
+ },
53
+ { status: 400 }
54
+ );
55
+ }
56
+ }
57
+
58
+ const id = randomBytes(16).toString("hex");
59
+ const passwordHash = await hashPassword(password);
60
+
61
+ queries.createUser(id, username, passwordHash, totpSecret || null);
62
+
63
+ const { token } = createSession(id);
64
+ const response = NextResponse.json({ ok: true });
65
+ response.headers.set("Set-Cookie", buildSessionCookie(token));
66
+ return response;
67
+ }
@@ -1,6 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { updateProjectDevServer, deleteProjectDevServer } from "@/lib/projects";
3
- import { queries, type ProjectDevServer } from "@/lib/db";
3
+ import { queries } from "@/lib/db";
4
4
 
5
5
  interface RouteParams {
6
6
  params: Promise<{ id: string; dsId: string }>;
@@ -3,7 +3,7 @@ import {
3
3
  updateProjectRepository,
4
4
  deleteProjectRepository,
5
5
  } from "@/lib/projects";
6
- import { queries, type ProjectRepository } from "@/lib/db";
6
+ import { queries } from "@/lib/db";
7
7
 
8
8
  interface RouteParams {
9
9
  params: Promise<{ id: string; repoId: string }>;
@@ -1,6 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { randomUUID } from "crypto";
3
- import { queries, type Session } from "@/lib/db";
3
+ import { queries } from "@/lib/db";
4
4
 
5
5
  interface RouteParams {
6
6
  params: Promise<{ id: string }>;
@@ -1,7 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { exec } from "child_process";
3
3
  import { promisify } from "util";
4
- import { queries, type Session } from "@/lib/db";
4
+ import { queries } from "@/lib/db";
5
5
 
6
6
  const execAsync = promisify(exec);
7
7
 
@@ -1,7 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { exec } from "child_process";
3
3
  import { promisify } from "util";
4
- import { queries, type Session } from "@/lib/db";
4
+ import { queries } from "@/lib/db";
5
5
 
6
6
  const execAsync = promisify(exec);
7
7
 
@@ -1,7 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { exec } from "child_process";
3
3
  import { promisify } from "util";
4
- import { queries, type Session } from "@/lib/db";
4
+ import { queries } from "@/lib/db";
5
5
  import { deleteWorktree, isClaudeDeckWorktree } from "@/lib/worktrees";
6
6
  import { releasePort } from "@/lib/ports";
7
7
  import { killWorker } from "@/lib/orchestration";
@@ -81,7 +81,10 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
81
81
  }
82
82
 
83
83
  // If this is a worktree session, also rename the git branch
84
- if (existing.worktree_path && isClaudeDeckWorktree(existing.worktree_path)) {
84
+ if (
85
+ existing.worktree_path &&
86
+ isClaudeDeckWorktree(existing.worktree_path)
87
+ ) {
85
88
  try {
86
89
  const currentBranch = await getCurrentBranch(existing.worktree_path);
87
90
  const newBranchName = generateBranchName(body.name);
@@ -174,7 +177,10 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
174
177
  await queries.deleteSession(id);
175
178
 
176
179
  // Clean up worktree in background (non-blocking)
177
- if (existing.worktree_path && isClaudeDeckWorktree(existing.worktree_path)) {
180
+ if (
181
+ existing.worktree_path &&
182
+ isClaudeDeckWorktree(existing.worktree_path)
183
+ ) {
178
184
  const worktreePath = existing.worktree_path; // Capture for closure
179
185
  runInBackground(async () => {
180
186
  const { exec } = await import("child_process");
@@ -196,7 +202,10 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
196
202
  // Also cleanup worker worktrees in background
197
203
  if (workers.length > 0) {
198
204
  for (const worker of workers) {
199
- if (worker.worktree_path && isClaudeDeckWorktree(worker.worktree_path)) {
205
+ if (
206
+ worker.worktree_path &&
207
+ isClaudeDeckWorktree(worker.worktree_path)
208
+ ) {
200
209
  const worktreePath = worker.worktree_path; // Capture for closure
201
210
  const workerId = worker.id; // Capture ID for task name
202
211
  runInBackground(async () => {
@@ -1,7 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { exec } from "child_process";
3
3
  import { promisify } from "util";
4
- import { queries, type Session } from "@/lib/db";
4
+ import { queries } from "@/lib/db";
5
5
  import { appendFileSync } from "fs";
6
6
 
7
7
  const execAsync = promisify(exec);
@@ -1,6 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { randomUUID } from "crypto";
3
- import { queries, type Session, type Group } from "@/lib/db";
3
+ import { queries, type Session } from "@/lib/db";
4
4
  import { isValidAgentType, type AgentType } from "@/lib/providers";
5
5
  import { createWorktree } from "@/lib/worktrees";
6
6
  import { setupWorktree, type SetupResult } from "@/lib/env-setup";
@@ -88,7 +88,7 @@ export async function POST(request: NextRequest) {
88
88
  let branchName: string | null = null;
89
89
  let actualWorkingDirectory = workingDirectory;
90
90
  let port: number | null = null;
91
- let setupResult: SetupResult | null = null;
91
+ const setupResult: SetupResult | null = null;
92
92
 
93
93
  if (useWorktree && featureName) {
94
94
  try {
@@ -0,0 +1,192 @@
1
+ "use client";
2
+
3
+ import { useState, useRef, useEffect } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Input } from "@/components/ui/input";
7
+ import { Loader2, Lock, Eye, EyeOff } from "lucide-react";
8
+
9
+ export default function LoginPage() {
10
+ const router = useRouter();
11
+ const [username, setUsername] = useState("");
12
+ const [password, setPassword] = useState("");
13
+ const [totpCode, setTotpCode] = useState("");
14
+ const [showPassword, setShowPassword] = useState(false);
15
+ const [error, setError] = useState("");
16
+ const [loading, setLoading] = useState(false);
17
+ const [step, setStep] = useState<"credentials" | "totp">("credentials");
18
+ const totpInputRef = useRef<HTMLInputElement>(null);
19
+
20
+ useEffect(() => {
21
+ if (step === "totp" && totpInputRef.current) {
22
+ totpInputRef.current.focus();
23
+ }
24
+ }, [step]);
25
+
26
+ const handleSubmit = async (e: React.FormEvent) => {
27
+ e.preventDefault();
28
+ setError("");
29
+ setLoading(true);
30
+
31
+ try {
32
+ const res = await fetch("/api/auth/login", {
33
+ method: "POST",
34
+ headers: { "Content-Type": "application/json" },
35
+ body: JSON.stringify({
36
+ username,
37
+ password,
38
+ ...(step === "totp" ? { totpCode } : {}),
39
+ }),
40
+ });
41
+
42
+ const data = await res.json();
43
+
44
+ if (res.status === 429) {
45
+ setError(
46
+ `Too many attempts. Try again in ${data.retryAfterSeconds || 60}s.`
47
+ );
48
+ return;
49
+ }
50
+
51
+ if (data.requiresTotp) {
52
+ setStep("totp");
53
+ return;
54
+ }
55
+
56
+ if (!res.ok) {
57
+ setError(data.error || "Invalid credentials");
58
+ if (step === "totp") setTotpCode("");
59
+ return;
60
+ }
61
+
62
+ router.push("/");
63
+ router.refresh();
64
+ } catch {
65
+ setError("Connection error");
66
+ } finally {
67
+ setLoading(false);
68
+ }
69
+ };
70
+
71
+ useEffect(() => {
72
+ if (step === "totp" && totpCode.length === 6) {
73
+ const form = document.getElementById("login-form") as HTMLFormElement;
74
+ form?.requestSubmit();
75
+ }
76
+ }, [totpCode, step]);
77
+
78
+ return (
79
+ <div className="bg-background flex min-h-screen items-center justify-center p-4">
80
+ <div className="border-border bg-card w-full max-w-sm rounded-xl border p-8 shadow-lg">
81
+ <div className="mb-8 text-center">
82
+ <div className="bg-primary/10 text-primary mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
83
+ <Lock className="h-6 w-6" />
84
+ </div>
85
+ <h1 className="text-foreground text-2xl font-semibold">ClaudeDeck</h1>
86
+ <p className="text-muted-foreground mt-1 text-sm">
87
+ {step === "credentials"
88
+ ? "Sign in to continue"
89
+ : "Enter your 2FA code"}
90
+ </p>
91
+ </div>
92
+
93
+ <form id="login-form" onSubmit={handleSubmit} className="space-y-4">
94
+ {step === "credentials" ? (
95
+ <>
96
+ <div className="space-y-2">
97
+ <label
98
+ htmlFor="username"
99
+ className="text-foreground text-sm font-medium"
100
+ >
101
+ Username
102
+ </label>
103
+ <Input
104
+ id="username"
105
+ type="text"
106
+ value={username}
107
+ onChange={(e) => setUsername(e.target.value)}
108
+ autoComplete="username"
109
+ autoFocus
110
+ required
111
+ />
112
+ </div>
113
+ <div className="space-y-2">
114
+ <label
115
+ htmlFor="password"
116
+ className="text-foreground text-sm font-medium"
117
+ >
118
+ Password
119
+ </label>
120
+ <div className="relative">
121
+ <Input
122
+ id="password"
123
+ type={showPassword ? "text" : "password"}
124
+ value={password}
125
+ onChange={(e) => setPassword(e.target.value)}
126
+ autoComplete="current-password"
127
+ required
128
+ />
129
+ <button
130
+ type="button"
131
+ onClick={() => setShowPassword(!showPassword)}
132
+ className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
133
+ tabIndex={-1}
134
+ >
135
+ {showPassword ? (
136
+ <EyeOff className="h-4 w-4" />
137
+ ) : (
138
+ <Eye className="h-4 w-4" />
139
+ )}
140
+ </button>
141
+ </div>
142
+ </div>
143
+ </>
144
+ ) : (
145
+ <div className="space-y-2">
146
+ <label
147
+ htmlFor="totp"
148
+ className="text-foreground text-sm font-medium"
149
+ >
150
+ Authentication code
151
+ </label>
152
+ <Input
153
+ ref={totpInputRef}
154
+ id="totp"
155
+ type="text"
156
+ inputMode="numeric"
157
+ pattern="[0-9]*"
158
+ maxLength={6}
159
+ value={totpCode}
160
+ onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ""))}
161
+ placeholder="000000"
162
+ className="text-center text-2xl tracking-[0.5em]"
163
+ autoComplete="one-time-code"
164
+ required
165
+ />
166
+ <button
167
+ type="button"
168
+ onClick={() => {
169
+ setStep("credentials");
170
+ setTotpCode("");
171
+ setError("");
172
+ }}
173
+ className="text-muted-foreground hover:text-foreground text-xs underline"
174
+ >
175
+ Back to login
176
+ </button>
177
+ </div>
178
+ )}
179
+
180
+ {error && (
181
+ <p className="text-destructive text-center text-sm">{error}</p>
182
+ )}
183
+
184
+ <Button type="submit" className="w-full" disabled={loading}>
185
+ {loading && <Loader2 className="h-4 w-4 animate-spin" />}
186
+ {step === "credentials" ? "Sign in" : "Verify"}
187
+ </Button>
188
+ </form>
189
+ </div>
190
+ </div>
191
+ );
192
+ }