Skip to content

Commit df280e2

Browse files
committed
feat: switch to zip-based skill download
1 parent 747effc commit df280e2

File tree

8 files changed

+178
-131
lines changed

8 files changed

+178
-131
lines changed

src/api/client.test.ts

Lines changed: 44 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ describe('listProjects', () => {
6262
});
6363
});
6464

65-
describe('generateSkill', () => {
65+
describe('downloadSkill', () => {
6666
let client: YavyApiClient;
6767

6868
beforeEach(async () => {
@@ -71,58 +71,62 @@ describe('generateSkill', () => {
7171
client = await YavyApiClient.create();
7272
});
7373

74-
it('sends POST to correct path', async () => {
75-
const skill = { content: '# Skill', format: 'md', generated_at: '2024-01-01', token_count: 100 };
76-
vi.mocked(fetch).mockResolvedValue(createMockResponse(skill));
74+
it('sends GET to correct download path', async () => {
75+
const mockBuffer = new ArrayBuffer(10);
76+
vi.mocked(fetch).mockResolvedValue({
77+
ok: true,
78+
status: 200,
79+
arrayBuffer: () => Promise.resolve(mockBuffer),
80+
headers: new Headers(),
81+
} as Response);
7782

78-
await client.generateSkill('my-org', 'my-project');
83+
await client.downloadSkill('my-org', 'my-project');
7984

8085
expect(fetch).toHaveBeenLastCalledWith(
81-
'https://test.yavy.dev/api/v1/my-org/my-project/skill/generate',
82-
expect.objectContaining({ method: 'POST' }),
86+
'https://test.yavy.dev/api/v1/my-org/my-project/skill/download',
87+
expect.objectContaining({
88+
method: 'GET',
89+
headers: expect.objectContaining({
90+
Authorization: 'Bearer test-token',
91+
Accept: 'application/zip',
92+
}),
93+
}),
8394
);
8495
});
8596

86-
it('sends { force: true } body when force=true', async () => {
87-
vi.mocked(fetch).mockResolvedValue(createMockResponse({ content: '' }));
88-
89-
await client.generateSkill('org', 'proj', true);
97+
it('returns ArrayBuffer from response', async () => {
98+
const mockBuffer = new ArrayBuffer(10);
99+
vi.mocked(fetch).mockResolvedValue({
100+
ok: true,
101+
status: 200,
102+
arrayBuffer: () => Promise.resolve(mockBuffer),
103+
headers: new Headers(),
104+
} as Response);
90105

91-
const callArgs = vi.mocked(fetch).mock.calls[0];
92-
const opts = callArgs[1] as RequestInit;
93-
expect(JSON.parse(opts.body as string)).toEqual({ force: true });
94-
expect(opts.headers).toHaveProperty('Content-Type', 'application/json');
106+
const result = await client.downloadSkill('org', 'proj');
107+
expect(result).toBe(mockBuffer);
95108
});
96109

97-
it('sends no body when force=false', async () => {
98-
vi.mocked(fetch).mockResolvedValue(createMockResponse({ content: '' }));
99-
100-
await client.generateSkill('org', 'proj', false);
110+
it('throws on 401 response', async () => {
111+
vi.mocked(fetch).mockResolvedValue({
112+
ok: false,
113+
status: 401,
114+
json: () => Promise.resolve({}),
115+
headers: new Headers(),
116+
} as Response);
101117

102-
const callArgs = vi.mocked(fetch).mock.calls[0];
103-
const opts = callArgs[1] as RequestInit;
104-
expect(opts.body).toBeUndefined();
118+
await expect(client.downloadSkill('org', 'proj')).rejects.toThrow('Authentication expired');
105119
});
106-
});
107120

108-
describe('getSkill', () => {
109-
it('sends GET to correct path with auth header', async () => {
110-
vi.stubGlobal('fetch', vi.fn());
111-
vi.mocked(getAccessToken).mockResolvedValue('test-token');
112-
vi.mocked(fetch).mockResolvedValue(createMockResponse({ content: '# Skill' }));
113-
114-
const client = await YavyApiClient.create();
115-
await client.getSkill('my-org', 'my-project');
121+
it('throws error message from JSON body on non-ok response', async () => {
122+
vi.mocked(fetch).mockResolvedValue({
123+
ok: false,
124+
status: 422,
125+
json: () => Promise.resolve({ error: 'Project has no indexed content' }),
126+
headers: new Headers(),
127+
} as Response);
116128

117-
expect(fetch).toHaveBeenLastCalledWith(
118-
'https://test.yavy.dev/api/v1/my-org/my-project/skill',
119-
expect.objectContaining({
120-
method: 'GET',
121-
headers: expect.objectContaining({
122-
Authorization: 'Bearer test-token',
123-
}),
124-
}),
125-
);
129+
await expect(client.downloadSkill('org', 'proj')).rejects.toThrow('Project has no indexed content');
126130
});
127131
});
128132

src/api/client.ts

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,7 @@ export interface ApiProject {
1212
};
1313
pages_count: number;
1414
last_indexed_at: string | null;
15-
has_skill: boolean;
16-
}
17-
18-
export interface ApiSkill {
19-
content: string;
20-
format: string;
21-
generated_at: string;
22-
token_count: number;
23-
project: {
24-
name: string;
25-
slug: string;
26-
};
15+
has_indexed_content: boolean;
2716
}
2817

2918
export class YavyApiClient {
@@ -75,11 +64,25 @@ export class YavyApiClient {
7564
return result.data;
7665
}
7766

78-
async generateSkill(orgSlug: string, projectSlug: string, force = false): Promise<ApiSkill> {
79-
return this.request<ApiSkill>('POST', `/${orgSlug}/${projectSlug}/skill/generate`, force ? { force: true } : undefined);
80-
}
67+
async downloadSkill(orgSlug: string, projectSlug: string): Promise<ArrayBuffer> {
68+
const url = `${YAVY_BASE_URL}/api/v1/${orgSlug}/${projectSlug}/skill/download`;
69+
const response = await fetch(url, {
70+
method: 'GET',
71+
headers: {
72+
Authorization: `Bearer ${this.token}`,
73+
Accept: 'application/zip',
74+
},
75+
});
76+
77+
if (response.status === 401) {
78+
throw new Error('Authentication expired. Run `yavy login` to re-authenticate.');
79+
}
80+
81+
if (!response.ok) {
82+
const errorData = (await response.json().catch(() => ({}))) as { error?: string };
83+
throw new Error(errorData.error ?? `API request failed with status ${response.status}`);
84+
}
8185

82-
async getSkill(orgSlug: string, projectSlug: string): Promise<ApiSkill> {
83-
return this.request<ApiSkill>('GET', `/${orgSlug}/${projectSlug}/skill`);
86+
return response.arrayBuffer();
8487
}
8588
}

src/commands/generate.test.ts

Lines changed: 19 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest';
2-
import { writeFileSync } from 'node:fs';
32
import { generateCommand } from './generate.js';
43

54
vi.mock('../api/client.js', () => ({
@@ -9,8 +8,8 @@ vi.mock('../api/client.js', () => ({
98
}));
109

1110
vi.mock('../utils/paths.js', () => ({
12-
getSkillOutputPath: vi.fn(() => '/mock/output/SKILL.md'),
13-
ensureParentDir: vi.fn(),
11+
getSkillOutputDir: vi.fn(() => '/mock/output/my-project'),
12+
ensureDir: vi.fn(),
1413
}));
1514

1615
vi.mock('../utils/output.js', () => ({
@@ -20,6 +19,15 @@ vi.mock('../utils/output.js', () => ({
2019

2120
vi.mock('node:fs', () => ({
2221
writeFileSync: vi.fn(),
22+
readFileSync: vi.fn(() => Buffer.from('')),
23+
existsSync: vi.fn(() => false),
24+
readdirSync: vi.fn(() => []),
25+
rmSync: vi.fn(),
26+
mkdirSync: vi.fn(),
27+
}));
28+
29+
vi.mock('node:child_process', () => ({
30+
execFileSync: vi.fn(),
2331
}));
2432

2533
vi.mock('chalk', () => ({
@@ -38,20 +46,13 @@ vi.mock('ora', () => ({
3846
}));
3947

4048
import { YavyApiClient } from '../api/client.js';
41-
import { getSkillOutputPath, ensureParentDir } from '../utils/paths.js';
49+
import { getSkillOutputDir } from '../utils/paths.js';
4250
import { error, success } from '../utils/output.js';
4351

4452
function createMockClient() {
4553
return {
46-
generateSkill: vi.fn().mockResolvedValue({
47-
content: '# Skill Content',
48-
format: 'md',
49-
generated_at: '2024-01-01',
50-
token_count: 500,
51-
project: { name: 'My Project', slug: 'my-project' },
52-
}),
54+
downloadSkill: vi.fn().mockResolvedValue(new ArrayBuffer(100)),
5355
listProjects: vi.fn(),
54-
getSkill: vi.fn(),
5556
};
5657
}
5758

@@ -85,24 +86,13 @@ describe('generateCommand', () => {
8586
expect(process.exit).toHaveBeenCalledWith(1);
8687
});
8788

88-
it('calls generateSkill with correct org/project/force args', async () => {
89-
const mockClient = createMockClient();
90-
vi.mocked(YavyApiClient.create).mockResolvedValue(mockClient as unknown as YavyApiClient);
91-
92-
await run(['my-org/my-project', '--force']);
93-
94-
expect(mockClient.generateSkill).toHaveBeenCalledWith('my-org', 'my-project', true);
95-
});
96-
97-
it('writes skill content to computed output path', async () => {
89+
it('calls downloadSkill with correct org/project args', async () => {
9890
const mockClient = createMockClient();
9991
vi.mocked(YavyApiClient.create).mockResolvedValue(mockClient as unknown as YavyApiClient);
10092

10193
await run(['my-org/my-project']);
10294

103-
expect(ensureParentDir).toHaveBeenCalledWith('/mock/output/SKILL.md');
104-
expect(writeFileSync).toHaveBeenCalledWith('/mock/output/SKILL.md', '# Skill Content', 'utf-8');
105-
expect(success).toHaveBeenCalled();
95+
expect(mockClient.downloadSkill).toHaveBeenCalledWith('my-org', 'my-project');
10696
});
10797

10898
it('outputs JSON when --json flag is used', async () => {
@@ -114,8 +104,8 @@ describe('generateCommand', () => {
114104
const logCall = vi.mocked(console.log).mock.calls[0][0] as string;
115105
const parsed = JSON.parse(logCall);
116106
expect(parsed).toHaveProperty('path');
117-
expect(parsed).toHaveProperty('project');
118-
expect(parsed).toHaveProperty('token_count');
107+
expect(parsed).toHaveProperty('skill_file');
108+
expect(parsed).toHaveProperty('reference_count');
119109
});
120110

121111
it('shows error and exits on API failure', async () => {
@@ -127,13 +117,13 @@ describe('generateCommand', () => {
127117
expect(process.exit).toHaveBeenCalledWith(1);
128118
});
129119

130-
it('passes options to getSkillOutputPath', async () => {
120+
it('passes options to getSkillOutputDir', async () => {
131121
const mockClient = createMockClient();
132122
vi.mocked(YavyApiClient.create).mockResolvedValue(mockClient as unknown as YavyApiClient);
133123

134124
await run(['my-org/my-project', '--global']);
135125

136-
expect(getSkillOutputPath).toHaveBeenCalledWith(
126+
expect(getSkillOutputDir).toHaveBeenCalledWith(
137127
'my-project',
138128
expect.objectContaining({ global: true }),
139129
);

src/commands/generate.ts

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,51 @@
11
import chalk from 'chalk';
22
import { Command } from 'commander';
3-
import { writeFileSync } from 'node:fs';
3+
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4+
import { tmpdir } from 'node:os';
5+
import { join } from 'node:path';
6+
import { execFileSync } from 'node:child_process';
47
import ora from 'ora';
58
import { YavyApiClient } from '../api/client.js';
69
import { error, success } from '../utils/output.js';
7-
import { ensureParentDir, getSkillOutputPath } from '../utils/paths.js';
10+
import { ensureDir, getSkillOutputDir } from '../utils/paths.js';
811

912
export function generateCommand(): Command {
1013
return new Command('generate')
11-
.description('Generate an AI skill for a project')
14+
.description('Download an AI skill for a project')
1215
.argument('<org/project>', 'Organization and project slug (e.g., my-org/my-project)')
1316
.option('--global', 'Save to global skills directory (~/.claude/skills/)')
14-
.option('--output <path>', 'Custom output path')
15-
.option('--force', 'Force regeneration even if cached')
17+
.option('--output <path>', 'Custom output directory')
1618
.option('--json', 'Output as JSON')
17-
.action(async (slug: string, options: { global?: boolean; output?: string; force?: boolean; json?: boolean }) => {
19+
.action(async (slug: string, options: { global?: boolean; output?: string; json?: boolean }) => {
1820
const parts = slug.split('/');
1921
if (parts.length !== 2) {
2022
error('Invalid slug format. Use: org-slug/project-slug');
2123
process.exit(1);
2224
}
2325

2426
const [orgSlug, projectSlug] = parts;
25-
const spinner = options.json ? null : ora(`Generating skill for ${chalk.bold(slug)}...`).start();
27+
const spinner = options.json ? null : ora(`Downloading skill for ${chalk.bold(slug)}...`).start();
2628

2729
try {
2830
const client = await YavyApiClient.create();
29-
const skill = await client.generateSkill(orgSlug, projectSlug, options.force);
31+
const zipBuffer = await client.downloadSkill(orgSlug, projectSlug);
3032

31-
const outputPath = getSkillOutputPath(projectSlug, options);
32-
ensureParentDir(outputPath);
33-
writeFileSync(outputPath, skill.content, 'utf-8');
33+
const outputDir = getSkillOutputDir(projectSlug, options);
34+
extractZip(Buffer.from(zipBuffer), outputDir, projectSlug);
3435

3536
spinner?.stop();
3637

38+
const skillPath = join(outputDir, 'SKILL.md');
39+
const refsDir = join(outputDir, 'references');
40+
const refCount = existsSync(refsDir) ? readdirSync(refsDir).length : 0;
41+
3742
if (options.json) {
3843
console.log(
3944
JSON.stringify(
4045
{
41-
path: outputPath,
42-
project: skill.project.name,
43-
token_count: skill.token_count,
44-
generated_at: skill.generated_at,
46+
path: outputDir,
47+
skill_file: skillPath,
48+
reference_count: refCount,
4549
},
4650
null,
4751
2,
@@ -50,12 +54,53 @@ export function generateCommand(): Command {
5054
return;
5155
}
5256

53-
success(`Generated skill for ${chalk.bold(skill.project.name)} (${skill.token_count.toLocaleString()} tokens)`);
54-
console.log(` ${chalk.dim(outputPath)}`);
57+
success(`Downloaded skill for ${chalk.bold(slug)} (${refCount} reference files)`);
58+
console.log(` ${chalk.dim(skillPath)}`);
5559
} catch (err) {
5660
spinner?.stop();
5761
error(err instanceof Error ? err.message : String(err));
5862
process.exit(1);
5963
}
6064
});
6165
}
66+
67+
/**
68+
* Extract a zip buffer to the output directory.
69+
* Strips the top-level project-slug prefix from zip entries.
70+
*/
71+
function extractZip(zipBuffer: Buffer, outputDir: string, projectSlug: string): void {
72+
const tmpZip = join(tmpdir(), `yavy-skill-${Date.now()}.zip`);
73+
const tmpExtract = join(tmpdir(), `yavy-extract-${Date.now()}`);
74+
75+
try {
76+
writeFileSync(tmpZip, zipBuffer);
77+
ensureDir(tmpExtract);
78+
79+
// execFileSync is safe from shell injection (no shell invoked)
80+
execFileSync('unzip', ['-o', tmpZip, '-d', tmpExtract], { stdio: 'pipe' });
81+
82+
const prefixDir = join(tmpExtract, projectSlug);
83+
const sourceDir = existsSync(prefixDir) ? prefixDir : tmpExtract;
84+
85+
ensureDir(outputDir);
86+
copyDirRecursive(sourceDir, outputDir);
87+
} finally {
88+
rmSync(tmpZip, { force: true });
89+
rmSync(tmpExtract, { recursive: true, force: true });
90+
}
91+
}
92+
93+
function copyDirRecursive(src: string, dest: string): void {
94+
ensureDir(dest);
95+
96+
for (const entry of readdirSync(src, { withFileTypes: true })) {
97+
const srcPath = join(src, entry.name);
98+
const destPath = join(dest, entry.name);
99+
100+
if (entry.isDirectory()) {
101+
copyDirRecursive(srcPath, destPath);
102+
} else {
103+
writeFileSync(destPath, readFileSync(srcPath));
104+
}
105+
}
106+
}

0 commit comments

Comments
 (0)