Skip to content

Commit d279324

Browse files
committed
feat: add yavy project create command
1 parent fa198a3 commit d279324

21 files changed

Lines changed: 1327 additions & 10 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ dist
44
.omc
55
*.log
66
*.tgz
7+
.claude

CLAUDE.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# @yavydev/cli
2+
3+
CLI for searching, managing, and configuring AI-ready documentation on Yavy.
4+
5+
## Commands
6+
7+
```bash
8+
pnpm install # Install dependencies
9+
pnpm run build # Build with tsup
10+
pnpm run dev # Build in watch mode
11+
pnpm test # Run tests (vitest)
12+
pnpm run typecheck # Type check without emitting
13+
pnpm run format:check # Check formatting (prettier)
14+
pnpm run format # Fix formatting
15+
```
16+
17+
## Architecture
18+
19+
The CLI is built on Commander.js with a modular command structure. Each command is a factory function returning a `Command` instance, registered in the entry point.
20+
21+
- **Commands** - one directory or file per command group. Each exports a factory.
22+
- **API Client** - token-based HTTP client with retry logic and exponential backoff.
23+
- **Auth** - OAuth2 PKCE flow with local callback server; credentials stored in `~/.yavy/`.
24+
- **Prompts** - interactive flows using @clack/prompts for multi-select and @inquirer/prompts for input/select.
25+
26+
See [docs/architecture.md](docs/architecture.md) for details.
27+
28+
## Key Design Decisions
29+
30+
- `@/` path aliases throughout (configured in tsconfig, tsup, vitest).
31+
- Commands set `process.exitCode` instead of calling `process.exit()` directly - keeps code testable.
32+
- Two auth patterns coexist: OAuth (login flow) and token-based (API token via env or config file).
33+
- Interactive mode activates when required CLI flags are missing; flags always take precedence.
34+
35+
## After Changing Commands
36+
37+
- Register new commands in the entry point.
38+
- Add the command to README.md under the Commands section.
39+
- If adding a new command group, create a directory under `src/commands/`.
40+
41+
## After Changing the API Client
42+
43+
- Update tests that mock fetch globally.
44+
- If adding new response types, define them alongside existing API interfaces in the client module.
45+
46+
## Documentation
47+
48+
- [Architecture](docs/architecture.md) - layers, data flow, design decisions
49+
- [README](README.md) - install, quick start, command reference

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,22 @@ Lists all projects you have access to across your organizations.
6363
| -------- | -------------- |
6464
| `--json` | Output as JSON |
6565

66+
### `yavy project create`
67+
68+
Create a new documentation project. Runs interactively when `--url` or `--github` is omitted.
69+
70+
| Flag | Description |
71+
| ----------------- | ---------------------------------------- |
72+
| `--url <url>` | Documentation URL (web crawl source) |
73+
| `--github <repo>` | GitHub repository (e.g. laravel/docs) |
74+
| `--org <slug>` | Organization slug |
75+
| `--name <name>` | Project name (auto-generated if omitted) |
76+
| `--public` | Make project public (default) |
77+
| `--private` | Make project private |
78+
| `--branch <name>` | GitHub branch override |
79+
| `--docs-path <p>` | GitHub docs path |
80+
| `--no-sync` | Skip initial auto-sync |
81+
6682
### `yavy generate <org/project>`
6783

6884
Downloads a skill from a project's indexed documentation.

docs/architecture.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Architecture
2+
3+
How the CLI is structured, where things live, and why.
4+
5+
## Layers
6+
7+
### Entry Point
8+
9+
`bin/yavy.js` is the shebang entry that loads the built output. The source entry registers all commands with Commander.js and calls `parseAsync()`. Top-level error handler catches unhandled rejections and exits with code 1.
10+
11+
### Commands
12+
13+
Each command is a factory function that returns a configured `Command`. Commands live in `src/commands/` - either as single files (login, logout, search) or directories when they have submodules (init, project).
14+
15+
Command factories wire up flags, descriptions, and an async action handler. The action handler orchestrates: validate inputs, call API, format output. No business logic lives in the action - it delegates to dedicated modules.
16+
17+
### API Client
18+
19+
Single HTTP client class handles all Yavy API communication. Key behaviors:
20+
21+
- Bearer token auth (loaded from credential store or environment)
22+
- Retry with exponential backoff on 429/502/503/504
23+
- Request timeout with AbortController
24+
- Typed response parsing
25+
26+
### Authentication
27+
28+
Two paths:
29+
30+
1. **OAuth PKCE** - `yavy login` opens browser, spawns local HTTP server for callback, exchanges code for token. Credentials persisted to `~/.yavy/credentials.json` with 0600 permissions. Auto-refresh when token nears expiry.
31+
2. **Token-based** - `YAVY_API_TOKEN` env variable or `~/.yavy/config.json`. Used by the project creation flow and CI environments.
32+
33+
### Interactive Prompts
34+
35+
When required CLI flags are missing, commands fall back to interactive mode. Prompts collect the missing values, then merge with any flags that were provided. Two prompt libraries are in use: @clack/prompts (multi-select, spinners) and @inquirer/prompts (input, select).
36+
37+
### Utilities
38+
39+
- **Output** - colored terminal helpers (success, error, warn, info) using chalk
40+
- **Paths** - skill output directories, zip-slip prevention, safe directory creation
41+
- **Errors** - API error formatting that maps HTTP status codes to actionable messages
42+
43+
## Data Flow
44+
45+
```
46+
User runs command
47+
-> Commander parses flags
48+
-> Command action handler runs
49+
-> If missing flags: interactive prompts fill them in
50+
-> API client makes request (with retry)
51+
-> Response formatted and printed to stdout
52+
-> Errors caught, formatted, printed to stderr
53+
```
54+
55+
## Build & Distribution
56+
57+
tsup bundles to ESM targeting Node 20+. The dist includes declarations and source maps. Published to npm as `@yavydev/cli` with `dist/` and `bin/` in the package.
58+
59+
## Testing Strategy
60+
61+
Vitest with globals enabled. Tests mock at module boundaries - the API client, prompts, and filesystem are mocked; pure functions (payload builders, error formatters, org extractors) are tested directly. Console output is verified via spies on console.log/console.error.

