Skip to content

Commit 3348ccb

Browse files
grichaclaude
andauthored
Make perry start create workspace if it doesn't exist (#7)
Consolidate workspace creation and starting into a single command. Now `perry start <name>` will create the workspace if it doesn't exist, similar to `docker run`. - Remove `perry create` CLI command (start now handles both) - Update WorkspaceManager.start() to delegate to create() when needed - Add --clone option to start command for initial repo clone - Update API router and client to pass through clone/env options - Update docs and tests to reflect new behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 5a62669 commit 3348ccb

File tree

9 files changed

+48
-62
lines changed

9 files changed

+48
-62
lines changed

docs/docs/cli.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@ perry agent status
1616
## Workspaces
1717

1818
```bash
19-
perry create <name> [--clone URL]
20-
perry start <name>
19+
perry start <name> [--clone URL] # Start (creates if doesn't exist)
2120
perry stop <name>
2221
perry delete <name>
2322
perry list

docs/docs/troubleshooting.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ perry build
9898
3. **Delete and recreate:**
9999
```bash
100100
perry delete <name>
101-
perry create <name>
101+
perry start <name>
102102
```
103103

104104
4. **Check for port conflicts:**
@@ -168,7 +168,7 @@ perry build
168168

169169
4. **Use HTTPS URL instead:**
170170
```bash
171-
perry create myproject --clone https://github.com/user/repo.git
171+
perry start myproject --clone https://github.com/user/repo.git
172172
```
173173

174174
### Workspace deleted but container still running

docs/src/pages/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,8 @@ perry build
117117
# Start agent
118118
perry agent run
119119
120-
# Create workspace
121-
perry create myproject --clone git@github.com:user/repo.git
120+
# Start workspace (creates if needed)
121+
perry start myproject --clone git@github.com:user/repo.git
122122
123123
# Access via SSH
124124
ssh -p 2201 workspace@localhost

src/agent/router.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,11 +159,20 @@ export function createRouter(ctx: RouterContext) {
159159
});
160160

161161
const startWorkspace = os
162-
.input(z.object({ name: z.string() }))
162+
.input(
163+
z.object({
164+
name: z.string(),
165+
clone: z.string().optional(),
166+
env: z.record(z.string(), z.string()).optional(),
167+
})
168+
)
163169
.output(WorkspaceInfoSchema)
164170
.handler(async ({ input }) => {
165171
try {
166-
return await ctx.workspaces.start(input.name);
172+
return await ctx.workspaces.start(input.name, {
173+
clone: input.clone,
174+
env: input.env,
175+
});
167176
} catch (err) {
168177
mapErrorToORPC(err, 'Failed to start workspace');
169178
}

src/client/api.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,12 @@ export class ApiClient {
8484
}
8585
}
8686

87-
async startWorkspace(name: string): Promise<WorkspaceInfo> {
87+
async startWorkspace(
88+
name: string,
89+
options?: { clone?: string; env?: Record<string, string> }
90+
): Promise<WorkspaceInfo> {
8891
try {
89-
return await this.client.workspaces.start({ name });
92+
return await this.client.workspaces.start({ name, clone: options?.clone, env: options?.env });
9093
} catch (err) {
9194
throw this.wrapError(err);
9295
}

src/index.ts

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -148,40 +148,20 @@ program
148148
}
149149
});
150150

151-
program
152-
.command('create <name>')
153-
.description('Create a new workspace')
154-
.option('--clone <url>', 'Git repository URL to clone')
155-
.action(async (name, options) => {
156-
try {
157-
const client = await getClient();
158-
console.log(`Creating workspace '${name}'...`);
159-
160-
const workspace = await client.createWorkspace({
161-
name,
162-
clone: options.clone,
163-
});
164-
165-
console.log(`Workspace '${workspace.name}' created.`);
166-
console.log(` Status: ${workspace.status}`);
167-
console.log(` SSH Port: ${workspace.ports.ssh}`);
168-
} catch (err) {
169-
handleError(err);
170-
}
171-
});
172-
173151
program
174152
.command('start <name>')
175-
.description('Start a stopped workspace')
176-
.action(async (name) => {
153+
.description('Start a workspace (creates it if it does not exist)')
154+
.option('--clone <url>', 'Git repository URL to clone (when creating)')
155+
.action(async (name, options) => {
177156
try {
178157
const client = await getClient();
179158
console.log(`Starting workspace '${name}'...`);
180159

181-
const workspace = await client.startWorkspace(name);
160+
const workspace = await client.startWorkspace(name, { clone: options.clone });
182161

183162
console.log(`Workspace '${workspace.name}' started.`);
184163
console.log(` Status: ${workspace.status}`);
164+
console.log(` SSH Port: ${workspace.ports.ssh}`);
185165
} catch (err) {
186166
handleError(err);
187167
}

src/workspace/manager.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -547,10 +547,13 @@ export class WorkspaceManager {
547547
}
548548
}
549549

550-
async start(name: string): Promise<Workspace> {
550+
async start(
551+
name: string,
552+
options?: { clone?: string; env?: Record<string, string> }
553+
): Promise<Workspace> {
551554
const workspace = await this.state.getWorkspace(name);
552555
if (!workspace) {
553-
throw new Error(`Workspace '${name}' not found`);
556+
return this.create({ name, clone: options?.clone, env: options?.env });
554557
}
555558

556559
const containerName = getContainerName(name);

test/integration/agent.test.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,13 @@ describe('Agent - Workspace Lifecycle', () => {
121121
}
122122
});
123123

124-
it('returns 404 when starting non-existent workspace', async () => {
125-
const result = await agent.api.startWorkspace('nonexistent');
126-
expect(result.status).toBe(404);
127-
expect((result.data as { code: string }).code).toBe('NOT_FOUND');
128-
});
124+
it('creates workspace when starting non-existent workspace', async () => {
125+
const name = `nonexistent-${Date.now()}`;
126+
const result = await agent.api.startWorkspace(name);
127+
expect(result.status).toBe(200);
128+
expect((result.data as { name: string }).name).toBe(name);
129+
await agent.api.deleteWorkspace(name);
130+
}, 60000);
129131

130132
it('returns 404 when stopping non-existent workspace', async () => {
131133
const result = await agent.api.stopWorkspace('nonexistent');

test/integration/cli.test.ts

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@ describe('CLI commands', () => {
4242
});
4343
});
4444

45-
describe('workspace create', () => {
46-
it('creates a workspace', async () => {
45+
describe('workspace start (create)', () => {
46+
it('creates a workspace when it does not exist', async () => {
4747
const name = generateTestWorkspaceName();
48-
const result = await runCLIExpecting(['create', name], [`Workspace '${name}' created`], {
48+
const result = await runCLIExpecting(['start', name], [`Workspace '${name}' started`], {
4949
env: cliEnv(),
5050
timeout: 30000,
5151
});
@@ -56,25 +56,25 @@ describe('CLI commands', () => {
5656

5757
it('creates workspace with --clone option', async () => {
5858
const name = generateTestWorkspaceName();
59-
const result = await runCLI(['create', name, '--clone', 'https://github.com/example/repo'], {
59+
const result = await runCLI(['start', name, '--clone', 'https://github.com/example/repo'], {
6060
env: cliEnv(),
6161
timeout: 30000,
6262
});
6363
expect(result.code).toBe(0);
64-
expect(result.stdout).toContain(`Workspace '${name}' created`);
64+
expect(result.stdout).toContain(`Workspace '${name}' started`);
6565

6666
await agent.api.deleteWorkspace(name);
6767
});
6868

69-
it('fails when workspace already exists', async () => {
69+
it('starts existing workspace without error', async () => {
7070
const name = generateTestWorkspaceName();
7171
await agent.api.createWorkspace({ name });
7272

73-
const result = await runCLIExpectingError(['create', name], ['already exists'], {
73+
const result = await runCLIExpecting(['start', name], [`Workspace '${name}' started`], {
7474
env: cliEnv(),
7575
timeout: 30000,
7676
});
77-
expect(result.code).not.toBe(0);
77+
expect(result.code).toBe(0);
7878

7979
await agent.api.deleteWorkspace(name);
8080
});
@@ -136,13 +136,6 @@ describe('CLI commands', () => {
136136

137137
await agent.api.deleteWorkspace(name);
138138
});
139-
140-
it('fails to start nonexistent workspace', async () => {
141-
const result = await runCLIExpectingError(['start', 'nonexistent-workspace'], ['not found'], {
142-
env: cliEnv(),
143-
});
144-
expect(result.code).not.toBe(0);
145-
});
146139
});
147140

148141
describe('workspace delete', () => {
@@ -172,11 +165,8 @@ describe('CLI commands', () => {
172165
});
173166

174167
it('fails to delete nonexistent workspace', async () => {
175-
const result = await runCLIExpectingError(
176-
['delete', 'nonexistent-workspace'],
177-
['not found'],
178-
{ env: cliEnv() }
179-
);
168+
const name = `nonexistent-${Date.now()}-${Math.random().toString(36).slice(2)}`;
169+
const result = await runCLIExpectingError(['delete', name], ['not found'], { env: cliEnv() });
180170
expect(result.code).not.toBe(0);
181171
});
182172
});

0 commit comments

Comments
 (0)