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
163 changes: 163 additions & 0 deletions packages/cli/src/commands/provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

// ─── Mocks ──────────────────────────────────────────────────────────────────

const detectProvidersMock = vi.fn();
const resolveProviderMock = vi.fn();
const setProviderMock = vi.fn();

vi.mock("../utils/providers.js", async () => {
const actual = await vi.importActual("../utils/providers.js") as Record<string, unknown>;
return {
detectProviders: (...args: unknown[]) => detectProvidersMock(...args),
getProviderNames: actual["getProviderNames"],
getSkillsDir: actual["getSkillsDir"],
isValidProvider: actual["isValidProvider"],
resolveProvider: (...args: unknown[]) => resolveProviderMock(...args),
};
});

vi.mock("../utils/config-manager.js", () => ({
ConfigManager: class {
setProvider(name: string) {
setProviderMock(name);
}
},
}));

import {
handleProviderList,
handleProviderSet,
handleProviderShow,
} from "./provider.js";

// ─── Helpers ────────────────────────────────────────────────────────────────

let logOutput: string[];
let stderrOutput: string[];

beforeEach(() => {
vi.clearAllMocks();
logOutput = [];
stderrOutput = [];
vi.spyOn(console, "log").mockImplementation((...args) => {
logOutput.push(args.join(" "));
});
vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
stderrOutput.push(String(chunk));
return true;
});
});

afterEach(() => {
vi.restoreAllMocks();
});

// ─── handleProviderList ─────────────────────────────────────────────────────

describe("handleProviderList", () => {
it("marks detected providers with a checkmark", async () => {
detectProvidersMock.mockReturnValue(["claude", "cursor"]);

await handleProviderList();

const output = logOutput.join("\n");
// claude should have checkmark, codex should not
expect(output).toMatch(/✓\s+claude/);
expect(output).toMatch(/✓\s+cursor/);
expect(output).toMatch(/\s{2}\s+copilot/); // space, not checkmark
});

it("shows all 7 providers even when none detected", async () => {
detectProvidersMock.mockReturnValue([]);

await handleProviderList();

const output = logOutput.join("\n");
for (const name of ["claude", "cursor", "copilot", "codex", "gemini", "goose", "opencode"]) {
expect(output).toContain(name);
}
expect(output).toContain("No providers detected");
});

it("shows detected summary when providers found", async () => {
detectProvidersMock.mockReturnValue(["gemini"]);

await handleProviderList();

const output = logOutput.join("\n");
expect(output).toContain("Detected: gemini");
});
});

// ─── handleProviderSet ──────────────────────────────────────────────────────

describe("handleProviderSet", () => {
it("persists a valid provider to config", async () => {
await handleProviderSet("cursor");

expect(setProviderMock).toHaveBeenCalledWith("cursor");
expect(logOutput.join("\n")).toContain("cursor");
});

it("rejects an invalid provider name", async () => {
const exitError = new Error("process.exit");
const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation(() => {
throw exitError;
});

await expect(handleProviderSet("vscode")).rejects.toThrow("process.exit");

expect(exitSpy).toHaveBeenCalledWith(1);
expect(stderrOutput.join("")).toContain("Unknown provider");
expect(stderrOutput.join("")).toContain("vscode");
expect(setProviderMock).not.toHaveBeenCalled();

exitSpy.mockRestore();
});

it("shows the skills directory in confirmation", async () => {
await handleProviderSet("claude");

const output = logOutput.join("\n");
// Should show both the name and the path
expect(output).toContain("claude");
expect(output).toContain(".claude/skills");
});
});

// ─── handleProviderShow ─────────────────────────────────────────────────────

describe("handleProviderShow", () => {
it("displays the resolved provider and directory", async () => {
resolveProviderMock.mockReturnValue({
provider: "cursor",
skillsDir: "/home/user/.cursor/skills",
});

await handleProviderShow();

const output = logOutput.join("\n");
expect(output).toContain("cursor");
expect(output).toContain("/home/user/.cursor/skills");
});

it("exits with error when resolution fails (e.g. invalid config)", async () => {
resolveProviderMock.mockImplementation(() => {
throw new Error("Unknown provider in config: stale-value");
});

const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation(() => undefined as never);

await handleProviderShow();

expect(exitSpy).toHaveBeenCalledWith(1);
expect(stderrOutput.join("")).toContain("Unknown provider in config");

exitSpy.mockRestore();
});
});
72 changes: 72 additions & 0 deletions packages/cli/src/commands/provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { ConfigManager } from "../utils/config-manager.js";
import {
detectProviders,
getProviderNames,
getSkillsDir,
isValidProvider,
resolveProvider,
} from "../utils/providers.js";

/**
* List all supported providers and indicate which are detected
*/
export async function handleProviderList(): Promise<void> {
const detected = detectProviders();
const all = getProviderNames();

console.log("");
console.log("Supported providers:");
console.log("");

for (const name of all) {
const isDetected = detected.includes(name);
const marker = isDetected ? "\u2713" : " ";
const dir = getSkillsDir(name);
console.log(` ${marker} ${name.padEnd(10)} ${dir}`);
}

console.log("");
if (detected.length > 0) {
console.log(`Detected: ${detected.join(", ")}`);
} else {
console.log("No providers detected. Defaulting to claude.");
}
}

