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
29 changes: 29 additions & 0 deletions skills/capabilities-manager/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,35 @@ tools:
required: true
```

#### Default Argument Values (`default`)

Command tool arguments support an optional `default` field. When the caller omits an argument that has a default, the default value is used for placeholder substitution instead of raising a missing-argument error. Arguments with defaults are exposed as **optional** in the MCP schema (removed from `required` and annotated with the default value), so the AI only needs to supply them when overriding.

```yaml
tools:
- id: list_items
type: command
description: List items from the API
def:
run:
cmd: "curl -s 'https://api.example.com/items?limit={limit}&offset={offset}'"
args:
- name: limit
type: number
description: Page size
default: 25
- name: offset
type: number
description: Starting offset
default: 0
```

In this example, both `limit` and `offset` will default to `25` and `0` respectively when not provided. The agent can still override them by passing explicit values.

This is useful when:
- A parameter has a sensible default that rarely changes (e.g. page sizes, output formats)
- You want to expose a tool to the agent without requiring it to pass every argument

**Optional Init**: Add `init` block to run setup commands before first use:

```yaml
Expand Down
250 changes: 250 additions & 0 deletions src/cli/commands/__tests__/sh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
import { describe, it, expect } from 'bun:test';
import { slugify, parseInlineArgs, resolveArgs } from '../sh';
import type { ShellCommand } from '../sh';

function makeCommand(overrides: Partial<ShellCommand> = {}): ShellCommand {
return {
id: 'test-tool',
slug: 'test-tool',
type: 'command',
description: '',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
argSlugs: new Map(),
...overrides,
};
}

describe('parseInlineArgs', () => {
it('should parse key-value pairs', () => {
const result = parseInlineArgs(['--name', 'Alice', '--age', '30']);
expect(result).toEqual({ name: 'Alice', age: '30' });
});

it('should treat flags without values as boolean true', () => {
const result = parseInlineArgs(['--verbose']);
expect(result).toEqual({ verbose: 'true' });
});

it('should handle mixed flags and key-value pairs', () => {
const result = parseInlineArgs(['--name', 'Alice', '--verbose', '--count', '5']);
expect(result).toEqual({ name: 'Alice', verbose: 'true', count: '5' });
});

it('should return empty object for no args', () => {
const result = parseInlineArgs([]);
expect(result).toEqual({});
});

it('should ignore tokens not starting with --', () => {
const result = parseInlineArgs(['ignored', '--name', 'Alice']);
expect(result).toEqual({ name: 'Alice' });
});
});

describe('resolveArgs', () => {
it('should resolve slugified names to original names', () => {
const cmd = makeCommand({
argSlugs: new Map([['page-size', 'pageSize']]),
inputSchema: {
type: 'object',
properties: { pageSize: { type: 'number' } },
required: ['pageSize'],
},
});

const result = resolveArgs(cmd, { 'page-size': '25' });
expect(result).toEqual({ pageSize: 25 });
});

it('should convert number types', () => {
const cmd = makeCommand({
argSlugs: new Map([['limit', 'limit']]),
inputSchema: {
type: 'object',
properties: { limit: { type: 'number' } },
},
});

const result = resolveArgs(cmd, { limit: '50' });
expect(result).toEqual({ limit: 50 });
});

it('should convert boolean types', () => {
const cmd = makeCommand({
argSlugs: new Map([['verbose', 'verbose']]),
inputSchema: {
type: 'object',
properties: { verbose: { type: 'boolean' } },
},
});

expect(resolveArgs(cmd, { verbose: 'true' })).toEqual({ verbose: true });
expect(resolveArgs(cmd, { verbose: 'false' })).toEqual({ verbose: false });
expect(resolveArgs(cmd, { verbose: '0' })).toEqual({ verbose: false });
});

it('should keep string types as-is', () => {
const cmd = makeCommand({
argSlugs: new Map([['name', 'name']]),
inputSchema: {
type: 'object',
properties: { name: { type: 'string' } },
},
});

const result = resolveArgs(cmd, { name: 'Alice' });
expect(result).toEqual({ name: 'Alice' });
});

it('should pass through args not in slugs map', () => {
const cmd = makeCommand();
const result = resolveArgs(cmd, { unknown: 'value' });
expect(result).toEqual({ unknown: 'value' });
});
});

describe('slugify', () => {
it('should convert camelCase to kebab-case', () => {
expect(slugify('pageSize')).toBe('page-size');
expect(slugify('myLongVariableName')).toBe('my-long-variable-name');
});

it('should convert underscores to hyphens', () => {
expect(slugify('page_size')).toBe('page-size');
});

it('should handle already kebab-case names', () => {
expect(slugify('page-size')).toBe('page-size');
});

it('should handle PascalCase', () => {
expect(slugify('PageSize')).toBe('page-size');
});

it('should collapse multiple hyphens', () => {
expect(slugify('a--b')).toBe('a-b');
});

it('should strip leading/trailing hyphens', () => {
expect(slugify('-name-')).toBe('name');
});
});

