diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dd3c930 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + name: Lint & Test (Node ${{ matrix.node-version }}) + runs-on: ubuntu-latest + strategy: + matrix: + # Node 18 is dropped from this matrix: the dev/test toolchain (vitest 4, + # tsup/rolldown, commander 14, @inquirer/select 5) requires Node >=20 and + # cannot run `npm test`/`npm run build` on Node 18. This is separate from + # (and doesn't verify) the package's documented "engines": ">=18" for the + # published CLI itself. + node-version: [20.x, 22.x, 24.x] + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint (type-check) + run: npm run lint + + - name: Run unit & integration tests + run: npm test + + - name: Build + run: npm run build diff --git a/src/assets/mcp.ts b/src/assets/mcp.ts index 8473e2c..52b8826 100644 --- a/src/assets/mcp.ts +++ b/src/assets/mcp.ts @@ -37,10 +37,11 @@ function resolveConfigFilePath( async function handleTomlMcp( job: InstallJob, configFilePath: string, + assetName: string, newServerConfig: Record, ): Promise { const { parse, stringify } = await import('smol-toml'); - const { host, assetName } = job; + const { host } = job; await ensureDir(path.dirname(configFilePath)); @@ -65,16 +66,25 @@ async function handleTomlMcp( if (host.id === 'vibe') { // Vibe uses [[mcp_servers]] array format const mcpServers = (parsed['mcp_servers'] as Array>) ?? []; - const existing = mcpServers.find((s) => s['name'] === assetName); - if (existing) { - return { job, status: 'exists', targetPath: configFilePath }; - } const entry: Record = { name: assetName, transport: 'stdio', command: (newServerConfig['command'] as string) ?? '', }; if (newServerConfig['args']) entry['args'] = newServerConfig['args']; + + const existingIdx = mcpServers.findIndex((s) => s['name'] === assetName); + if (existingIdx !== -1) { + if (JSON.stringify(mcpServers[existingIdx]) === JSON.stringify(entry)) { + return { job, status: 'exists', targetPath: configFilePath }; + } + return { + job, + status: 'conflict', + targetPath: configFilePath, + reason: `${configFilePath} already has an mcp_servers entry named '${assetName}' with different content`, + }; + } mcpServers.push(entry); parsed['mcp_servers'] = mcpServers; } else { @@ -83,14 +93,23 @@ async function handleTomlMcp( parsed['mcp_servers'] = {}; } const mcpServers = parsed['mcp_servers'] as Record; - if (assetName in mcpServers) { - return { job, status: 'exists', targetPath: configFilePath }; - } const entry: Record = { command: (newServerConfig['command'] as string) ?? '', }; if (newServerConfig['args']) entry['args'] = newServerConfig['args']; if (newServerConfig['env']) entry['env'] = newServerConfig['env']; + + if (assetName in mcpServers) { + if (JSON.stringify(mcpServers[assetName]) === JSON.stringify(entry)) { + return { job, status: 'exists', targetPath: configFilePath }; + } + return { + job, + status: 'conflict', + targetPath: configFilePath, + reason: `${configFilePath} already has a '${assetName}' key with different content`, + }; + } mcpServers[assetName] = entry; } @@ -139,12 +158,21 @@ export const mcpHandler: AssetHandler = { // Dispatch to TOML handler when config file has .toml extension if (configFilePath.endsWith('.toml')) { - return handleTomlMcp(job, configFilePath, newServerConfig); + return handleTomlMcp(job, configFilePath, assetName, newServerConfig); } const configKey = mcpCapability.configKey ?? 'mcpServers'; - const existingConfig = (await readJSONOrNull>(configFilePath)) ?? {}; + let existingConfig: Record; + try { + existingConfig = (await readJSONOrNull>(configFilePath)) ?? {}; + } catch (err) { + return { + job, + status: 'error', + reason: `Failed to parse existing config file as JSON: ${configFilePath}\n Cause: ${(err as Error).message}`, + }; + } const mcpServers = (existingConfig[configKey] as Record) ?? {}; if (assetName in mcpServers) { diff --git a/src/assets/skill.ts b/src/assets/skill.ts index 205663b..d079404 100644 --- a/src/assets/skill.ts +++ b/src/assets/skill.ts @@ -26,10 +26,14 @@ export const skillHandler: AssetHandler = { const entryFile = path.join(targetDir, 'SKILL.md'); try { - await fs.promises.access(targetDir); + // Check the entry file, not just the directory: a directory that + // exists without SKILL.md means a previous install was interrupted + // partway through copyDirRecursive, and should be retried rather + // than permanently reported as already installed. + await fs.promises.access(entryFile); return { job, status: 'exists', targetPath: entryFile }; } catch { - // target doesn't exist, proceed with install + // target doesn't exist or is an incomplete install, proceed with install } await copyDirRecursive(resolvedSource.localPath, targetDir); diff --git a/src/cli.ts b/src/cli.ts index b3a4283..86ea092 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -89,7 +89,7 @@ Examples: if (cliInput.host) { hostId = cliInput.host; - } else if (!process.stdout.isTTY) { + } else if (!process.stdin.isTTY || !process.stdout.isTTY) { const validIds = getValidHostIds().join(', '); process.stderr.write( `agent-add error: Non-interactive environment detected. Please specify a host with --host .\n`, diff --git a/src/installer.ts b/src/installer.ts index 1d35ced..afb5ad4 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -27,6 +27,16 @@ export interface CliInput { host?: string; } +async function cleanupTempDirs(tempDirs: Set): Promise { + for (const dir of tempDirs) { + try { + await fs.promises.rm(dir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + } +} + function getHandler(assetType: AssetType) { switch (assetType) { case 'mcp': return mcpHandler; @@ -43,13 +53,13 @@ async function validateAsset( ): Promise { if (assetType === 'skill') { if (resolved.type === 'http-file' || resolved.type === 'inline-json' || resolved.type === 'inline-md') { - return 'Skill 资产必须指向目录来源(本地路径或 Git URL),不支持内联内容或直接 HTTP(S) URL'; + return 'Skill assets must point to a directory source (local path or Git URL); inline content and direct HTTP(S) URLs are not supported'; } const skillMdPath = path.join(resolved.localPath, 'SKILL.md'); try { await fs.promises.access(skillMdPath); } catch { - return `Skill 目录内缺少 SKILL.md 文件(期望路径:${skillMdPath})`; + return `Skill directory is missing a SKILL.md file (expected path: ${skillMdPath})`; } return null; } @@ -58,10 +68,10 @@ async function validateAsset( try { await fs.promises.access(resolved.localPath); } catch { - return `MCP 来源文件不存在:${resolved.localPath}`; + return `MCP source file does not exist: ${resolved.localPath}`; } if (!resolved.localPath.endsWith('.json')) { - return `MCP 来源文件扩展名必须为 .json(得到:${resolved.localPath})`; + return `MCP source file extension must be .json (got: ${resolved.localPath})`; } return null; } @@ -70,10 +80,10 @@ async function validateAsset( try { await fs.promises.access(resolved.localPath); } catch { - return `来源文件不存在:${resolved.localPath}`; + return `Source file does not exist: ${resolved.localPath}`; } if (!resolved.localPath.endsWith('.md')) { - return `${assetType} 来源文件扩展名必须为 .md(得到:${resolved.localPath})`; + return `${assetType} source file extension must be .md (got: ${resolved.localPath})`; } return null; } @@ -147,12 +157,17 @@ export async function runInstaller( fromExplicitFlag: boolean; }> = []; + const tempDirsToClean = new Set(); + let explicitCount = explicitDescriptors.length; let idx = 0; for (const item of expandedItems) { const isGlob = item.source.endsWith('/*'); const cleanSource = isGlob ? item.source.slice(0, -2) : item.source; const resolved = await resolveSource(cleanSource, cwd); + if (resolved.tempDir) { + tempDirsToClean.add(resolved.tempDir); + } const fromExplicitFlag = idx < explicitCount; if (isGlob) { @@ -162,6 +177,7 @@ export async function runInstaller( `agent-add error: No matching files found in directory for ${item.assetType}\n` + ` Source: ${item.source}\n`, ); + await cleanupTempDirs(tempDirsToClean); process.exit(2); } const dirName = path.basename(resolved.localPath); @@ -174,7 +190,7 @@ export async function runInstaller( }); } } else { - const assetName = inferName(cleanSource); + const assetName = inferName(cleanSource, { isDirectorySource: item.assetType === 'skill' }); resolvedItems.push({ assetType: item.assetType, assetName, @@ -190,6 +206,7 @@ export async function runInstaller( if (validationError) { process.stderr.write(`agent-add error: ${validationError}\n`); process.stderr.write(` Source: ${item.resolved.originalSource}\n`); + await cleanupTempDirs(tempDirsToClean); process.exit(2); } } @@ -228,16 +245,7 @@ export async function runInstaller( results.push(result); } - for (const item of resolvedItems) { - if (item.resolved.tempDir) { - try { - const fs2 = await import('fs'); - await fs2.promises.rm(item.resolved.tempDir, { recursive: true, force: true }); - } catch { - // ignore cleanup errors - } - } - } + await cleanupTempDirs(tempDirsToClean); return { host, results }; } diff --git a/src/source/git.ts b/src/source/git.ts index d975085..26dafa7 100644 --- a/src/source/git.ts +++ b/src/source/git.ts @@ -8,18 +8,67 @@ import type { SourceType } from './index.js'; const execFileAsync = promisify(execFile); -export async function resolveGit(source: string, type: SourceType): Promise { +export interface ParsedGitSource { + repoUrl: string; + ref?: string; + subPath?: string; +} + +/** + * Split a git source string into repo URL, optional @ref, and optional #subPath. + * + * Two ref orderings are supported: + * - `repo.git@ref#path` (the documented convention for user-supplied sources) + * - `repo.git#path@ref` (emitted by normalizeGitUrl for GitHub/GitLab + * `/tree//` web URLs) + * + * For non-SSH URLs, the @ref separator before `#` is only searched for within + * the path segment (after the authority), so credentials embedded in the URL + * (e.g. https://token@github.com/org/repo.git) are not mistaken for a ref + * separator. + */ +export function parseGitSource(source: string): ParsedGitSource { // Step 1: split off #subPath const hashIdx = source.indexOf('#'); const withoutPath = hashIdx !== -1 ? source.slice(0, hashIdx) : source; - const subPath = hashIdx !== -1 ? source.slice(hashIdx + 1) || undefined : undefined; + const rawSubPath = hashIdx !== -1 ? source.slice(hashIdx + 1) || undefined : undefined; - // Step 2: split off @ref — for SSH URLs (git@host:...), skip the leading "git@" prefix + // Step 2: split off @ref — for SSH URLs (git@host:...), skip the leading "git@" prefix. + // For other URLs, only look for @ within the path segment, so userinfo + // credentials (https://user:pass@host/... or https://token@host/...) aren't + // mistaken for the ref separator. const isSSH = withoutPath.startsWith('git@'); - const searchFrom = isSSH ? 4 : 0; + let searchFrom = 0; + if (isSSH) { + searchFrom = 4; + } else { + const schemeIdx = withoutPath.indexOf('://'); + if (schemeIdx !== -1) { + const authorityStart = schemeIdx + 3; + const pathStart = withoutPath.indexOf('/', authorityStart); + searchFrom = pathStart !== -1 ? pathStart : withoutPath.length; + } + } const atIdx = withoutPath.indexOf('@', searchFrom); const repoUrl = atIdx !== -1 ? withoutPath.slice(0, atIdx) : withoutPath; - const ref = atIdx !== -1 ? withoutPath.slice(atIdx + 1) || undefined : undefined; + let ref = atIdx !== -1 ? withoutPath.slice(atIdx + 1) || undefined : undefined; + + // Step 3: if no ref was found before `#`, check for a trailing @ref after + // the subPath (the `#path@ref` ordering). + let subPath = rawSubPath; + if (!ref && rawSubPath) { + const subAtIdx = rawSubPath.lastIndexOf('@'); + if (subAtIdx !== -1) { + ref = rawSubPath.slice(subAtIdx + 1) || undefined; + subPath = rawSubPath.slice(0, subAtIdx) || undefined; + } + } + + return { repoUrl, ref, subPath }; +} + +export async function resolveGit(source: string, type: SourceType): Promise { + const { repoUrl, ref, subPath } = parseGitSource(source); const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'agent-add-git-')); diff --git a/src/source/index.ts b/src/source/index.ts index cc7102c..faacc6c 100644 --- a/src/source/index.ts +++ b/src/source/index.ts @@ -10,6 +10,11 @@ export type SourceType = 'local' | 'git-ssh' | 'git-https' | 'http-file' | 'inli // .git suffix: appears as .git at end, before #, @, or / const GIT_REPO_SUFFIX_RE = /\.git(\/|@|#|$)/; +// Extensions this tool fetches directly as a single file (mcp/prompt/command/subAgent). +// If the part of the URL before "#" already looks like one of these, the "#" +// is treated as a URL fragment rather than a git subPath marker. +const DIRECT_FILE_EXT_RE = /\.(json|md)$/i; + // GitHub web URL: https://github.com/owner/repo[/tree|blob/ref/path] const GITHUB_WEB_RE = /^https?:\/\/(www\.)?github\.com\/([^/#]+\/[^/#]+?)(?:\.git)?\/?(?:\/(tree|blob)\/([^/]+)\/?(.*?))?$/; @@ -73,9 +78,16 @@ export function detectSourceType(source: string): SourceType { return 'git-ssh'; } if (source.startsWith('https://') || source.startsWith('http://')) { - if (GIT_REPO_SUFFIX_RE.test(source) || source.includes('#')) { + if (GIT_REPO_SUFFIX_RE.test(source)) { return 'git-https'; } + const hashIdx = source.indexOf('#'); + if (hashIdx !== -1) { + const beforeHash = (source.slice(0, hashIdx).split('?')[0] ?? ''); + if (!DIRECT_FILE_EXT_RE.test(beforeHash)) { + return 'git-https'; + } + } return 'http-file'; } // Fallback: try JSON.parse to handle edge cases where MSYS inserts a non-whitespace diff --git a/src/source/infer-name.ts b/src/source/infer-name.ts index 993974d..489297f 100644 --- a/src/source/infer-name.ts +++ b/src/source/infer-name.ts @@ -1,17 +1,27 @@ import path from 'path'; import { unwrapMcpServers } from '../utils/unwrap-mcp-servers.js'; +export interface InferNameOptions { + /** + * Set for sources that resolve to a directory (e.g. skill assets), where + * the last path segment / basename IS the asset name and must not have a + * trailing ".something" stripped as if it were a file extension. + */ + isDirectorySource?: boolean; +} + /** * Infer asset name from source string. * * Rules: * 0. If inline JSON (starts with `{`): extract the single top-level key * 0. If inline Markdown (contains `\n`): extract first `# Heading` and kebab-case it - * 1. If source contains `#path`, use last segment of path (minus extension) + * 1. If source contains `#path`, use last segment of path (minus extension, unless isDirectorySource) * 2. If git URL without #path (e.g. git@...repo.git), use repo name (strip .git) - * 3. If local path or http-file URL, use filename without extension + * 3. If local path or http-file URL, use filename without extension (unless isDirectorySource) */ -export function inferName(source: string): string { +export function inferName(source: string, options: InferNameOptions = {}): string { + const { isDirectorySource = false } = options; const s = source.trim(); // normalize: remove leading/trailing whitespace or BOM // Inline JSON: extract single top-level key as name @@ -21,11 +31,11 @@ export function inferName(source: string): string { parsed = JSON.parse(s); } catch { throw new Error( - `内联 JSON 解析失败。格式应为 {"":{...}},例如:{"playwright":{"command":"npx","args":["-y","@playwright/mcp"]}}`, + `Failed to parse inline JSON. Expected format: {"":{...}}, e.g.: {"playwright":{"command":"npx","args":["-y","@playwright/mcp"]}}`, ); } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error(`内联 JSON 必须为对象类型`); + throw new Error(`Inline JSON must be an object`); } const obj = parsed as Record; const unwrapped = unwrapMcpServers(obj); @@ -35,7 +45,7 @@ export function inferName(source: string): string { const keys = Object.keys(obj); if (keys.length !== 1) { throw new Error( - `内联 JSON 必须包含恰好一个 key(作为资产名称),当前有 ${keys.length} 个 key`, + `Inline JSON must contain exactly one key (used as the asset name), got ${keys.length} keys`, ); } return keys[0]!; @@ -54,7 +64,7 @@ export function inferName(source: string): string { } } throw new Error( - `内联 Markdown 必须包含一级标题(如 # My Prompt)以推断资产名称`, + `Inline Markdown must contain a level-1 heading (e.g. # My Prompt) to infer the asset name`, ); } @@ -64,8 +74,8 @@ export function inferName(source: string): string { const subPath = s.slice(hashIdx + 1); const segments = subPath.split('/').filter(Boolean); if (segments.length > 0) { - const last = segments[segments.length - 1]; - return stripExtension(last); + const last = segments[segments.length - 1]!; + return isDirectorySource ? last : stripExtension(last); } } @@ -80,7 +90,7 @@ export function inferName(source: string): string { // Local path or HTTP file: use filename without extension const basename = path.basename(s.split('?')[0] ?? s); - return stripExtension(basename); + return isDirectorySource ? basename : stripExtension(basename); } function stripExtension(filename: string): string { diff --git a/src/source/inline.ts b/src/source/inline.ts index 9868bf8..b8041a2 100644 --- a/src/source/inline.ts +++ b/src/source/inline.ts @@ -10,12 +10,12 @@ export async function resolveInlineJson(source: string, assetName: string): Prom parsed = JSON.parse(source); } catch { throw new Error( - `内联 JSON 解析失败。格式应为 {"":{...}},例如:{"playwright":{"command":"npx","args":["-y","@playwright/mcp"]}}`, + `Failed to parse inline JSON. Expected format: {"":{...}}, e.g.: {"playwright":{"command":"npx","args":["-y","@playwright/mcp"]}}`, ); } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error(`内联 JSON 必须为对象类型,得到:${JSON.stringify(parsed)}`); + throw new Error(`Inline JSON must be an object, got: ${JSON.stringify(parsed)}`); } const obj = parsed as Record; @@ -28,7 +28,7 @@ export async function resolveInlineJson(source: string, assetName: string): Prom const entries = Object.entries(obj); if (entries.length !== 1) { throw new Error( - `内联 JSON 必须包含恰好一个 key(作为资产名称),当前有 ${entries.length} 个 key`, + `Inline JSON must contain exactly one key (used as the asset name), got ${entries.length} keys`, ); } [, value] = entries[0]!; diff --git a/src/utils/fs.ts b/src/utils/fs.ts index a021221..a9e0b21 100644 --- a/src/utils/fs.ts +++ b/src/utils/fs.ts @@ -5,13 +5,23 @@ export async function ensureDir(dirPath: string): Promise { await fs.promises.mkdir(dirPath, { recursive: true }); } +/** + * Read and parse a JSON file. Returns null only if the file doesn't exist — + * any other error (permission denied, invalid JSON) is thrown, so callers + * that default a missing file to `{}` don't also silently discard an + * existing-but-malformed config and overwrite it. + */ export async function readJSONOrNull(filePath: string): Promise { + let content: string; try { - const content = await fs.promises.readFile(filePath, 'utf-8'); - return JSON.parse(content) as T; - } catch { - return null; + content = await fs.promises.readFile(filePath, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw err; } + return JSON.parse(content) as T; } export async function atomicWriteJSON(filePath: string, data: unknown): Promise { diff --git a/tests/unit/assets/mcp.test.ts b/tests/unit/assets/mcp.test.ts new file mode 100644 index 0000000..7dab639 --- /dev/null +++ b/tests/unit/assets/mcp.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { mcpHandler } from '../../../src/assets/mcp.js'; +import type { InstallJob } from '../../../src/assets/types.js'; +import type { HostConfig } from '../../../src/hosts/types.js'; + +async function makeTmpDir(): Promise { + return fs.promises.mkdtemp(path.join(os.tmpdir(), 'agent-add-mcp-test-')); +} + +function makeHost(configFilePath: string, hostId = 'codex'): HostConfig { + return { + id: hostId, + displayName: hostId, + docs: '', + detection: { paths: [] }, + assets: { + mcp: { + supported: true, + configFile: configFilePath, + configKey: 'mcpServers', + writeStrategy: 'inject-json-key', + }, + skill: { supported: false }, + prompt: { supported: false }, + command: { supported: false }, + subAgent: { supported: false }, + }, + } as HostConfig; +} + +async function cleanup(tmpDir: string): Promise { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); +} + +describe('mcpHandler - TOML hosts (regression)', () => { + it('writes the unwrapped mcpServers name, not the stale pre-unwrap assetName', async () => { + const tmpDir = await makeTmpDir(); + const sourcePath = path.join(tmpDir, 'wrapped-source.json'); + await fs.promises.writeFile( + sourcePath, + JSON.stringify({ mcpServers: { playwright: { command: 'npx', args: ['-y', '@playwright/mcp'] } } }), + ); + const configFilePath = path.join(tmpDir, 'config.toml'); + const host = makeHost(configFilePath); + + const job: InstallJob = { + assetType: 'mcp', + assetName: 'wrapped-source', // the (wrong) name inferred from the source filename + resolvedSource: { type: 'local', localPath: sourcePath, originalSource: sourcePath }, + host, + }; + + const result = await mcpHandler.handle(job); + expect(result.status).toBe('written'); + + const toml = await fs.promises.readFile(configFilePath, 'utf-8'); + expect(toml).toContain('playwright'); + expect(toml).not.toContain('wrapped-source'); + + await cleanup(tmpDir); + }); + + it('reports conflict (not exists) when a differently-configured entry already exists at the same name', async () => { + const tmpDir = await makeTmpDir(); + const sourcePath = path.join(tmpDir, 'playwright.json'); + await fs.promises.writeFile(sourcePath, JSON.stringify({ command: 'npx', args: ['-y', '@playwright/mcp'] })); + const configFilePath = path.join(tmpDir, 'config.toml'); + await fs.promises.writeFile(configFilePath, '[mcp_servers.playwright]\ncommand = "different-command"\n'); + const host = makeHost(configFilePath); + + const job: InstallJob = { + assetType: 'mcp', + assetName: 'playwright', + resolvedSource: { type: 'local', localPath: sourcePath, originalSource: sourcePath }, + host, + }; + + const result = await mcpHandler.handle(job); + expect(result.status).toBe('conflict'); + + await cleanup(tmpDir); + }); + + it('reports exists (not conflict) once the identical entry has already been written', async () => { + const tmpDir = await makeTmpDir(); + const sourcePath = path.join(tmpDir, 'playwright.json'); + await fs.promises.writeFile(sourcePath, JSON.stringify({ command: 'npx' })); + const configFilePath = path.join(tmpDir, 'config.toml'); + const host = makeHost(configFilePath); + const job: InstallJob = { + assetType: 'mcp', + assetName: 'playwright', + resolvedSource: { type: 'local', localPath: sourcePath, originalSource: sourcePath }, + host, + }; + + const first = await mcpHandler.handle(job); + expect(first.status).toBe('written'); + + const second = await mcpHandler.handle(job); + expect(second.status).toBe('exists'); + + await cleanup(tmpDir); + }); + + it('vibe array format: reports conflict for a same-name entry with different content', async () => { + const tmpDir = await makeTmpDir(); + const sourcePath = path.join(tmpDir, 'playwright.json'); + await fs.promises.writeFile(sourcePath, JSON.stringify({ command: 'npx' })); + const configFilePath = path.join(tmpDir, 'config.toml'); + await fs.promises.writeFile( + configFilePath, + '[[mcp_servers]]\nname = "playwright"\ntransport = "stdio"\ncommand = "different"\n', + ); + const host = makeHost(configFilePath, 'vibe'); + const job: InstallJob = { + assetType: 'mcp', + assetName: 'playwright', + resolvedSource: { type: 'local', localPath: sourcePath, originalSource: sourcePath }, + host, + }; + + const result = await mcpHandler.handle(job); + expect(result.status).toBe('conflict'); + + await cleanup(tmpDir); + }); + + it('vibe array format: reports exists once the identical entry has already been written', async () => { + const tmpDir = await makeTmpDir(); + const sourcePath = path.join(tmpDir, 'playwright.json'); + await fs.promises.writeFile(sourcePath, JSON.stringify({ command: 'npx' })); + const configFilePath = path.join(tmpDir, 'config.toml'); + const host = makeHost(configFilePath, 'vibe'); + const job: InstallJob = { + assetType: 'mcp', + assetName: 'playwright', + resolvedSource: { type: 'local', localPath: sourcePath, originalSource: sourcePath }, + host, + }; + + const first = await mcpHandler.handle(job); + expect(first.status).toBe('written'); + const second = await mcpHandler.handle(job); + expect(second.status).toBe('exists'); + + await cleanup(tmpDir); + }); +}); + +describe('mcpHandler - JSON hosts (regression)', () => { + it('reports error instead of silently discarding a malformed existing config', async () => { + const tmpDir = await makeTmpDir(); + const sourcePath = path.join(tmpDir, 'playwright.json'); + await fs.promises.writeFile(sourcePath, JSON.stringify({ command: 'npx' })); + const configFilePath = path.join(tmpDir, 'mcp.json'); + // malformed JSON (trailing comma) — this file has real, pre-existing content + await fs.promises.writeFile(configFilePath, '{"mcpServers":{"existing-tool":{"command":"echo"},}}'); + const host = makeHost(configFilePath); + const job: InstallJob = { + assetType: 'mcp', + assetName: 'playwright', + resolvedSource: { type: 'local', localPath: sourcePath, originalSource: sourcePath }, + host, + }; + + const result = await mcpHandler.handle(job); + expect(result.status).toBe('error'); + + // The malformed file must be left untouched, not silently overwritten + const stillThere = await fs.promises.readFile(configFilePath, 'utf-8'); + expect(stillThere).toContain('existing-tool'); + + await cleanup(tmpDir); + }); +}); diff --git a/tests/unit/assets/skill.test.ts b/tests/unit/assets/skill.test.ts new file mode 100644 index 0000000..31e5d87 --- /dev/null +++ b/tests/unit/assets/skill.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { skillHandler } from '../../../src/assets/skill.js'; +import type { InstallJob } from '../../../src/assets/types.js'; +import type { HostConfig } from '../../../src/hosts/types.js'; + +function makeHost(installDir: string): HostConfig { + return { + id: 'test-host', + displayName: 'Test Host', + docs: '', + detection: { paths: [] }, + assets: { + mcp: { supported: false }, + skill: { supported: true, installDir, entryFile: 'SKILL.md', writeStrategy: 'copy-file' }, + prompt: { supported: false }, + command: { supported: false }, + subAgent: { supported: false }, + }, + } as HostConfig; +} + +describe('skillHandler (regression)', () => { + it('retries an install left incomplete by a previous interrupted run', async () => { + const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'agent-add-skill-test-')); + const sourceDir = path.join(tmpDir, 'source-skill'); + await fs.promises.mkdir(sourceDir, { recursive: true }); + await fs.promises.writeFile(path.join(sourceDir, 'SKILL.md'), '# My Skill\n'); + await fs.promises.writeFile(path.join(sourceDir, 'extra.txt'), 'extra content'); + + const installRoot = path.join(tmpDir, 'installed'); + // Simulate a previous install interrupted mid-copy: the target directory + // exists, but SKILL.md was never written into it. + const targetDir = path.join(installRoot, 'my-skill'); + await fs.promises.mkdir(targetDir, { recursive: true }); + + const host = makeHost(installRoot); + const job: InstallJob = { + assetType: 'skill', + assetName: 'my-skill', + resolvedSource: { type: 'local', localPath: sourceDir, originalSource: sourceDir }, + host, + }; + + const result = await skillHandler.handle(job); + expect(result.status).toBe('written'); + + const installedSkillMd = await fs.promises.readFile(path.join(targetDir, 'SKILL.md'), 'utf-8'); + expect(installedSkillMd).toBe('# My Skill\n'); + + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + }); + + it('reports exists when SKILL.md is already present in the target directory', async () => { + const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'agent-add-skill-test-')); + const sourceDir = path.join(tmpDir, 'source-skill'); + await fs.promises.mkdir(sourceDir, { recursive: true }); + await fs.promises.writeFile(path.join(sourceDir, 'SKILL.md'), '# My Skill\n'); + + const installRoot = path.join(tmpDir, 'installed'); + const targetDir = path.join(installRoot, 'my-skill'); + await fs.promises.mkdir(targetDir, { recursive: true }); + await fs.promises.writeFile(path.join(targetDir, 'SKILL.md'), '# Already installed\n'); + + const host = makeHost(installRoot); + const job: InstallJob = { + assetType: 'skill', + assetName: 'my-skill', + resolvedSource: { type: 'local', localPath: sourceDir, originalSource: sourceDir }, + host, + }; + + const result = await skillHandler.handle(job); + expect(result.status).toBe('exists'); + const content = await fs.promises.readFile(path.join(targetDir, 'SKILL.md'), 'utf-8'); + expect(content).toBe('# Already installed\n'); + + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + }); +}); diff --git a/tests/unit/installer.test.ts b/tests/unit/installer.test.ts new file mode 100644 index 0000000..f9b3d92 --- /dev/null +++ b/tests/unit/installer.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { runInstaller } from '../../src/installer.js'; +import { getHost } from '../../src/hosts/index.js'; + +function listAgentAddTempDirs(): string[] { + return fs + .readdirSync(os.tmpdir()) + .filter((name) => name.startsWith('agent-add-http-')) + .map((name) => path.join(os.tmpdir(), name)); +} + +describe('runInstaller temp-dir cleanup', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('removes downloaded temp dirs even when validation fails', async () => { + const before = new Set(listAgentAddTempDirs()); + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response('not json', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }), + ), + ); + + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as never); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + const host = getHost('claude-code')!; + + await expect( + runInstaller( + { + // .txt extension fails MCP asset validation (must be .json) + mcp: ['https://example.com/not-an-mcp-config.txt'], + skill: [], + prompt: [], + command: [], + subAgent: [], + pack: [], + host: 'claude-code', + }, + host, + process.cwd(), + ), + ).rejects.toThrow('process.exit(2)'); + + expect(exitSpy).toHaveBeenCalledWith(2); + + const after = new Set(listAgentAddTempDirs()); + const leaked = [...after].filter((dir) => !before.has(dir)); + expect(leaked).toEqual([]); + + stderrSpy.mockRestore(); + }); +}); diff --git a/tests/unit/source/infer-name.test.ts b/tests/unit/source/infer-name.test.ts index 58a23ff..38b1d29 100644 --- a/tests/unit/source/infer-name.test.ts +++ b/tests/unit/source/infer-name.test.ts @@ -103,4 +103,25 @@ describe('inferName', () => { expect(inferName('https://github.com/org/repo.git@abc123f')).toBe('repo'); }); }); + + describe('isDirectorySource (skill assets)', () => { + it('should not strip a dotted directory name from a local path (regression)', () => { + expect(inferName('./skills/pdf.js-tools', { isDirectorySource: true })).toBe('pdf.js-tools'); + }); + + it('should not strip a dotted directory name from a git #path last segment (regression)', () => { + expect(inferName('git@github.com:demo/skills.git#pdf.js-tools', { isDirectorySource: true })).toBe( + 'pdf.js-tools', + ); + }); + + it('should still strip .git suffix for a bare repo (whole repo is the skill directory)', () => { + expect(inferName('git@github.com:org/repo.git', { isDirectorySource: true })).toBe('repo'); + }); + + it('should behave identically to the default when the name has no dot', () => { + expect(inferName('./skills/e2e-guide', { isDirectorySource: true })).toBe('e2e-guide'); + expect(inferName('./skills/e2e-guide')).toBe('e2e-guide'); + }); + }); }); diff --git a/tests/unit/source/parse-git-source.test.ts b/tests/unit/source/parse-git-source.test.ts new file mode 100644 index 0000000..a459dff --- /dev/null +++ b/tests/unit/source/parse-git-source.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { parseGitSource } from '../../../src/source/git.js'; + +describe('parseGitSource', () => { + describe('HTTPS URLs', () => { + it('parses a plain repo URL with no ref or subpath', () => { + expect(parseGitSource('https://github.com/org/repo.git')).toEqual({ + repoUrl: 'https://github.com/org/repo.git', + ref: undefined, + subPath: undefined, + }); + }); + + it('parses @ref', () => { + expect(parseGitSource('https://github.com/org/repo.git@v1.0.0')).toEqual({ + repoUrl: 'https://github.com/org/repo.git', + ref: 'v1.0.0', + subPath: undefined, + }); + }); + + it('parses #subPath', () => { + expect(parseGitSource('https://github.com/org/repo.git#skills/pdf')).toEqual({ + repoUrl: 'https://github.com/org/repo.git', + ref: undefined, + subPath: 'skills/pdf', + }); + }); + + it('parses #subPath@ref', () => { + expect(parseGitSource('https://github.com/org/repo.git#skills/pdf@main')).toEqual({ + repoUrl: 'https://github.com/org/repo.git', + ref: 'main', + subPath: 'skills/pdf', + }); + }); + + it('does not mistake a token embedded as userinfo for the ref separator', () => { + expect(parseGitSource('https://ghp_token123@github.com/org/repo.git@v1.0.0')).toEqual({ + repoUrl: 'https://ghp_token123@github.com/org/repo.git', + ref: 'v1.0.0', + subPath: undefined, + }); + }); + + it('does not mistake user:pass credentials for the ref separator', () => { + expect(parseGitSource('https://user:pass@github.com/org/repo.git')).toEqual({ + repoUrl: 'https://user:pass@github.com/org/repo.git', + ref: undefined, + subPath: undefined, + }); + }); + + it('parses the documented @ref#path ordering', () => { + expect(parseGitSource('https://github.com/org/repo.git@v1.0#path/to/skill')).toEqual({ + repoUrl: 'https://github.com/org/repo.git', + ref: 'v1.0', + subPath: 'path/to/skill', + }); + }); + + it('preserves credentials and still parses ref + subpath together', () => { + expect( + parseGitSource('https://user:pass@github.com/org/repo.git#skills/pdf@main'), + ).toEqual({ + repoUrl: 'https://user:pass@github.com/org/repo.git', + ref: 'main', + subPath: 'skills/pdf', + }); + }); + }); + + describe('SSH URLs', () => { + it('parses a plain repo URL with no ref', () => { + expect(parseGitSource('git@github.com:org/repo.git')).toEqual({ + repoUrl: 'git@github.com:org/repo.git', + ref: undefined, + subPath: undefined, + }); + }); + + it('parses @ref, skipping the leading git@ prefix', () => { + expect(parseGitSource('git@github.com:org/repo.git@v1.0.0')).toEqual({ + repoUrl: 'git@github.com:org/repo.git', + ref: 'v1.0.0', + subPath: undefined, + }); + }); + }); +}); diff --git a/tests/unit/source/uri-detect.test.ts b/tests/unit/source/uri-detect.test.ts index 95387c5..503c09c 100644 --- a/tests/unit/source/uri-detect.test.ts +++ b/tests/unit/source/uri-detect.test.ts @@ -105,5 +105,21 @@ describe('detectSourceType', () => { it('https URL with # but no .git is git-https', () => { expect(detectSourceType('https://github.com/org/repo#main/src')).toBe('git-https'); }); + + it('plain .md file URL with a # fragment is http-file, not git-https (regression)', () => { + expect(detectSourceType('https://example.com/prompt.md#usage')).toBe('http-file'); + }); + + it('plain .json file URL with a # fragment is http-file, not git-https (regression)', () => { + expect(detectSourceType('https://example.com/config.json#section')).toBe('http-file'); + }); + + it('plain .md file URL with a query string and # fragment is http-file', () => { + expect(detectSourceType('https://example.com/prompt.md?raw=1#usage')).toBe('http-file'); + }); + + it('non-.git repo URL with # subPath ending in .json is still git-https when the repo itself has no extension', () => { + expect(detectSourceType('https://git.example.com/org/repo#configs/settings.json')).toBe('git-https'); + }); }); }); diff --git a/tests/unit/utils/read-json-or-null.test.ts b/tests/unit/utils/read-json-or-null.test.ts new file mode 100644 index 0000000..a96cbca --- /dev/null +++ b/tests/unit/utils/read-json-or-null.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { readJSONOrNull } from '../../../src/utils/fs.js'; + +describe('readJSONOrNull', () => { + it('returns null when the file does not exist', async () => { + const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'agent-add-test-')); + const result = await readJSONOrNull(path.join(tmpDir, 'missing.json')); + expect(result).toBeNull(); + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns the parsed object for valid JSON', async () => { + const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'agent-add-test-')); + const filePath = path.join(tmpDir, 'valid.json'); + await fs.promises.writeFile(filePath, JSON.stringify({ key: 'value' })); + const result = await readJSONOrNull(filePath); + expect(result).toEqual({ key: 'value' }); + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + }); + + it('throws (does not silently return null) for a file with malformed JSON (regression)', async () => { + // Regression: previously any error (including a JSON.parse failure on an + // existing-but-malformed file) was swallowed and treated the same as a + // missing file, which let callers default to {} and silently overwrite/ + // destroy the malformed file's actual content. + const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'agent-add-test-')); + const filePath = path.join(tmpDir, 'malformed.json'); + await fs.promises.writeFile(filePath, '{"mcpServers":{"existing-tool":{"command":"echo"},}}'); + await expect(readJSONOrNull(filePath)).rejects.toThrow(); + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + }); +});