/**
* Set the default provider
*/
export async function handleProviderSet(name: string): Promise<void> {
if (!isValidProvider(name)) {
process.stderr.write(
`Error: Unknown provider: ${name}\n`,
);
process.stderr.write(
`Valid providers: ${getProviderNames().join(", ")}\n`,
);
process.exit(1);
}

const configManager = new ConfigManager();
configManager.setProvider(name);

console.log(
`Default provider set to: ${name} (${getSkillsDir(name)})`,
);
}

/**
* Show the current resolved provider and skills directory
*/
export async function handleProviderShow(): Promise<void> {
try {
const { provider, skillsDir } = resolveProvider();
console.log(`Provider: ${provider}`);
console.log(`Skills directory: ${skillsDir}`);
} catch (err) {
process.stderr.write(
`${err instanceof Error ? err.message : String(err)}\n`,
);
process.exit(1);
}
}
18 changes: 8 additions & 10 deletions packages/cli/src/commands/skills/install.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
import { existsSync, mkdirSync, writeFileSync, rmSync } from "fs";
import { join, basename } from "path";
import { homedir, tmpdir } from "os";
import { tmpdir } from "os";
import { execFileSync } from "child_process";
import { formatSize, fmtError } from "../../utils/format.js";
import { createClient } from "../../utils/client.js";

/**
* Get the Claude Code skills directory
*/
function getSkillsDir(): string {
return join(homedir(), ".claude", "skills");
}
import { resolveProvider } from "../../utils/providers.js";

/**
* Parse skill spec into name and version
Expand Down Expand Up @@ -45,6 +39,7 @@ function getShortName(scopedName: string): string {
export interface InstallOptions {
force?: boolean;
json?: boolean;
provider?: string;
}

/**
Expand All @@ -57,13 +52,15 @@ export async function handleSkillInstall(
try {
const { name, version } = parseSkillSpec(skillSpec);

// Resolve target provider
const { provider, skillsDir } = resolveProvider(options.provider);

// Get download info
const client = createClient();
const downloadInfo = version
? await client.getSkillVersionDownload(name, version)
: await client.getSkillDownload(name);
const shortName = getShortName(downloadInfo.skill.name);
const skillsDir = getSkillsDir();
const installPath = join(skillsDir, shortName);

// Check if already installed
Expand Down Expand Up @@ -138,6 +135,7 @@ export async function handleSkillInstall(
shortName,
version: downloadInfo.skill.version,
path: installPath,
provider,
},
null,
2,
Expand All @@ -148,7 +146,7 @@ export async function handleSkillInstall(
console.log(`\u2713 Installed: ${shortName}`);
console.log("");
console.log(
"Skill available in Claude Code. Restart to activate.",
`Skill available in ${provider}. Restart to activate.`,
);
}
} catch (err) {
Expand Down
21 changes: 7 additions & 14 deletions packages/cli/src/commands/skills/list.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import { join } from "path";
import { homedir } from "os";
import matter from "gray-matter";

/**
* Get the Claude Code skills directory
*/
function getSkillsDir(): string {
return join(homedir(), ".claude", "skills");
}
import { resolveProvider } from "../../utils/providers.js";

interface InstalledSkill {
name: string;
Expand All @@ -20,9 +13,7 @@ interface InstalledSkill {
/**
* List all installed skills
*/
function listInstalledSkills(): InstalledSkill[] {
const skillsDir = getSkillsDir();

function listInstalledSkills(skillsDir: string): InstalledSkill[] {
if (!existsSync(skillsDir)) {
return [];
}
Expand Down Expand Up @@ -72,6 +63,7 @@ function listInstalledSkills(): InstalledSkill[] {

export interface ListOptions {
json?: boolean;
provider?: string;
}

/**
Expand All @@ -80,7 +72,8 @@ export interface ListOptions {
export async function handleSkillList(
options: ListOptions,
): Promise<void> {
const skills = listInstalledSkills();
const { provider, skillsDir } = resolveProvider(options.provider);
const skills = listInstalledSkills(skillsDir);

if (options.json) {
console.log(JSON.stringify(skills, null, 2));
Expand All @@ -91,7 +84,7 @@ export async function handleSkillList(
console.log("No skills installed.");
console.log("");
console.log("Install skills with: mpak skill install <name>");
console.log("Or create your own in ~/.claude/skills/");
console.log(`Or create your own in ${skillsDir}/`);
return;
}

Expand Down Expand Up @@ -122,6 +115,6 @@ export async function handleSkillList(

console.log("");
console.log(
`${skills.length} skill(s) installed in ${getSkillsDir()}`,
`${skills.length} skill(s) installed in ${skillsDir} (${provider})`,
);
}
Loading
Loading