Skip to content
Open
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
84 changes: 83 additions & 1 deletion packages/cli/src/commands/skills.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
Expand Down Expand Up @@ -103,6 +103,88 @@ describe('skills install command', () => {
});
});

describe('skills new command', () => {
let stdout: string[];
let tempDir: string;
Comment on lines +106 to +108
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 tempDir is declared as string but is never initialised, so its runtime value is undefined until skillFixture() runs. Typing it as string | undefined makes the intent explicit and prevents the compiler from masking accidental reads before assignment.

Suggested change
describe('skills new command', () => {
let stdout: string[];
let tempDir: string;
describe('skills new command', () => {
let stdout: string[];
let tempDir: string | undefined;

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


beforeEach(() => {
stdout = [];
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
stdout.push(args.map(String).join(' '));
});
});

afterEach(() => {
vi.restoreAllMocks();
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = '';
}
});

function skillFixture(): { skillFile: string; manifestFile: string } {
tempDir = mkdtempSync(join(tmpdir(), 'sh1pt-skills-new-'));
const skillFile = join(tempDir, 'SKILL.md');
const manifestFile = join(tempDir, 'sh1pt.skill.json');
writeFileSync(skillFile, '---\nname: paid-helper\ndescription: Paid helper skill\n---\n', 'utf8');
return { skillFile, manifestFile };
}

it('preserves a valid integer price in the manifest and marketplace commands', async () => {
const { skillFile, manifestFile } = skillFixture();
const newCmd = skillsCmd.commands.find((c) => c.name() === 'new');
expect(newCmd).toBeDefined();

await newCmd?.parseAsync(['--skill-file', skillFile, '--out', manifestFile, '--price', '42'], { from: 'user' });

const manifest = JSON.parse(readFileSync(manifestFile, 'utf8'));
expect(manifest.price).toBe(42);
expect(manifest.marketplaces.ugig.command).toContain('--price 42');
});

it('preserves zero as a valid price', async () => {
const { skillFile, manifestFile } = skillFixture();
const newCmd = skillsCmd.commands.find((c) => c.name() === 'new');
expect(newCmd).toBeDefined();

await newCmd?.parseAsync(['--skill-file', skillFile, '--out', manifestFile, '--price', '0'], { from: 'user' });

const manifest = JSON.parse(readFileSync(manifestFile, 'utf8'));
expect(manifest.price).toBe(0);
expect(manifest.marketplaces.ugig.command).toContain('--price 0');
});

it('rejects negative prices without writing a manifest', async () => {
const { skillFile, manifestFile } = skillFixture();
const newCmd = skillsCmd.commands.find((c) => c.name() === 'new');
expect(newCmd).toBeDefined();

await expect(newCmd?.parseAsync(['--skill-file', skillFile, '--out', manifestFile, '--price', '-5'], { from: 'user' }))
.rejects.toThrow('--price must be a non-negative integer in sats');
expect(existsSync(manifestFile)).toBe(false);
});

it('rejects fractional prices without truncating them', async () => {
const { skillFile, manifestFile } = skillFixture();
const newCmd = skillsCmd.commands.find((c) => c.name() === 'new');
expect(newCmd).toBeDefined();

await expect(newCmd?.parseAsync(['--skill-file', skillFile, '--out', manifestFile, '--price', '1.9'], { from: 'user' }))
.rejects.toThrow('--price must be a non-negative integer in sats');
expect(existsSync(manifestFile)).toBe(false);
});

it('rejects prices larger than Number.MAX_SAFE_INTEGER', async () => {
const { skillFile, manifestFile } = skillFixture();
const newCmd = skillsCmd.commands.find((c) => c.name() === 'new');
expect(newCmd).toBeDefined();

await expect(newCmd?.parseAsync(['--skill-file', skillFile, '--out', manifestFile, '--price', '9007199254740992'], { from: 'user' }))
.rejects.toThrow('--price must be a safe non-negative integer in sats');
expect(existsSync(manifestFile)).toBe(false);
});
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.

describe('skills marketplaces --json', () => {
let stdout: string[];

Expand Down
19 changes: 16 additions & 3 deletions packages/cli/src/commands/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,17 @@ function slugify(s: string): string {
}
function q(s: string): string { return JSON.stringify(s); }
async function exists(path: string): Promise<boolean> { try { await access(path); return true; } catch { return false; } }
function parsePriceSats(value: string): number {
const normalized = value.trim();
if (!/^\d+$/.test(normalized)) {
throw new Error('--price must be a non-negative integer in sats');
}
const price = Number(normalized);
if (!Number.isSafeInteger(price)) {
throw new Error('--price must be a safe non-negative integer in sats');
}
Comment on lines +87 to +89
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The unsafe-integer branch emits a different message ("a safe non-negative integer") than the regex branch ("a non-negative integer"). Both express the same user-facing constraint, so the messages should be identical — otherwise users who hit the large-number path see an unexplained "safe" qualifier that has no meaning outside JavaScript internals.

Suggested change
if (!Number.isSafeInteger(price)) {
throw new Error('--price must be a safe non-negative integer in sats');
}
if (!Number.isSafeInteger(price)) {
throw new Error('--price must be a non-negative integer in sats');
}

return price;
}
function frontmatterValue(text: string, key: string): string | undefined {
const m = text.match(new RegExp(`^${key}:\\s*["']?([^"'\\n]+)["']?\\s*$`, 'm'));
return m?.[1]?.trim();
Expand Down Expand Up @@ -349,17 +360,19 @@ skillsCmd
const name = slugify(opts.name ?? inferred.name ?? basename(dirname(skillFile)));
const title = opts.title ?? inferred.title ?? name;
const description = opts.description ?? inferred.description ?? `Agent skill: ${title}`;
const tags = opts.tags.split(',').map(t => t.trim()).filter(Boolean).slice(0, 10);
const price = parsePriceSats(opts.price);
const manifest: SkillManifest = {
name,
title,
description,
tagline: opts.tagline,
category: opts.category,
tags: opts.tags.split(',').map(t => t.trim()).filter(Boolean).slice(0, 10),
price: Number.parseInt(opts.price, 10) || 0,
tags,
price,
skillFile,
sourceUrl: opts.sourceUrl,
marketplaces: Object.fromEntries(MARKETPLACES.map(mp => [mp.id, { enabled: true, status: 'pending', command: 'command' in mp && mp.command ? mp.command({ name, title, description, tagline: opts.tagline, category: opts.category, tags: opts.tags.split(',').map(t => t.trim()).filter(Boolean).slice(0, 10), price: Number.parseInt(opts.price, 10) || 0, skillFile, sourceUrl: opts.sourceUrl, marketplaces: {} }) : undefined, note: 'note' in mp ? mp.note : undefined }])) as SkillManifest['marketplaces'],
marketplaces: Object.fromEntries(MARKETPLACES.map(mp => [mp.id, { enabled: true, status: 'pending', command: 'command' in mp && mp.command ? mp.command({ name, title, description, tagline: opts.tagline, category: opts.category, tags, price, skillFile, sourceUrl: opts.sourceUrl, marketplaces: {} }) : undefined, note: 'note' in mp ? mp.note : undefined }])) as SkillManifest['marketplaces'],
};
await mkdir(dirname(resolve(opts.out)), { recursive: true });
await saveManifest(opts.out, manifest);
Expand Down