src/api/client.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
11
import { getAccessToken } from '@/auth/store';
22
import { MAX_RETRIES, REQUEST_TIMEOUT_MS, YAVY_BASE_URL, YAVY_USER_AGENT } from '@/config';
33

4+
export class ApiError extends Error {
5+
constructor(
6+
public readonly status: number,
7+
public readonly body: unknown,
8+
message?: string,
9+
) {
10+
super(message ?? `API request failed with status ${status}`);
11+
this.name = 'ApiError';
12+
}
13+
}
14+
15+
export interface OrganizationInfo {
16+
name: string;
17+
slug: string;
18+
}
19+
420
export interface ProjectContext {
521
product: string | null;
622
type: string | null;
@@ -21,16 +37,19 @@ export interface ApiProject {
2137
name: string;
2238
slug: string;
2339
description: string | null;
24-
organization: {
25-
name: string;
26-
slug: string;
27-
};
40+
organization: OrganizationInfo;
2841
pages_count: number;
2942
last_indexed_at: string | null;
3043
has_indexed_content: boolean;
44+
mcp_url: string;
3145
context: ProjectContext;
3246
}
3347

48+
export interface ApiValidationError {
49+
message: string;
50+
errors: Record<string, string[]>;
51+
}
52+
3453
export interface SearchResult {
3554
title: string;
3655
url: string;
@@ -112,12 +131,14 @@ export class YavyApiClient {
112131
}
113132

114133
private async handleErrorResponse(response: Response): Promise<never> {
134+
const body = await response.json().catch(() => ({}));
135+
115136
if (response.status === 401) {
116-
throw new Error('Authentication expired. Run `yavy login` to re-authenticate.');
137+
throw new ApiError(401, body, 'Authentication expired. Run `yavy login` to re-authenticate.');
117138
}
118139

119-
const errorData = (await response.json().catch(() => ({}))) as { error?: string };
120-
throw new Error(errorData.error ?? `API request failed with status ${response.status}`);
140+
const errorData = body as { error?: string };
141+
throw new ApiError(response.status, body, errorData.error ?? `API request failed with status ${response.status}`);
121142
}
122143

123144
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
@@ -146,6 +167,10 @@ export class YavyApiClient {
146167
return result.data;
147168
}
148169

170+
async createProject(orgSlug: string, payload: unknown): Promise<{ data: ApiProject }> {
171+
return this.request<{ data: ApiProject }>('POST', `/${encodeURIComponent(orgSlug)}/projects`, payload);
172+
}
173+
149174
async search(query: string, options?: { project?: string; limit?: number }): Promise<SearchResponse> {
150175
const params = new URLSearchParams({ query });
151176
if (options?.project) params.set('project', options.project);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { CreateProjectOptions, CreateProjectPayload } from '@/commands/project/types';
2+
3+
export function buildCreateProjectPayload(options: CreateProjectOptions): CreateProjectPayload {
4+
if (options.url) {
5+
return buildWebCrawlPayload(options);
6+
}
7+
8+
if (options.github) {
9+
return buildGitHubPayload(options);
10+
}
11+
12+
throw new Error('Either --url or --github is required.');
13+
}
14+
15+
function buildWebCrawlPayload(options: CreateProjectOptions): CreateProjectPayload {
16+
return {
17+
url_discovery_mode: 'web_crawl',
18+
base_url: options.url,
19+
...sharedFields(options),
20+
};
21+
}
22+
23+
function buildGitHubPayload(options: CreateProjectOptions): CreateProjectPayload {
24+
return {
25+
url_discovery_mode: 'github_repository',
26+
github_repo: options.github,
27+
...(options.branch && { github_branch: options.branch }),
28+
...(options.docsPath && { github_docs_path: options.docsPath }),
29+
...sharedFields(options),
30+
};
31+
}
32+
33+
function sharedFields(options: CreateProjectOptions): Pick<CreateProjectPayload, 'name' | 'is_public' | 'no_sync'> {
34+
return {
35+
...(options.name && { name: options.name }),
36+
is_public: !options.private,
37+
...(options.noSync && { no_sync: true }),
38+
};
39+
}

src/commands/project/create.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { Command } from 'commander';
2+
import { YavyApiClient } from '@/api/client';
3+
import { needsInteractiveMode, runInteractiveFlow } from '@/prompts/project-create';
4+
import { error, formatProjectCreated } from '@/utils';
5+
import { buildCreateProjectPayload } from '@/commands/project/build-request';
6+
import { resolveOrg } from '@/commands/project/resolve-org';
7+
import type { CreateProjectOptions } from '@/commands/project/types';
8+
9+
export function createProjectCommand(): Command {
10+
return new Command('create')
11+
.description('Create a new documentation project')
12+
.option('--url <url>', 'Documentation URL (WebCrawl source)')
13+
.option('--github <owner/repo>', 'GitHub repository (e.g. laravel/docs)')
14+
.option('--org <slug>', 'Organization slug')
15+
.option('--name <name>', 'Project name (auto-generated if omitted)')
16+
.option('--public', 'Make project public (default)')
17+
.option('--private', 'Make project private')
18+
.option('--branch <branch>', 'GitHub branch override')
19+
.option('--docs-path <path>', 'GitHub docs path')
20+
.option('--no-sync', 'Skip initial auto-sync')
21+
.action(async (options: CreateProjectOptions) => {
22+
try {
23+
await executeCreateProject(options);
24+
} catch (err) {
25+
if (err instanceof Error && err.message === 'cancelled') {
26+
console.log('\nProject creation cancelled.');
27+
return;
28+
}
29+
error(err instanceof Error ? err.message : String(err));
30+
process.exit(1);
31+
}
32+
});
33+
}
34+
35+
export async function executeCreateProject(options: CreateProjectOptions): Promise<void> {
36+
const client = await YavyApiClient.create();
37+
38+
let resolvedOptions = options;
39+
40+
if (needsInteractiveMode(options)) {
41+
resolvedOptions = await runInteractiveFlow(client, options);
42+
}
43+
44+
if (!resolvedOptions.url && !resolvedOptions.github) {
45+
throw new Error('Either --url or --github is required.');
46+
}
47+
48+
if (resolvedOptions.url && resolvedOptions.github) {
49+
throw new Error('Provide either --url or --github, not both.');
50+
}
51+
52+
const projects = await client.listProjects();
53+
const { slug: orgSlug, orgs } = await resolveOrg(projects, resolvedOptions.org);
54+
55+
if (!orgSlug) {
56+
const slugList = orgs.map((o) => ` - ${o.slug} (${o.name})`).join('\n');
57+
throw new Error(`Multiple organizations found. Please specify one with --org <slug>:\n${slugList}`);
58+
}
59+
60+
const payload = buildCreateProjectPayload(resolvedOptions);
61+
const response = await client.createProject(orgSlug, payload);
62+
63+
console.log(formatProjectCreated(response.data));
64+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { ApiProject, OrganizationInfo } from '@/api/client';
2+
3+
export function extractUniqueOrgs(projects: Array<{ organization: OrganizationInfo }>): OrganizationInfo[] {
4+
const seen = new Set<string>();
5+
const orgs: OrganizationInfo[] = [];
6+
7+
for (const project of projects) {
8+
if (!seen.has(project.organization.slug)) {
9+
seen.add(project.organization.slug);
10+
orgs.push(project.organization);
11+
}
12+
}
13+
14+
return orgs;
15+
}
16+
17+
export async function resolveOrg(
18+
projects: ApiProject[],
19+
orgFlag: string | undefined,
20+
): Promise<{ slug: string; orgs: OrganizationInfo[] }> {
21+
if (orgFlag) {
22+
return { slug: orgFlag, orgs: [] };
23+
}
24+
25+
const orgs = extractUniqueOrgs(projects);
26+
27+
if (orgs.length === 0) {
28+
throw new Error('No organizations found. Please specify an organization with --org <slug>.');
29+
}
30+
31+
if (orgs.length === 1) {
32+
return { slug: orgs[0].slug, orgs };
33+
}
34+
35+
return { slug: '', orgs };
36+
}

src/commands/project/types.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export interface CreateProjectOptions {
2+
url?: string;
3+
github?: string;
4+
org?: string;
5+
name?: string;
6+
public?: boolean;
7+
private?: boolean;
8+
branch?: string;
9+
docsPath?: string;
10+
noSync?: boolean;
11+
}
12+
13+
export interface CreateProjectPayload {
14+
url_discovery_mode: 'web_crawl' | 'github_repository';
15+
base_url?: string;
16+
github_repo?: string;
17+
github_branch?: string;
18+
github_docs_path?: string;
19+
name?: string;
20+
is_public?: boolean;
21+
no_sync?: boolean;
22+
}

0 commit comments

Comments
 (0)