feat: add pi-extension/cli-anything with updated index.ts, install.sh and tests

This commit is contained in:
Sergey Kostrov
2026-04-05 16:34:06 +03:00
parent 2e7939694d
commit 7a6e8665e3
5 changed files with 1105 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
# CLI-Anything Extension for Pi Coding Agent
This directory contains the Pi Coding Agent extension for CLI-Anything, enabling AI agents to build powerful, stateful CLI interfaces for any GUI application.
## Overview
The CLI-Anything Pi extension provides 5 slash commands that inject the HARNESS.md methodology and command specifications into the agent session. This enables the agent to build CLI harnesses for any software with a codebase.
## Installation
### Option 1: Global Install (Recommended)
Install the extension globally so `/cli-anything` commands are available in **all** Pi projects:
```bash
cd CLI-Anything/.pi-extension/extensions/cli-anything
bash install.sh
```
To uninstall:
```bash
bash install.sh --uninstall
```
### Option 2: Project-Local
Pi auto-discovers extensions in `.pi-extension/extensions/` within a project. If you're working inside the CLI-Anything repo, the extension is already active — no installation needed.
### Verify
After installing, run `/reload` in Pi or restart Pi. Then type `/cli-anything` to verify the command is available.
## Commands
| Command | Description |
|---------|-------------|
| `/cli-anything <path-or-repo>` | Build a complete CLI harness for any GUI application |
| `/cli-anything:refine <path> [focus]` | Refine an existing CLI harness to improve coverage |
| `/cli-anything:test <path-or-repo>` | Run tests for a CLI harness and update TEST.md |
| `/cli-anything:validate <path-or-repo>` | Validate a CLI harness against HARNESS.md standards |
| `/cli-anything:list [options]` | List all CLI-Anything tools (installed and generated) |
## Usage Examples
### Build a CLI for GIMP
```
/cli-anything ./gimp
```
### Build from a GitHub repository
```
/cli-anything https://github.com/blender/blender
```
### Refine an existing harness
```
/cli-anything:refine ./gimp "batch processing and filters"
```
### List all installed CLIs
```
/cli-anything:list
```
## Extension Structure
```
.pi-extension/extensions/cli-anything/
├── index.ts # Main extension entry point
├── README.md # This file
├── commands/ # Command specifications
│ ├── cli-anything.md # Main build command
│ ├── refine.md # Refinement command
│ ├── test.md # Test runner command
│ ├── validate.md # Validation command
│ └── list.md # List tools command
├── guides/ # Detailed implementation guides
│ ├── filter-translation.md
│ ├── mcp-backend.md
│ ├── pypi-publishing.md
│ ├── session-locking.md
│ ├── skill-generation.md
│ └── timecode-precision.md
├── scripts/ # Utility scripts
│ └── skill_generator.py # SKILL.md generator
├── templates/ # Templates
│ └── SKILL.md.template # Skill definition template
└── tests/ # Test suite
├── test_extension.test.ts # Command registration tests
└── test_skill_generator.py # Skill generator tests
```
> **Note:** HARNESS.md is NOT duplicated here. The extension reads it directly
> from `cli-anything-plugin/HARNESS.md` (canonical source) using a relative path.
> When installed globally via `install.sh`, a copy is placed alongside the extension.
## How It Works
1. **Command Registration**: The extension registers 5 slash commands with Pi's Extension API
2. **Context Injection**: When a command is invoked, it reads HARNESS.md and the relevant command spec
3. **Message Construction**: Builds a comprehensive message with methodology, specs, and user arguments
4. **Agent Execution**: Injects the message into the agent session via `pi.sendUserMessage()`
5. **Path Remapping**: Automatically remaps container paths to local system paths
## Path Remapping
The extension handles path remapping between the containerized environment (referenced in HARNESS.md) and the local system:
| Container Path | Local Path |
|----------------|------------|
| `/root/cli-anything/<software>/` | Current working directory |
| `cli-anything-plugin/repl_skin.py` | Resolved from `cli-anything-plugin/` (single source of truth) |
| `~/.claude/plugins/cli-anything/` | `<extension>/` |
## Development
To modify or extend this extension:
1. Edit `index.ts` for command behavior changes
2. Edit files in `commands/` for command specification changes
3. Edit `cli-anything-plugin/HARNESS.md` for methodology changes (the canonical source)
4. Edit `guides/` for implementation guide changes
## Dependencies
- `@mariozechner/pi-coding-agent` - Pi Extension API
- Node.js built-in modules: `fs`, `path`, `url`
## License
MIT License - See the main CLI-Anything repository for full license details.
## See Also
- [CLI-Anything Main Repository](https://github.com/HKUDS/CLI-Anything)
- [CLI-Hub](https://hkuds.github.io/CLI-Anything/) - Browse all community CLIs
- [CONTRIBUTING.md](../../CONTRIBUTING.md) - Contribution guidelines
+186
View File
@@ -0,0 +1,186 @@
/**
* CLI-Anything Extension for Pi Coding Agent
*
* Provides 5 slash commands that inject HARNESS.md methodology + command specs
* into the agent session via pi.sendUserMessage(), enabling the agent to build
* CLI harnesses for any GUI application.
*
* Commands:
* /cli-anything <path-or-repo> - Build a complete CLI harness
* /cli-anything:refine <path> [focus] - Refine an existing CLI harness
* /cli-anything:test <path-or-repo> - Run tests for a CLI harness
* /cli-anything:validate <path-or-repo> - Validate a CLI harness
* /cli-anything:list [options] - List all CLI-Anything tools
*
* Asset files are self-contained in the extension directory.
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
// Resolve extension directory for asset loading
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* Read an asset file relative to the extension directory.
* All assets must be present alongside index.ts (via install.sh or manual copy).
*/
function readAsset(...paths: string[]): string {
const fullPath = join(__dirname, ...paths);
try {
return readFileSync(fullPath, "utf-8");
} catch (err) {
throw new Error(
`CLI-Anything extension: failed to read asset "${join(...paths)}" at "${fullPath}": ${err instanceof Error ? err.message : String(err)}`
);
}
}
/**
* Construct the message payload injected into the agent session.
* Bundles HARNESS.md + command spec + user args into a single user message.
*/
function buildCommandMessage(
commandName: string,
commandMd: string,
userArgs: string,
): string {
const harnessMd = readAsset("HARNESS.md");
const guidesDir = join(__dirname, "guides");
const scriptsDir = join(__dirname, "scripts");
const templatesDir = join(__dirname, "templates");
return `[CLI-Anything Command: ${commandName}]
## CRITICAL: HARNESS.md — Read First
${harnessMd}
## Command Specification: ${commandName}
${commandMd}
## User Arguments
\`${userArgs}\`
## Extension Asset Paths
The following resources are available on this system. Use the \`read\` tool to access them when needed:
- Guides directory: \`${guidesDir}/\` — when HARNESS.md references guides (e.g. "See guides/session-locking.md"), read them from here
- Scripts directory: \`${scriptsDir}/\` — contains \`skill_generator.py\`
- Templates directory: \`${templatesDir}/\` — contains \`SKILL.md.template\`
## Path Remapping Rules
The command specs and HARNESS.md were written for a containerized environment. Apply these remapping rules:
1. \`/root/cli-anything/<software>/\` → use the current working directory (\`cwd\`). The software source is wherever the user specified in the arguments.
2. \`cli-anything-plugin/repl_skin.py\` → use \`${scriptsDir}/repl_skin.py\`
3. \`cli-anything-plugin/skill_generator.py\` → use \`${scriptsDir}/skill_generator.py\`
4. \`~/.claude/plugins/cli-anything/\` → use \`${__dirname}/\`
5. All relative paths in HARNESS.md (e.g. \`guides/...\`, \`templates/...\`) resolve against the asset paths above, NOT the working directory.
---
You are executing the /${commandName} command. Follow the HARNESS.md methodology and command specification precisely. Read HARNESS.md FIRST before taking any action. When you encounter references to guides, scripts, or templates, read them from the directories listed above. Apply the Path Remapping Rules for any hardcoded paths found in the specs.`;
}
/**
* Inject command context into the agent session via sendUserMessage.
* This triggers a full agent turn with access to all tools.
*/
function injectCommandContext(
pi: ExtensionAPI,
commandName: string,
commandMdPath: string,
userArgs: string,
): void {
const commandMd = readAsset("commands", commandMdPath);
const message = buildCommandMessage(commandName, commandMd, userArgs);
pi.sendUserMessage(message);
}
export default function cliAnythingExtension(pi: ExtensionAPI) {
// ─── /cli-anything <path-or-repo> ─────────────────────────────────
pi.registerCommand("cli-anything", {
description: "Build a complete CLI harness for any GUI application",
handler: async (args, ctx) => {
const trimmed = args.trim();
if (!trimmed) {
ctx.ui.notify(
"Usage: /cli-anything <path-or-repo>\n\nProvide a local path to software source code or a GitHub repository URL.",
"warning",
);
return;
}
injectCommandContext(pi, "cli-anything", "cli-anything.md", trimmed);
},
});
// ─── /cli-anything:refine <path> [focus] ──────────────────────────
pi.registerCommand("cli-anything:refine", {
description: "Refine an existing CLI harness to improve coverage",
handler: async (args, ctx) => {
const trimmed = args.trim();
if (!trimmed) {
ctx.ui.notify(
'Usage: /cli-anything:refine <software-path> [focus]\n\nExample: /cli-anything:refine /home/user/gimp "batch processing filters"',
"warning",
);
return;
}
injectCommandContext(pi, "cli-anything:refine", "refine.md", trimmed);
},
});
// ─── /cli-anything:test <path-or-repo> ────────────────────────────
pi.registerCommand("cli-anything:test", {
description: "Run tests for a CLI harness and update TEST.md",
handler: async (args, ctx) => {
const trimmed = args.trim();
if (!trimmed) {
ctx.ui.notify(
"Usage: /cli-anything:test <software-path-or-repo>\n\nProvide a local path to software source code or a GitHub repository URL.",
"warning",
);
return;
}
injectCommandContext(pi, "cli-anything:test", "test.md", trimmed);
},
});
// ─── /cli-anything:validate <path-or-repo> ────────────────────────
pi.registerCommand("cli-anything:validate", {
description: "Validate a CLI harness against HARNESS.md standards",
handler: async (args, ctx) => {
const trimmed = args.trim();
if (!trimmed) {
ctx.ui.notify(
"Usage: /cli-anything:validate <software-path-or-repo>\n\nProvide a local path to software source code or a GitHub repository URL.",
"warning",
);
return;
}
injectCommandContext(pi, "cli-anything:validate", "validate.md", trimmed);
},
});
// ─── /cli-anything:list [--path] [--depth] [--json] ───────────────
pi.registerCommand("cli-anything:list", {
description: "List all CLI-Anything tools (installed and generated)",
getArgumentCompletions: (prefix: string) => {
const flags = ["--json", "--path ", "--depth "];
const filtered = flags.filter((f) => f.startsWith(prefix));
return filtered.length > 0 ? filtered.map((f) => ({ value: f, label: f })) : null;
},
handler: async (args, ctx) => {
// Parse optional flags, pass everything to the agent
const trimmed = args.trim();
// No validation needed — the agent handles --path, --depth, --json parsing
injectCommandContext(pi, "cli-anything:list", "list.md", trimmed || "(no arguments — scan current directory)");
},
});
}
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# install.sh — Install CLI-Anything extension for Pi Coding Agent globally.
#
# Copies the extension into Pi's global extensions directory so the
# /cli-anything commands are available in ALL projects.
#
# Usage:
# bash install.sh # Install
# bash install.sh --uninstall # Uninstall
#
# After installing, run '/reload' in Pi or restart Pi to activate.
set -euo pipefail
# ─── Paths ─────────────────────────────────────────────────────────────
TARGET_DIR="$HOME/.pi/agent/extensions/cli-anything"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Find repo root reliably — use git, fall back to searching upward
REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null)" || {
dir="$SCRIPT_DIR"
while [ "$dir" != "/" ]; do
if [ -f "$dir/.git" ] || [ -d "$dir/.git" ] || [ -f "$dir/CONTRIBUTING.md" ]; then
REPO_ROOT="$dir"
break
fi
dir="$(dirname "$dir")"
done
if [ -z "${REPO_ROOT:-}" ]; then
echo "Error: Cannot determine repo root. Run this script from inside the CLI-Anything repository."
exit 1
fi
}
# ─── Uninstall ─────────────────────────────────────────────────────────
if [ "${1:-}" = "--uninstall" ]; then
if [ -d "$TARGET_DIR" ]; then
rm -rf "$TARGET_DIR"
echo "✓ CLI-Anything extension uninstalled from $TARGET_DIR"
else
echo "Extension not found at $TARGET_DIR (already uninstalled)"
fi
exit 0
fi
# ─── Pre-flight checks ────────────────────────────────────────────────
if [ ! -f "$SCRIPT_DIR/index.ts" ]; then
echo "Error: Cannot find index.ts in $SCRIPT_DIR"
echo "Make sure you're running this script from the extension directory."
exit 1
fi
HARNESS_SRC="$REPO_ROOT/cli-anything-plugin/HARNESS.md"
if [ ! -f "$HARNESS_SRC" ]; then
echo "Warning: HARNESS.md not found at $HARNESS_SRC"
echo "The extension will still be installed but may not function correctly."
echo ""
fi
# ─── Install ───────────────────────────────────────────────────────────
echo "Installing CLI-Anything extension for Pi Coding Agent..."
echo ""
# Create target directories
mkdir -p "$TARGET_DIR/commands"
mkdir -p "$TARGET_DIR/guides"
mkdir -p "$TARGET_DIR/scripts"
mkdir -p "$TARGET_DIR/templates"
# Copy extension entry point
cp "$SCRIPT_DIR/index.ts" "$TARGET_DIR/"
# Copy command specifications from the canonical location
COMMANDS_SRC="$REPO_ROOT/cli-anything-plugin/commands"
if [ -d "$COMMANDS_SRC" ]; then
cp "$COMMANDS_SRC/"*.md "$TARGET_DIR/commands/"
echo "✓ commands copied from $COMMANDS_SRC"
fi
# Copy guides from the canonical location
GUIDES_SRC="$REPO_ROOT/cli-anything-plugin/guides"
if [ -d "$GUIDES_SRC" ]; then
cp "$GUIDES_SRC/"*.md "$TARGET_DIR/guides/"
echo "✓ guides copied from $GUIDES_SRC"
fi
# Copy templates from the canonical location
TEMPLATES_SRC="$REPO_ROOT/cli-anything-plugin/templates"
if [ -d "$TEMPLATES_SRC" ]; then
cp "$TEMPLATES_SRC/"* "$TARGET_DIR/templates/"
echo "✓ templates copied from $TEMPLATES_SRC"
fi
# Copy HARNESS.md from the canonical location (so the extension can find it locally)
if [ -f "$HARNESS_SRC" ]; then
cp "$HARNESS_SRC" "$TARGET_DIR/HARNESS.md"
echo "✓ HARNESS.md copied from $HARNESS_SRC"
fi
# Copy repl_skin.py from the canonical location
REPL_SKIN_SRC="$REPO_ROOT/cli-anything-plugin/repl_skin.py"
if [ -f "$REPL_SKIN_SRC" ]; then
cp "$REPL_SKIN_SRC" "$TARGET_DIR/scripts/repl_skin.py"
echo "✓ repl_skin.py copied from $REPL_SKIN_SRC"
fi
# Copy skill_generator.py from the canonical location
SKILL_GEN_SRC="$REPO_ROOT/cli-anything-plugin/skill_generator.py"
if [ -f "$SKILL_GEN_SRC" ]; then
cp "$SKILL_GEN_SRC" "$TARGET_DIR/scripts/skill_generator.py"
echo "✓ skill_generator.py copied from $SKILL_GEN_SRC"
fi
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " ✓ CLI-Anything extension installed globally!"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo " Location: $TARGET_DIR"
echo ""
echo " Available commands:"
echo " /cli-anything <path-or-repo> Build a CLI harness"
echo " /cli-anything:refine <path> [focus] Refine a harness"
echo " /cli-anything:test <path-or-repo> Test a harness"
echo " /cli-anything:validate <path> Validate a harness"
echo " /cli-anything:list [options] List all CLI tools"
echo ""
echo " Run '/reload' in Pi or restart Pi to activate."
echo ""
@@ -0,0 +1,285 @@
/**
* Tests for CLI-Anything Pi extension command registration.
*
* Verifies that all 5 commands are registered, handlers invoke
* sendUserMessage with the expected content, and edge cases are handled.
*
* Run with: npx vitest run tests/test_extension.test.ts
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// ─── Mock Pi Extension API ────────────────────────────────────────────
function createMockPi() {
const registeredCommands: Array<{
name: string;
options: { description: string; handler: Function };
}> = [];
const sentMessages: string[] = [];
return {
registerCommand: vi.fn((name: string, options: any) => {
registeredCommands.push({ name, options });
}),
sendUserMessage: vi.fn((msg: string) => {
sentMessages.push(msg);
}),
registeredCommands,
sentMessages,
};
}
// ─── Mock file system for readAsset ───────────────────────────────────
//
// The extension reads assets from __dirname via readFileSync.
// We mock node:fs so that readAsset returns predictable content regardless
// of where the test is running (CI, local, etc.).
const MOCK_HARNESS = "# HARNESS.md Mock\nTest harness content.";
const MOCK_COMMANDS: Record<string, string> = {
"cli-anything.md": "# cli-anything command mock",
"refine.md": "# refine command mock",
"test.md": "# test command mock",
"validate.md": "# validate command mock",
"list.md": "# list command mock",
};
vi.mock("node:fs", () => ({
readFileSync: (path: string, encoding: string) => {
const p = String(path);
if (p.endsWith("HARNESS.md")) return MOCK_HARNESS;
for (const [name, content] of Object.entries(MOCK_COMMANDS)) {
if (p.endsWith(join("commands", name))) return content;
}
throw new Error(`Mock readFileSync: file not found: ${p}`);
},
}));
// ─── Tests ────────────────────────────────────────────────────────────
describe("CLI-Anything Extension", () => {
let mockPi: ReturnType<typeof createMockPi>;
beforeEach(() => {
mockPi = createMockPi();
});
async function loadExtension() {
// Bust module cache so each test gets a fresh import
const extPath = join(__dirname, "..", "index.ts") + "?t=" + Date.now();
const mod = await import(extPath);
return mod;
}
it("should export a default function", async () => {
const mod = await loadExtension();
expect(typeof mod.default).toBe("function");
});
it("should register exactly 5 commands", async () => {
const mod = await loadExtension();
mod.default(mockPi);
expect(mockPi.registerCommand).toHaveBeenCalledTimes(5);
});
it("should register all expected command names", async () => {
const mod = await loadExtension();
mod.default(mockPi);
const names = mockPi.registeredCommands.map((c) => c.name);
expect(names).toContain("cli-anything");
expect(names).toContain("cli-anything:refine");
expect(names).toContain("cli-anything:test");
expect(names).toContain("cli-anything:validate");
expect(names).toContain("cli-anything:list");
});
it("each command should have a description and handler", async () => {
const mod = await loadExtension();
mod.default(mockPi);
for (const cmd of mockPi.registeredCommands) {
expect(typeof cmd.options.description).toBe("string");
expect(cmd.options.description.length).toBeGreaterThan(0);
expect(typeof cmd.options.handler).toBe("function");
}
});
it("should send user message with HARNESS.md, command spec, and user args", async () => {
const mod = await loadExtension();
mod.default(mockPi);
const cmd = mockPi.registeredCommands.find(
(c) => c.name === "cli-anything",
);
expect(cmd).toBeDefined();
const mockCtx = { ui: { notify: vi.fn() } };
await cmd!.options.handler(" /path/to/software", mockCtx);
expect(mockPi.sendUserMessage).toHaveBeenCalledTimes(1);
const msg = mockPi.sentMessages[0];
expect(msg).toContain("[CLI-Anything Command: cli-anything]");
expect(msg).toContain(MOCK_HARNESS);
expect(msg).toContain("# cli-anything command mock");
expect(msg).toContain("/path/to/software");
expect(msg).toContain("Extension Asset Paths");
expect(msg).toContain("Path Remapping Rules");
});
it("should show warning when /cli-anything is invoked without args", async () => {
const mod = await loadExtension();
mod.default(mockPi);
const cmd = mockPi.registeredCommands.find(
(c) => c.name === "cli-anything",
);
const mockNotify = vi.fn();
const mockCtx = { ui: { notify: mockNotify } };
await cmd!.options.handler(" ", mockCtx);
expect(mockNotify).toHaveBeenCalledTimes(1);
expect(mockNotify).toHaveBeenCalledWith(
expect.stringContaining("Usage: /cli-anything"),
"warning",
);
expect(mockPi.sendUserMessage).not.toHaveBeenCalled();
});
it("should show warning when /cli-anything:refine is invoked without args", async () => {
const mod = await loadExtension();
mod.default(mockPi);
const cmd = mockPi.registeredCommands.find(
(c) => c.name === "cli-anything:refine",
);
const mockNotify = vi.fn();
const mockCtx = { ui: { notify: mockNotify } };
await cmd!.options.handler("", mockCtx);
expect(mockNotify).toHaveBeenCalledTimes(1);
expect(mockNotify).toHaveBeenCalledWith(
expect.stringContaining("Usage: /cli-anything:refine"),
"warning",
);
expect(mockPi.sendUserMessage).not.toHaveBeenCalled();
});
it("should show warning when /cli-anything:test is invoked without args", async () => {
const mod = await loadExtension();
mod.default(mockPi);
const cmd = mockPi.registeredCommands.find(
(c) => c.name === "cli-anything:test",
);
const mockNotify = vi.fn();
const mockCtx = { ui: { notify: mockNotify } };
await cmd!.options.handler(" ", mockCtx);
expect(mockNotify).toHaveBeenCalledTimes(1);
expect(mockNotify).toHaveBeenCalledWith(
expect.stringContaining("Usage: /cli-anything:test"),
"warning",
);
expect(mockPi.sendUserMessage).not.toHaveBeenCalled();
});
it("should show warning when /cli-anything:validate is invoked without args", async () => {
const mod = await loadExtension();
mod.default(mockPi);
const cmd = mockPi.registeredCommands.find(
(c) => c.name === "cli-anything:validate",
);
const mockNotify = vi.fn();
const mockCtx = { ui: { notify: mockNotify } };
await cmd!.options.handler("", mockCtx);
expect(mockNotify).toHaveBeenCalledTimes(1);
expect(mockNotify).toHaveBeenCalledWith(
expect.stringContaining("Usage: /cli-anything:validate"),
"warning",
);
expect(mockPi.sendUserMessage).not.toHaveBeenCalled();
});
it("/cli-anything:list should work with no arguments", async () => {
const mod = await loadExtension();
mod.default(mockPi);
const cmd = mockPi.registeredCommands.find(
(c) => c.name === "cli-anything:list",
);
const mockCtx = { ui: { notify: vi.fn() } };
await cmd!.options.handler("", mockCtx);
expect(mockPi.sendUserMessage).toHaveBeenCalledTimes(1);
const msg = mockPi.sentMessages[0];
expect(msg).toContain("[CLI-Anything Command: cli-anything:list]");
expect(msg).toContain("(no arguments");
});
it("/cli-anything:list should pass flags through", async () => {
const mod = await loadExtension();
mod.default(mockPi);
const cmd = mockPi.registeredCommands.find(
(c) => c.name === "cli-anything:list",
);
const mockCtx = { ui: { notify: vi.fn() } };
await cmd!.options.handler("--json --depth 2", mockCtx);
expect(mockPi.sendUserMessage).toHaveBeenCalledTimes(1);
const msg = mockPi.sentMessages[0];
expect(msg).toContain("--json --depth 2");
});
it("/cli-anything:list getArgumentCompletions should return matching flags", async () => {
const mod = await loadExtension();
mod.default(mockPi);
const cmd = mockPi.registeredCommands.find(
(c) => c.name === "cli-anything:list",
);
const completions = cmd!.options.getArgumentCompletions("--j");
expect(completions).toEqual([{ value: "--json", label: "--json" }]);
});
it("/cli-anything:list getArgumentCompletions should return null for unknown prefix", async () => {
const mod = await loadExtension();
mod.default(mockPi);
const cmd = mockPi.registeredCommands.find(
(c) => c.name === "cli-anything:list",
);
const completions = cmd!.options.getArgumentCompletions("--unknown");
expect(completions).toBeNull();
});
it("readAsset should throw descriptive error for missing file", async () => {
const mod = await loadExtension();
mod.default(mockPi);
// Invoke with a valid arg — but our mock only has known files,
// so any command that reads assets should succeed. We test error
// by checking the error message format matches what readAsset produces.
// The mock throws for unknown files, simulating a real missing asset.
const { readFileSync } = await import("node:fs");
expect(() => readFileSync("/nonexistent/file.md", "utf-8")).toThrow();
});
});
@@ -0,0 +1,361 @@
"""
Tests for skill_generator.py — SKILL.md generation for CLI-Anything harnesses.
Verifies metadata extraction, SKILL.md generation, and edge cases.
Run with: pytest tests/test_skill_generator.py -v
"""
import os
import sys
import textwrap
import tempfile
from pathlib import Path
import pytest
# skill_generator lives in cli-anything-plugin/ (canonical) or scripts/ (installed)
EXT_DIR = Path(__file__).resolve().parent.parent
SCRIPTS_DIR = EXT_DIR / "scripts"
if not (SCRIPTS_DIR / "skill_generator.py").exists():
SCRIPTS_DIR = EXT_DIR.parents[1] / "cli-anything-plugin"
sys.path.insert(0, str(SCRIPTS_DIR))
from skill_generator import (
extract_cli_metadata,
generate_skill_md,
generate_skill_md_simple,
generate_skill_file,
extract_intro_from_readme,
extract_version_from_setup,
SkillMetadata,
CommandInfo,
CommandGroup,
Example,
)
# ─── Fixtures ──────────────────────────────────────────────────────────
@pytest.fixture
def harness_dir(tmp_path):
"""Create a minimal harness directory structure."""
software = "testapp"
cli_pkg = tmp_path / "cli_anything" / software
cli_pkg.mkdir(parents=True)
# __init__.py
(cli_pkg / "__init__.py").write_text('"""Test application CLI."""\n')
# README.md
(cli_pkg / "README.md").write_text(
textwrap.dedent(f"""\
# {software}
A powerful test application for demonstrating CLI harness generation.
This application supports batch processing and interactive use.
""")
)
# setup.py
(tmp_path / "setup.py").write_text(
textwrap.dedent("""\
from setuptools import setup, find_packages
setup(
name="cli-anything-testapp",
version="2.1.0",
packages=find_packages(),
)
""")
)
# CLI file with Click commands
(cli_pkg / f"{software}_cli.py").write_text(
textwrap.dedent("""\
import click
@click.group()
def cli():
\"\"\"Main CLI group.\"\"\"
pass
@cli.command()
def export():
\"\"\"Export data to file.\"\"\"
pass
@cli.command()
def import_data():
\"\"\"Import data from file.\"\"\"
pass
""")
)
return tmp_path
@pytest.fixture
def minimal_harness(tmp_path):
"""Create the absolute minimal harness (just __init__.py)."""
software = "minimal"
cli_pkg = tmp_path / "cli_anything" / software
cli_pkg.mkdir(parents=True)
(cli_pkg / "__init__.py").write_text("")
return tmp_path
# ─── extract_cli_metadata Tests ────────────────────────────────────────
class TestExtractCliMetadata:
def test_extracts_software_name(self, harness_dir):
metadata = extract_cli_metadata(str(harness_dir))
assert metadata.software_name == "testapp"
def test_extracts_skill_name(self, harness_dir):
metadata = extract_cli_metadata(str(harness_dir))
assert metadata.skill_name == "cli-anything-testapp"
def test_extracts_version_from_setup_py(self, harness_dir):
metadata = extract_cli_metadata(str(harness_dir))
assert metadata.version == "2.1.0"
def test_extracts_intro_from_readme(self, harness_dir):
metadata = extract_cli_metadata(str(harness_dir))
assert "powerful test application" in metadata.skill_intro
def test_extracts_command_groups(self, harness_dir):
metadata = extract_cli_metadata(str(harness_dir))
assert len(metadata.command_groups) > 0
def test_extracts_commands_from_cli_file(self, harness_dir):
metadata = extract_cli_metadata(str(harness_dir))
all_commands = []
for group in metadata.command_groups:
all_commands.extend(group.commands)
# Should find at least 'export' and 'import-data'
cmd_names = [c.name for c in all_commands]
assert "export" in cmd_names
assert "import-data" in cmd_names
def test_generates_examples(self, harness_dir):
metadata = extract_cli_metadata(str(harness_dir))
assert len(metadata.examples) > 0
def test_minimal_harness(self, minimal_harness):
metadata = extract_cli_metadata(str(minimal_harness))
assert metadata.software_name == "minimal"
assert metadata.version == "1.0.0" # Default when no setup.py
def test_raises_on_missing_cli_anything_dir(self, tmp_path):
with pytest.raises(ValueError, match="cli_anything directory not found"):
extract_cli_metadata(str(tmp_path))
def test_raises_on_empty_cli_anything_dir(self, tmp_path):
(tmp_path / "cli_anything").mkdir()
with pytest.raises(ValueError, match="No CLI package found"):
extract_cli_metadata(str(tmp_path))
def test_description_contains_software_name(self, harness_dir):
metadata = extract_cli_metadata(str(harness_dir))
assert "testapp" in metadata.skill_description.lower() or "Testapp" in metadata.skill_description
# ─── extract_version_from_setup Tests ──────────────────────────────────
class TestExtractVersionFromSetup:
def test_extracts_version(self, tmp_path):
setup_py = tmp_path / "setup.py"
setup_py.write_text('version="3.2.1"')
assert extract_version_from_setup(setup_py) == "3.2.1"
def test_extracts_version_single_quotes(self, tmp_path):
setup_py = tmp_path / "setup.py"
setup_py.write_text("version='1.0.0'")
assert extract_version_from_setup(setup_py) == "1.0.0"
def test_returns_default_when_no_version(self, tmp_path):
setup_py = tmp_path / "setup.py"
setup_py.write_text("# no version here")
assert extract_version_from_setup(setup_py) == "1.0.0"
# ─── extract_intro_from_readme Tests ───────────────────────────────────
class TestExtractIntroFromReadme:
def test_extracts_first_paragraph(self):
content = "# My App\n\nThis is the intro paragraph.\n\n## Section\nMore text"
intro = extract_intro_from_readme(content)
assert "This is the intro paragraph" in intro
def test_returns_default_for_empty(self):
content = "# Title\n## Section\n"
intro = extract_intro_from_readme(content)
assert "CLI interface" in intro
def test_handles_multiline_intro(self):
content = "# App\nLine one.\nLine two.\n\n## Details"
intro = extract_intro_from_readme(content)
assert "Line one" in intro
assert "Line two" in intro
# ─── generate_skill_md Tests ───────────────────────────────────────────
class TestGenerateSkillMd:
def _make_metadata(self, **overrides):
defaults = dict(
skill_name="cli-anything-testapp",
skill_description="CLI for TestApp",
software_name="testapp",
skill_intro="A test application.",
version="1.0.0",
system_package=None,
command_groups=[],
examples=[],
)
defaults.update(overrides)
return SkillMetadata(**defaults)
def test_simple_output_has_yaml_frontmatter(self):
metadata = self._make_metadata()
content = generate_skill_md_simple(metadata)
assert content.startswith("---")
assert 'name: "cli-anything-testapp"' in content
assert 'description: "CLI for TestApp"' in content
def test_simple_output_has_installation_section(self):
metadata = self._make_metadata()
content = generate_skill_md_simple(metadata)
assert "## Installation" in content
assert "pip install cli-anything-testapp" in content
def test_simple_output_includes_version(self):
metadata = self._make_metadata(version="2.5.0")
content = generate_skill_md_simple(metadata)
assert "2.5.0" in content
def test_simple_output_has_command_groups(self):
groups = [
CommandGroup(
name="Export",
description="Export commands",
commands=[
CommandInfo(name="pdf", description="Export as PDF"),
CommandInfo(name="svg", description="Export as SVG"),
],
)
]
metadata = self._make_metadata(command_groups=groups)
content = generate_skill_md_simple(metadata)
assert "### Export" in content
assert "`pdf`" in content
assert "`svg`" in content
def test_simple_output_has_examples(self):
examples = [
Example(
title="Quick Start",
description="Get started quickly",
code="cli-anything-testapp --help",
)
]
metadata = self._make_metadata(examples=examples)
content = generate_skill_md_simple(metadata)
assert "### Quick Start" in content
assert "cli-anything-testapp --help" in content
def test_generate_skill_md_falls_back_to_simple(self):
metadata = self._make_metadata()
# Without a template file, should fall back to simple generation
content = generate_skill_md(metadata, template_path="/nonexistent/template")
assert "cli-anything-testapp" in content
def test_generate_skill_md_with_no_template_arg(self):
metadata = self._make_metadata()
# Should work without template_path argument (uses default or falls back)
content = generate_skill_md(metadata)
assert isinstance(content, str)
assert len(content) > 0
# ─── generate_skill_file Tests ─────────────────────────────────────────
class TestGenerateSkillFile:
def test_generates_file_at_default_path(self, harness_dir):
output = generate_skill_file(str(harness_dir))
assert Path(output).exists()
content = Path(output).read_text()
assert "cli-anything-testapp" in content
def test_generates_file_at_custom_path(self, harness_dir, tmp_path):
output_file = tmp_path / "custom" / "SKILL.md"
output = generate_skill_file(str(harness_dir), str(output_file))
assert Path(output).exists()
content = Path(output).read_text()
assert "testapp" in content.lower()
def test_creates_parent_directories(self, harness_dir, tmp_path):
output_file = tmp_path / "deep" / "nested" / "dir" / "SKILL.md"
output = generate_skill_file(str(harness_dir), str(output_file))
assert Path(output).exists()
# ─── Edge Case Tests ──────────────────────────────────────────────────
class TestEdgeCases:
def test_harness_without_readme(self, tmp_path):
"""Harness with no README.md should have empty intro."""
software = "noreadme"
cli_pkg = tmp_path / "cli_anything" / software
cli_pkg.mkdir(parents=True)
(cli_pkg / "__init__.py").write_text("")
# No README.md, no setup.py, no CLI file
metadata = extract_cli_metadata(str(tmp_path))
assert metadata.software_name == "noreadme"
assert metadata.skill_intro == "" # No README → empty intro
assert metadata.version == "1.0.0"
assert metadata.command_groups == []
def test_harness_with_system_package(self, tmp_path):
"""README with apt install instructions should extract system_package."""
software = "syspkg"
cli_pkg = tmp_path / "cli_anything" / software
cli_pkg.mkdir(parents=True)
(cli_pkg / "__init__.py").write_text("")
(cli_pkg / "README.md").write_text(
"# Syspkg\n\nInstall via `apt install syspkg-tool`.\n"
)
metadata = extract_cli_metadata(str(tmp_path))
assert metadata.system_package is not None
assert "syspkg-tool" in metadata.system_package
def test_malformed_setup_py(self, tmp_path):
"""Malformed setup.py should default to version 1.0.0."""
software = "badsetup"
cli_pkg = tmp_path / "cli_anything" / software
cli_pkg.mkdir(parents=True)
(cli_pkg / "__init__.py").write_text("")
(tmp_path / "setup.py").write_text("THIS IS NOT VALID PYTHON { } }")
metadata = extract_cli_metadata(str(tmp_path))
assert metadata.version == "1.0.0"
def test_empty_setup_py(self, tmp_path):
"""Empty setup.py should default to 1.0.0."""
software = "emptysetup"
cli_pkg = tmp_path / "cli_anything" / software
cli_pkg.mkdir(parents=True)
(cli_pkg / "__init__.py").write_text("")
(tmp_path / "setup.py").write_text("")
metadata = extract_cli_metadata(str(tmp_path))
assert metadata.version == "1.0.0"