Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions .claude/skills/create-pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,37 @@ That's it. No "Test Plan", no "Screenshots", no checklists unless truly needed.

## Steps

1. Check current state:
1. **Check changed files**:
```bash
git diff --name-only main...HEAD
git log --oneline main...HEAD
```

2. Create PR:
2. **Run code-simplifier first** (if available):

Run the `code-simplifier:code-simplifier` agent to simplify and clean up the code.
This step modifies code, so it must run before reviews. Commit any changes it makes.

3. **Run validation + reviews in parallel**:

After code-simplifier is done, run these concurrently:
- `bun run validate` (background)
- Review agents based on changed files:

| Changed files | Agent to spawn |
|---------------|----------------|
| `src/agent/`, auth, user input, data handling | `security-review` |
| Loops, data fetching, DB queries, heavy computation | `perf-review` |
| `web/` or `mobile/` (.tsx/.jsx files) | `react-review` |

Spawn all applicable review agents in parallel using the Task tool.

4. **Fix any issues** found by validation or review agents before proceeding

5. **Create PR** (only after validation passes and reviews are addressed):
```bash
gh pr create --title "<type>: <description>" --body "$(cat <<'EOF'
## Summary

- <what>
- <why>
EOF
Expand Down
8 changes: 7 additions & 1 deletion src/agent/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { timingSafeEqual } from 'crypto';
import type { AgentConfig } from '../shared/types';
import { getTailscaleIdentity } from '../tailscale';

function secureCompare(a: string, b: string): boolean {
if (a.length !== b.length) return false;
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
}

export interface AuthResult {
ok: boolean;
identity?: { type: 'token' | 'tailscale'; user?: string };
Expand Down Expand Up @@ -36,7 +42,7 @@ export function checkAuth(req: Request, config: AgentConfig): AuthResult {
const authHeader = req.headers.get('Authorization');
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7);
if (token === config.auth.token) {
if (secureCompare(token, config.auth.token)) {
return { ok: true, identity: { type: 'token' } };
}
}
Expand Down
28 changes: 28 additions & 0 deletions src/agent/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as z from 'zod';
import os_module from 'os';
import { promises as fs } from 'fs';
import path from 'path';
import crypto from 'crypto';
import type { AgentConfig } from '../shared/types';
import { HOST_WORKSPACE_NAME } from '../shared/client-types';
import { AnyWorkspaceNameSchema, UserWorkspaceNameSchema } from '../shared/workspace-name';
Expand Down Expand Up @@ -186,6 +187,11 @@ const TailscaleConfigSchema = z.object({
hostnamePrefix: z.string().optional(),
});

const AuthConfigSchema = z.object({
hasToken: z.boolean(),
tokenPreview: z.string().optional(),
});

export interface TailscaleInfo {
running: boolean;
dnsName?: string;
Expand Down Expand Up @@ -656,6 +662,24 @@ export function createRouter(ctx: RouterContext) {
};
});

const getAuthConfig = os.output(AuthConfigSchema).handler(async () => {
const config = ctx.config.get();
const token = config.auth?.token;
return {
hasToken: !!token,
tokenPreview: token ? `${token.slice(0, 10)}...${token.slice(-4)}` : undefined,
};
});

const generateAuthToken = os.output(z.object({ token: z.string() })).handler(async () => {
const currentConfig = ctx.config.get();
const token = `perry-${crypto.randomBytes(16).toString('hex')}`;
const newConfig = { ...currentConfig, auth: { ...currentConfig.auth, token } };
ctx.config.set(newConfig);
await saveAgentConfig(newConfig, ctx.configDir);
return { token };
});

const GitHubRepoSchema = z.object({
name: z.string(),
fullName: z.string(),
Expand Down Expand Up @@ -1553,6 +1577,10 @@ export function createRouter(ctx: RouterContext) {
get: getTailscaleConfig,
update: updateTailscaleConfig,
},
auth: {
get: getAuthConfig,
generate: generateAuthToken,
},
},
};
}
Expand Down
136 changes: 124 additions & 12 deletions src/cli/setup-wizard.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import React, { useState, useEffect } from 'react';
import { render, Box, Text, useInput, useApp } from 'ink';
import TextInput from 'ink-text-input';
import crypto from 'crypto';
import { loadAgentConfig, saveAgentConfig, getConfigDir, ensureConfigDir } from '../config/loader';
import { discoverSSHKeys } from '../ssh';
import type { SSHKeyInfo } from '../shared/client-types';

type Step = 'welcome' | 'github' | 'ssh' | 'tailscale' | 'complete';
type Step = 'welcome' | 'auth' | 'github' | 'ssh' | 'tailscale' | 'complete';

const STEPS: Step[] = ['welcome', 'github', 'ssh', 'tailscale', 'complete'];
const STEPS: Step[] = ['welcome', 'auth', 'github', 'ssh', 'tailscale', 'complete'];

interface WizardState {
authToken: string;
authTokenGenerated: boolean;
githubToken: string;
selectedSSHKeys: string[];
tailscaleAuthKey: string;
Expand All @@ -31,6 +34,7 @@ function WelcomeStep({ onNext }: { onNext: () => void }) {
</Box>
<Text>This wizard will help you configure:</Text>
<Box marginLeft={2} flexDirection="column">
<Text>• API security (auth token)</Text>
<Text>• Git access (GitHub token)</Text>
<Text>• SSH keys for workspaces</Text>
<Text>• Tailscale networking</Text>
Expand Down Expand Up @@ -69,7 +73,7 @@ function TokenInputStep({
} else if (key.escape) {
onBack();
} else if (input === 'v' && key.ctrl) {
setShowValue((s: boolean) => !s);
setShowValue((s) => !s);
} else if (input === 's' && optional) {
onNext();
}
Expand Down Expand Up @@ -100,6 +104,81 @@ function TokenInputStep({
);
}

function AuthStep({
token,
isNew,
onGenerate,
onNext,
onBack,
}: {
token: string;
isNew: boolean;
onGenerate: () => void;
onNext: () => void;
onBack: () => void;
}) {
const [showToken, setShowToken] = useState(false);

useInput((input, key) => {
if (key.return) {
onNext();
} else if (key.escape) {
onBack();
} else if (input === 'g' && !token) {
onGenerate();
} else if (input === 'v' && key.ctrl) {
setShowToken((s) => !s);
}
});

const maskedToken = token ? `${token.slice(0, 10)}...${token.slice(-4)}` : '';

return (
<Box flexDirection="column" gap={1}>
<Text bold>API Security</Text>
<Text color="gray">
Secure your Perry agent with token authentication. Clients will need this token to connect.
</Text>

<Box marginTop={1} flexDirection="column">
{token ? (
<>
<Box>
<Text color="green">✓ </Text>
<Text>Auth token {isNew ? 'generated' : 'configured'}</Text>
</Box>
<Box marginTop={1}>
<Text>Token: </Text>
<Text color="cyan">{showToken ? token : maskedToken}</Text>
</Box>
<Box marginTop={1} flexDirection="column">
<Text color="yellow">Save this token! You'll need it to configure clients:</Text>
<Box marginLeft={2}>
<Text color="gray">CLI: perry config token {showToken ? token : '<token>'}</Text>
</Box>
<Box marginLeft={2}>
<Text color="gray">Web: Enter when prompted on first visit</Text>
</Box>
</Box>
</>
) : (
<>
<Text color="yellow">No auth token configured.</Text>
<Text>Without a token, anyone with network access can control your agent.</Text>
</>
)}
</Box>

<Box marginTop={1}>
<Text color="gray">
{token ? 'Ctrl+V to show/hide token, ' : 'G to generate token, '}
Enter to continue, Esc to go back
</Text>
</Box>
</Box>
);
}

function SSHKeySelectStep({
keys,
selected,
Expand All @@ -118,9 +197,9 @@ function SSHKeySelectStep({

useInput((input, key) => {
if (key.upArrow) {
setHighlighted((h: number) => Math.max(0, h - 1));
setHighlighted((h) => Math.max(0, h - 1));
} else if (key.downArrow) {
setHighlighted((h: number) => Math.min(privateKeys.length - 1, h + 1));
setHighlighted((h) => Math.min(privateKeys.length - 1, h + 1));
} else if (input === ' ' && privateKeys.length > 0) {
onToggle(privateKeys[highlighted].path);
} else if (key.return) {
Expand Down Expand Up @@ -175,6 +254,7 @@ function CompleteStep({ state, onFinish }: { state: WizardState; onFinish: () =>
});

const configured: string[] = [];
if (state.authToken) configured.push('Auth token');
if (state.githubToken) configured.push('GitHub');
if (state.selectedSSHKeys.length > 0)
configured.push(`${state.selectedSSHKeys.length} SSH key(s)`);
Expand All @@ -197,6 +277,14 @@ function CompleteStep({ state, onFinish }: { state: WizardState; onFinish: () =>
) : (
<Text color="yellow">No configuration added. You can always configure later.</Text>
)}
{state.authToken && (
<Box marginTop={1} flexDirection="column">
<Text color="yellow">Remember to configure your clients with the auth token:</Text>
<Box marginLeft={2}>
<Text color="gray">perry config token {'<token>'}</Text>
</Box>
</Box>
)}
<Box marginTop={1}>
<Text>Start the agent with: </Text>
<Text color="cyan">perry agent run</Text>
Expand All @@ -217,6 +305,8 @@ function SetupWizard() {
const [step, setStep] = useState<Step>('welcome');
const [sshKeys, setSSHKeys] = useState<SSHKeyInfo[]>([]);
const [state, setState] = useState<WizardState>({
authToken: '',
authTokenGenerated: false,
githubToken: '',
selectedSSHKeys: [],
tailscaleAuthKey: '',
Expand All @@ -234,8 +324,10 @@ function SetupWizard() {
const configDir = getConfigDir();
await ensureConfigDir(configDir);
const config = await loadAgentConfig(configDir);
setState((s: WizardState) => ({
setState((s) => ({
...s,
authToken: config.auth?.token || '',
authTokenGenerated: false,
githubToken: config.agents?.github?.token || '',
selectedSSHKeys: config.ssh?.global.copy || [],
tailscaleAuthKey: config.tailscale?.authKey || '',
Expand All @@ -259,21 +351,34 @@ function SetupWizard() {
};

const toggleSSHKey = (path: string) => {
setState((s: WizardState) => ({
setState((s) => ({
...s,
selectedSSHKeys: s.selectedSSHKeys.includes(path)
? s.selectedSSHKeys.filter((k: string) => k !== path)
? s.selectedSSHKeys.filter((k) => k !== path)
: [...s.selectedSSHKeys, path],
}));
};

const generateAuthToken = () => {
const token = `perry-${crypto.randomBytes(16).toString('hex')}`;
setState((s) => ({
...s,
authToken: token,
authTokenGenerated: true,
}));
};

const saveAndFinish = async () => {
setSaving(true);
try {
const configDir = getConfigDir();
await ensureConfigDir(configDir);
const config = await loadAgentConfig(configDir);

if (state.authTokenGenerated && state.authToken) {
config.auth = { ...config.auth, token: state.authToken };
}

if (state.githubToken) {
config.agents = {
...config.agents,
Expand Down Expand Up @@ -325,13 +430,22 @@ function SetupWizard() {
) : (
<>
{step === 'welcome' && <WelcomeStep onNext={nextStep} />}
{step === 'auth' && (
<AuthStep
token={state.authToken}
isNew={state.authTokenGenerated}
onGenerate={generateAuthToken}
onNext={nextStep}
onBack={prevStep}
/>
)}
{step === 'github' && (
<TokenInputStep
title="GitHub Personal Access Token"
placeholder="ghp_... or github_pat_..."
helpText="Create at https://github.com/settings/personal-access-tokens/new"
value={state.githubToken}
onChange={(v: string) => setState((s: WizardState) => ({ ...s, githubToken: v }))}
onChange={(v) => setState((s) => ({ ...s, githubToken: v }))}
onNext={nextStep}
onBack={prevStep}
optional
Expand All @@ -352,9 +466,7 @@ function SetupWizard() {
placeholder="tskey-auth-..."
helpText="Generate at https://login.tailscale.com/admin/settings/keys (Reusable: Yes, Ephemeral: No)"
value={state.tailscaleAuthKey}
onChange={(v: string) =>
setState((s: WizardState) => ({ ...s, tailscaleAuthKey: v }))
}
onChange={(v) => setState((s) => ({ ...s, tailscaleAuthKey: v }))}
onNext={nextStep}
onBack={prevStep}
optional
Expand Down
Loading
Loading