describe('default argument merging in CLI context', () => {
it('should use defaults when args are not provided', () => {
const cmd = makeCommand({
argSlugs: new Map([['limit', 'limit'], ['offset', 'offset']]),
inputSchema: {
type: 'object',
properties: {
limit: { type: 'number', default: 25 },
offset: { type: 'number', default: 0 },
},
required: [],
},
defaults: { limit: 25, offset: 0 },
});

const rawArgs = parseInlineArgs([]);
const resolved = resolveArgs(cmd, rawArgs);

if (cmd.defaults) {
for (const [key, value] of Object.entries(cmd.defaults)) {
if (!(key in resolved)) {
resolved[key] = value;
}
}
}

expect(resolved).toEqual({ limit: 25, offset: 0 });
});

it('should let user-provided args override defaults', () => {
const cmd = makeCommand({
argSlugs: new Map([['limit', 'limit'], ['offset', 'offset']]),
inputSchema: {
type: 'object',
properties: {
limit: { type: 'number', default: 25 },
offset: { type: 'number', default: 0 },
},
required: [],
},
defaults: { limit: 25, offset: 0 },
});

const rawArgs = parseInlineArgs(['--limit', '100']);
const resolved = resolveArgs(cmd, rawArgs);

if (cmd.defaults) {
for (const [key, value] of Object.entries(cmd.defaults)) {
if (!(key in resolved)) {
resolved[key] = value;
}
}
}

expect(resolved).toEqual({ limit: 100, offset: 0 });
});

it('should work with no defaults defined', () => {
const cmd = makeCommand({
argSlugs: new Map([['name', 'name']]),
inputSchema: {
type: 'object',
properties: { name: { type: 'string' } },
required: ['name'],
},
});

const rawArgs = parseInlineArgs(['--name', 'test']);
const resolved = resolveArgs(cmd, rawArgs);

if (cmd.defaults) {
for (const [key, value] of Object.entries(cmd.defaults)) {
if (!(key in resolved)) {
resolved[key] = value;
}
}
}

expect(resolved).toEqual({ name: 'test' });
});

it('should not count defaulted args as missing required', () => {
const cmd = makeCommand({
argSlugs: new Map([['limit', 'limit']]),
inputSchema: {
type: 'object',
properties: {
limit: { type: 'number', default: 25 },
},
required: [],
},
defaults: { limit: 25 },
});

const rawArgs = parseInlineArgs([]);
const resolved = resolveArgs(cmd, rawArgs);

if (cmd.defaults) {
for (const [key, value] of Object.entries(cmd.defaults)) {
if (!(key in resolved)) {
resolved[key] = value;
}
}
}

const required: string[] = cmd.inputSchema?.required || [];
const missingRequired = required.filter(
(r: string) => !(slugify(r) in rawArgs) && !(r in rawArgs) && !(r in (cmd.defaults || {}))
);

expect(missingRequired).toEqual([]);
expect(resolved.limit).toBe(25);
});
});
18 changes: 13 additions & 5 deletions src/cli/commands/sh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@ interface ShellToolInfo {
defaults?: Record<string, any>;
}

interface ShellCommand {
export interface ShellCommand {
id: string;
slug: string;
type: 'command' | 'mcp';
description: string;
inputSchema: any;
/** Maps slugified arg name → original arg name */
argSlugs: Map<string, string>;
/** Default argument values (MCP tools only) */
/** Default argument values */
defaults?: Record<string, any>;
}

Expand Down Expand Up @@ -153,7 +153,7 @@ export function slugify(name: string): string {
.replace(/^-|-$/g, '');
}

function parseInlineArgs(tokens: string[]): Record<string, string> {
export function parseInlineArgs(tokens: string[]): Record<string, string> {
const result: Record<string, string> = {};
let i = 0;
while (i < tokens.length) {
Expand All @@ -175,7 +175,7 @@ function parseInlineArgs(tokens: string[]): Record<string, string> {
}

/** Resolve slugified arg names in the user's input to original names expected by the tool. */
function resolveArgs(cmd: ShellCommand, rawArgs: Record<string, string>): Record<string, any> {
export function resolveArgs(cmd: ShellCommand, rawArgs: Record<string, string>): Record<string, any> {
const resolved: Record<string, any> = {};
for (const [slug, value] of Object.entries(rawArgs)) {
const originalName = cmd.argSlugs.get(slug) ?? slug;
Expand Down Expand Up @@ -370,8 +370,16 @@ async function execCommand(
const rawArgs = parseInlineArgs(rawArgTokens);
const resolved = resolveArgs(cmd, rawArgs);

if (cmd.defaults) {
for (const [key, value] of Object.entries(cmd.defaults)) {
if (!(key in resolved)) {
resolved[key] = value;
}
}
}

const required: string[] = cmd.inputSchema?.required || [];
const missingRequired = required.filter((r) => !(slugify(r) in rawArgs) && !(r in rawArgs));
const missingRequired = required.filter((r) => !(slugify(r) in rawArgs) && !(r in rawArgs) && !(r in (cmd.defaults || {})));
if (missingRequired.length > 0) {
const props = cmd.inputSchema?.properties || {};
console.error(`Missing required parameter(s):\n`);
Expand Down
Loading
Loading