diff --git a/docs/resources/(resources)/claude-code-project.mdx b/docs/resources/(resources)/claude-code-project.mdx new file mode 100644 index 0000000..ee4cb07 --- /dev/null +++ b/docs/resources/(resources)/claude-code-project.mdx @@ -0,0 +1,119 @@ +--- +title: claude-code-project +description: A reference page for the claude-code-project resource +--- + +The claude-code-project resource manages **per-project** Claude Code configuration. It writes project-scoped instructions, settings, and MCP servers under a specific directory — leaving global configuration untouched. Use it alongside the [`claude-code`](/docs/resources/claude-code/claude-code) resource, which handles installation. + +## Parameters + +- **directory**: *(string, required)* Path to the project directory. All configuration files are written relative to this path: + - `/.claude/CLAUDE.md` + - `/.claude/settings.json` + - `/.claude.json` + +- **claudeMd**: *(string, optional)* Content for `/.claude/CLAUDE.md`. Accepts inline text, an `https://` URL, or a `codify://documentId:fileId` cloud URL. Claude Code reads this at the start of every session within the project, making it ideal for project-specific conventions, preferred libraries, and review checklists. + +- **settings**: *(object, optional)* Key-value pairs to merge into `/.claude/settings.json`. On apply, the declared keys are written; on destroy, only the declared keys are removed. Supports the same keys as the global settings: + - `model` — override the default Claude model for this project + - `effortLevel` — `"low"` | `"medium"` | `"high"` | `"xhigh"` + - `editorMode` — `"normal"` | `"vim"` + - `permissions` — `{ allow: [...], deny: [...] }` + - `env` — environment variables injected into every session + - `hooks` — lifecycle hooks (PreToolUse, PostToolUse, SessionStart, etc.) + +- **mcpServers**: *(array, optional)* MCP servers to register for this project in `/.claude.json`. Each entry requires a `name` and `type`, plus transport-specific fields: + - **stdio**: `{ name, type: "stdio", command, args?, env? }` — local process server + - **http**: `{ name, type: "http", url, headers? }` — remote HTTP (streamable-http) server + - **sse**: `{ name, type: "sse", url, headers? }` — remote SSE server (deprecated; prefer http) + +## Example usage + +### Per-project instructions and permissions + +```json title="codify.jsonc" +[ + { + "type": "claude-code-project", + "directory": "~/projects/my-api", + "claudeMd": "# Project Instructions\n\nThis is a Node.js API. Always use async/await.\nRun `npm test` before committing.", + "settings": { + "permissions": { + "allow": ["Bash(npm run *)", "Bash(git *)"], + "deny": ["Bash(rm -rf *)"] + } + } + } +] +``` + +### Per-project instructions with an MCP server + +```json title="codify.jsonc" +[ + { + "type": "claude-code-project", + "directory": "~/projects/my-api", + "claudeMd": "# Project Instructions\n\nAlways check types with `npm run typecheck` before submitting.", + "mcpServers": [ + { + "name": "project-db", + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"] + } + ] + } +] +``` + +### Per-project CLAUDE.md from a remote URL + +```json title="codify.jsonc" +[ + { + "type": "claude-code-project", + "directory": "~/projects/my-api", + "claudeMd": "codify://my-document-id:my-file-id" + } +] +``` + +Or from a public HTTPS URL: + +```json title="codify.jsonc" +[ + { + "type": "claude-code-project", + "directory": "~/projects/my-api", + "claudeMd": "https://raw.githubusercontent.com/my-org/dotfiles/main/CLAUDE.md" + } +] +``` + +### Global install + per-project config together + +```json title="codify.jsonc" +[ + { + "type": "claude-code", + "settings": { + "model": "claude-opus-4-7" + } + }, + { + "type": "claude-code-project", + "directory": "~/projects/my-api", + "claudeMd": "# My API\n\nNode.js + TypeScript. Run `npm test` before any commit." + } +] +``` + +## Notes + +- The `claude-code` resource must be applied before `claude-code-project` (it declares a dependency automatically). If Claude Code is not installed, this resource will report as not present. +- Multiple `claude-code-project` entries can coexist — each unique `directory` is a separate resource instance. +- Destroying a `claude-code-project` resource removes only the per-project files (`CLAUDE.md`, the declared `settings` keys, and the declared `mcpServers`). The Claude Code binary and global configuration are left untouched. +- The `settings` parameter merges only the declared keys. Existing project settings not in your Codify config are left untouched. +- The `claudeMd` parameter manages the entire file. On destroy, the file is removed. +- MCP servers are stored in `/.claude.json` under the `mcpServers` key. Removing an MCP server from your config removes it from the file; other servers are untouched. diff --git a/docs/resources/(resources)/claude-code.mdx b/docs/resources/(resources)/claude-code.mdx new file mode 100644 index 0000000..f9d8dab --- /dev/null +++ b/docs/resources/(resources)/claude-code.mdx @@ -0,0 +1,99 @@ +--- +title: claude-code +description: A reference page for the claude-code resource +--- + +The claude-code resource installs [Claude Code](https://code.claude.com) — Anthropic's agentic coding assistant — and manages its configuration. It handles installation via the official installer script and gives you declarative control over settings, MCP servers, and global instructions. + +## Parameters + +- **globalClaudeMd**: *(string, optional)* Content to write to `~/.claude/CLAUDE.md`. Claude Code reads this file at the start of every session, making it ideal for global coding standards, preferred libraries, and review checklists that apply to all projects. + +- **settings**: *(object, optional)* Key-value pairs to merge into `~/.claude/settings.json`. On apply, the declared keys are written; on destroy, only the declared keys are removed. Common settings include: + - `model` — override the default Claude model + - `effortLevel` — `"low"` | `"medium"` | `"high"` | `"xhigh"` + - `editorMode` — `"normal"` | `"vim"` + - `permissions` — `{ allow: [...], deny: [...] }` + - `env` — environment variables injected into every session + - `hooks` — lifecycle hooks (PreToolUse, PostToolUse, SessionStart, etc.) + - `autoMemoryEnabled` — enable/disable auto memory (default: `true`) + +- **mcpServers**: *(array, optional)* MCP servers to register globally in `~/.claude.json`. Each entry requires a `name` and `type`, plus transport-specific fields: + - **stdio**: `{ name, type: "stdio", command, args?, env? }` — local process server + - **http**: `{ name, type: "http", url, headers? }` — remote HTTP (streamable-http) server + - **sse**: `{ name, type: "sse", url, headers? }` — remote SSE server (deprecated; prefer http) + +## Example usage + +### Install Claude Code with custom settings + +```json title="codify.jsonc" +[ + { + "type": "claude-code", + "settings": { + "model": "claude-opus-4-7", + "effortLevel": "high", + "editorMode": "vim", + "permissions": { + "allow": ["Bash(npm run *)", "Bash(git *)"], + "deny": ["Bash(rm -rf *)"] + } + } + } +] +``` + +### Claude Code with global instructions and an MCP server + +```json title="codify.jsonc" +[ + { + "type": "claude-code", + "globalClaudeMd": "# Global Instructions\n\nAlways follow security best practices.\nPrefer TypeScript over JavaScript.", + "mcpServers": [ + { + "name": "filesystem", + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + } + ] + } +] +``` + +### Claude Code with hooks + +```json title="codify.jsonc" +[ + { + "type": "claude-code", + "settings": { + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "npx", + "args": ["eslint", "--fix", "${tool_input.file_path}"] + } + ] + } + ] + } + } + } +] +``` + +## Notes + +- Claude Code is installed via the official installer (`curl -fsSL https://claude.ai/install.sh | bash`) on both macOS and Linux. The binary is placed at `~/.local/bin/claude`. +- The installer adds `~/.local/bin` to your PATH via your shell RC file (`.bashrc` or `.zshrc`). This entry remains after destroy — remove it manually if you no longer want it. +- The `settings` parameter merges only the declared keys. Existing settings not in your Codify config are left untouched. +- The `globalClaudeMd` parameter manages the entire file. On destroy, the file is removed. +- MCP servers are stored in `~/.claude.json` under the `mcpServers` key. Each server's `name` becomes its key in that object. Removing an MCP server from your config removes it from the file; other servers are untouched. +- To see all available settings, run `claude config list` or visit the [settings reference](https://code.claude.com/docs/en/settings). diff --git a/package.json b/package.json index 2e31381..4bf8c58 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "default", - "version": "1.1.2", + "version": "1.2.0", "description": "Default plugin for Codify - provides 50+ declarative resources for managing development tools and system configuration across macOS and Linux", "main": "dist/index.js", "scripts": { diff --git a/scripts/deploy.ts b/scripts/deploy.ts index e281f7e..6df530f 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -150,4 +150,4 @@ async function uploadResources(prerelease: boolean) { .upsert(parameters, { onConflict: 'name,resource_id,prerelease' }) .throwOnError(); } -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index 076647c..d0aee60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,8 @@ import { FnmResource } from './resources/javascript/fast-node-manager/fast-node- import { NvmResource } from './resources/javascript/nvm/nvm.js'; import { Pnpm } from './resources/javascript/pnpm/pnpm.js'; import { MacportsResource } from './resources/macports/macports.js'; +import { ClaudeCodeResource } from './resources/claude-code/claude-code.js'; +import { ClaudeCodeProjectResource } from './resources/claude-code/claude-code-project.js'; import { OllamaResource } from './resources/ollama/ollama.js'; import { PgcliResource } from './resources/pgcli/pgcli.js'; import { Pip } from './resources/python/pip/pip.js'; @@ -108,6 +110,8 @@ runPlugin(Plugin.create( new SnapResource(), new TartResource(), new TartVmResource(), + new ClaudeCodeResource(), + new ClaudeCodeProjectResource(), new OllamaResource(), new SyncthingResource(), new SyncthingDeviceResource(), diff --git a/src/resources/claude-code/claude-code-project.ts b/src/resources/claude-code/claude-code-project.ts new file mode 100644 index 0000000..8bade65 --- /dev/null +++ b/src/resources/claude-code/claude-code-project.ts @@ -0,0 +1,231 @@ +import { + CodifyCliSender, + CreatePlan, + DestroyPlan, + ExampleConfig, + ModifyPlan, + ParameterChange, + Resource, + ResourceSettings, + z, +} from '@codifycli/plugin-core'; +import { OS } from '@codifycli/schemas'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { untildify } from '../../utils/untildify.js'; +import { McpServersParameter } from './mcp-servers-parameter.js'; +import { SettingsParameter } from './settings-parameter.js'; +import { mcpServerSchema } from './claude-code.js'; + +const schema = z + .object({ + directory: z + .string() + .describe( + 'Path to the project directory. All configuration is written under /.claude/ ' + + 'and /.claude.json.', + ), + claudeMd: z + .string() + .optional() + .describe( + 'Content for /.claude/CLAUDE.md. Accepts inline text, an https:// URL, ' + + 'or a codify:// cloud URL (e.g. codify://documentId:fileId).', + ), + settings: z + .record(z.string(), z.unknown()) + .optional() + .describe( + 'Settings to merge into /.claude/settings.json. Supports the same keys as the ' + + 'global settings: model, effortLevel, editorMode, permissions, env, hooks, etc.', + ), + mcpServers: z + .array(mcpServerSchema) + .optional() + .describe('MCP servers to register for this project in /.claude.json.'), + }) + .meta({ $comment: 'https://codifycli.com/docs/resources/claude-code/claude-code-project' }) + .describe('Per-project Claude Code configuration'); + +export type ClaudeCodeProjectConfig = z.infer; + +const defaultConfig: Partial = { + mcpServers: [], +}; + +const examplePerProject: ExampleConfig = { + title: 'Per-project instructions and settings', + description: 'Add project-specific CLAUDE.md instructions and scoped tool permissions for a single repository.', + configs: [ + { + type: 'claude-code-project', + directory: '~/projects/my-api', + claudeMd: '# Project Instructions\n\nThis is a Node.js API. Always use async/await.\nRun `npm test` before committing.', + settings: { + permissions: { + allow: ['Bash(npm run *)', 'Bash(git *)'], + deny: ['Bash(rm -rf *)'], + }, + }, + }, + ], +}; + +const exampleWithMcp: ExampleConfig = { + title: 'Per-project instructions with MCP server', + description: 'Configure per-project CLAUDE.md and a project-scoped MCP server for database access.', + configs: [ + { + type: 'claude-code-project', + directory: '~/projects/my-api', + claudeMd: '# Project Instructions\n\nAlways check types with `npm run typecheck` before submitting.', + mcpServers: [ + { + name: 'project-db', + type: 'stdio', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-postgres', 'postgresql://localhost/mydb'], + }, + ], + }, + ], +}; + +function resolveClaudeDir(directory: string): string { + return path.join(untildify(directory), '.claude'); +} + +function resolveClaudeMdPath(directory: string): string { + return path.join(resolveClaudeDir(directory), 'CLAUDE.md'); +} + +export class ClaudeCodeProjectResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'claude-code-project', + schema, + defaultConfig, + exampleConfigs: { + example1: examplePerProject, + example2: exampleWithMcp, + }, + operatingSystems: [OS.Darwin, OS.Linux], + dependencies: ['claude-code'], + parameterSettings: { + directory: { type: 'directory', canModify: false }, + claudeMd: { canModify: true }, + settings: { type: 'stateful', definition: new SettingsParameter(), order: 1 }, + mcpServers: { type: 'stateful', definition: new McpServersParameter(), order: 2 }, + }, + allowMultiple: { + identifyingParameters: ['directory'], + }, + removeStatefulParametersBeforeDestroy: true, + }; + } + + async refresh(parameters: Partial): Promise | null> { + if (!parameters.directory) { + return null; + } + + // Use the .claude dir as the existence marker. Return null (not installed) if it doesn't exist. + try { + await fs.access(resolveClaudeDir(parameters.directory)); + } catch { + return null; + } + + // Start from parameters so identifying fields (directory) are present in the result, + // preventing the framework from re-planning a CREATE on every validation pass. + const result: Partial = { ...parameters }; + + if (parameters.claudeMd !== undefined) { + if (isRemoteUrl(parameters.claudeMd)) { + // For remote URLs, keep the URL as-is so the framework compares URL vs URL. + // Change detection for remote content is done via hash on apply. + result.claudeMd = parameters.claudeMd; + } else { + try { + result.claudeMd = await fs.readFile(resolveClaudeMdPath(parameters.directory), 'utf8'); + } catch { + result.claudeMd = undefined; + } + } + } + + return result; + } + + async create(plan: CreatePlan): Promise { + const { directory, claudeMd } = plan.desiredConfig; + if (directory) { + await fs.mkdir(resolveClaudeDir(directory), { recursive: true }); + } + if (claudeMd && directory) { + await this.writeClaudeMd(claudeMd, directory); + } + } + + async modify( + pc: ParameterChange, + plan: ModifyPlan, + ): Promise { + if (pc.name === 'claudeMd') { + const { directory, claudeMd } = plan.desiredConfig; + if (claudeMd && directory) { + await this.writeClaudeMd(claudeMd, directory); + } else if (directory) { + await fs.rm(resolveClaudeMdPath(directory), { force: true }); + } + } + } + + async destroy(plan: DestroyPlan): Promise { + const { directory } = plan.currentConfig; + if (!directory) return; + + await fs.rm(resolveClaudeMdPath(directory), { force: true }); + await fs.rm(resolveClaudeDir(directory), { recursive: true, force: true }); + } + + private async writeClaudeMd(content: string, directory: string): Promise { + const claudeDir = resolveClaudeDir(directory); + await fs.mkdir(claudeDir, { recursive: true }); + const resolved = await resolveClaudeMdContent(content); + await fs.writeFile(resolveClaudeMdPath(directory), resolved, 'utf8'); + } +} + +function isRemoteUrl(value: string): boolean { + return value.startsWith('https://') || value.startsWith('http://') || value.startsWith('codify://'); +} + +async function resolveClaudeMdContent(content: string): Promise { + if (content.startsWith('codify://')) { + const regex = /codify:\/\/(.*):(.*)/; + const [, documentId, fileId] = regex.exec(content) ?? []; + if (!documentId || !fileId) { + throw new Error(`Invalid codify URL for claudeMd: ${content}`); + } + const credentials = await CodifyCliSender.getCodifyCliCredentials(); + const response = await fetch(`https://api.codifycli.com/v1/documents/${documentId}/file/${fileId}`, { + headers: { Authorization: `Bearer ${credentials}` }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch claudeMd from ${content}: ${response.statusText}`); + } + return response.text(); + } + + if (content.startsWith('https://') || content.startsWith('http://')) { + const response = await fetch(content); + if (!response.ok) { + throw new Error(`Failed to fetch claudeMd from ${content}: ${response.statusText}`); + } + return response.text(); + } + + return content; +} diff --git a/src/resources/claude-code/claude-code.ts b/src/resources/claude-code/claude-code.ts new file mode 100644 index 0000000..286aad3 --- /dev/null +++ b/src/resources/claude-code/claude-code.ts @@ -0,0 +1,246 @@ +import { + CodifyCliSender, + CreatePlan, + DestroyPlan, + ExampleConfig, + ModifyPlan, + ParameterChange, + Resource, + ResourceSettings, + getPty, + z, +} from '@codifycli/plugin-core'; +import { OS } from '@codifycli/schemas'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { McpServersParameter } from './mcp-servers-parameter.js'; +import { SettingsParameter } from './settings-parameter.js'; + +const CLAUDE_DIR = path.join(os.homedir(), '.claude'); +const CLAUDE_MD_PATH = path.join(CLAUDE_DIR, 'CLAUDE.md'); + +const mcpStdioServerSchema = z.object({ + name: z.string().describe('Unique name for this MCP server'), + type: z.literal('stdio'), + command: z.string().describe('Executable or command to launch the server process'), + args: z.array(z.string()).optional().describe('Arguments to pass to the command'), + env: z.record(z.string(), z.string()).optional().describe('Environment variables for the server process'), +}); + +const mcpHttpServerSchema = z.object({ + name: z.string().describe('Unique name for this MCP server'), + type: z.literal('http'), + url: z.string().describe('URL of the HTTP (streamable-http) MCP server'), + headers: z.record(z.string(), z.string()).optional().describe('HTTP headers sent with every request'), +}); + +const mcpSseServerSchema = z.object({ + name: z.string().describe('Unique name for this MCP server'), + type: z.literal('sse'), + url: z.string().describe('URL of the SSE MCP server (deprecated transport; prefer http)'), + headers: z.record(z.string(), z.string()).optional().describe('HTTP headers sent with every request'), +}); + +export const mcpServerSchema = z.discriminatedUnion('type', [ + mcpStdioServerSchema, + mcpHttpServerSchema, + mcpSseServerSchema, +]); + +export type McpServer = z.infer; + +const schema = z + .object({ + globalClaudeMd: z + .string() + .optional() + .describe( + 'Content for ~/.claude/CLAUDE.md. Accepts inline text, an https:// URL, or a ' + + 'codify:// cloud URL (e.g. codify://documentId:fileId). Claude Code reads this at ' + + 'the start of every session.', + ), + settings: z + .record(z.string(), z.unknown()) + .optional() + .describe( + 'Settings to merge into ~/.claude/settings.json. Supports model, effortLevel, ' + + 'editorMode, permissions, env, hooks, and all other Claude Code settings.', + ), + mcpServers: z + .array(mcpServerSchema) + .optional() + .describe('MCP servers to register globally in ~/.claude.json.'), + }) + .meta({ $comment: 'https://codifycli.com/docs/resources/claude-code/claude-code' }) + .describe('Claude Code installation and configuration management'); + +export type ClaudeCodeConfig = z.infer; + +const defaultConfig: Partial = { + mcpServers: [], +}; + +const exampleSettings: ExampleConfig = { + title: 'Claude Code with custom settings', + description: 'Install Claude Code and configure model selection, editor mode, and shell permissions.', + configs: [ + { + type: 'claude-code', + settings: { + model: 'claude-opus-4-7', + effortLevel: 'high', + editorMode: 'vim', + permissions: { + allow: ['Bash(npm run *)', 'Bash(git *)'], + deny: ['Bash(rm -rf *)'], + }, + }, + }, + ], +}; + +const exampleWithMcp: ExampleConfig = { + title: 'Claude Code with global instructions and MCP', + description: 'Install Claude Code, set global instructions via CLAUDE.md, and wire up an MCP server.', + configs: [ + { + type: 'claude-code', + globalClaudeMd: + '# Global Instructions\n\nAlways follow security best practices.\nPrefer TypeScript over JavaScript.', + mcpServers: [ + { + name: 'filesystem', + type: 'stdio', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'], + }, + ], + }, + ], +}; + +export class ClaudeCodeResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'claude-code', + defaultConfig, + exampleConfigs: { + example1: exampleSettings, + example2: exampleWithMcp, + }, + operatingSystems: [OS.Darwin, OS.Linux], + schema, + parameterSettings: { + globalClaudeMd: { canModify: true }, + settings: { type: 'stateful', definition: new SettingsParameter(), order: 1 }, + mcpServers: { type: 'stateful', definition: new McpServersParameter(), order: 2 }, + }, + }; + } + + async refresh(parameters: Partial): Promise | null> { + const claudeBin = path.join(os.homedir(), '.local', 'bin', 'claude'); + try { + await fs.access(claudeBin); + } catch { + return null; + } + + const result: Partial = {}; + + if (parameters.globalClaudeMd !== undefined) { + if (isRemoteUrl(parameters.globalClaudeMd)) { + result.globalClaudeMd = parameters.globalClaudeMd; + } else { + try { + result.globalClaudeMd = await fs.readFile(CLAUDE_MD_PATH, 'utf8'); + } catch { + result.globalClaudeMd = undefined; + } + } + } + + return result; + } + + async create(plan: CreatePlan): Promise { + const $ = getPty(); + + await $.spawn( + 'bash -c "curl -fsSL https://claude.ai/install.sh | bash"', + { interactive: true }, + ); + + // Ensure PATH is updated so subsequent lifecycle methods can call `claude` + const localBin = path.join(os.homedir(), '.local', 'bin'); + process.env['PATH'] = `${localBin}:${process.env['PATH'] ?? ''}`; + + if (plan.desiredConfig.globalClaudeMd) { + await this.writeClaudeMd(plan.desiredConfig.globalClaudeMd); + } + } + + async modify( + pc: ParameterChange, + plan: ModifyPlan, + ): Promise { + if (pc.name === 'globalClaudeMd') { + const newValue = plan.desiredConfig.globalClaudeMd; + if (newValue) { + await this.writeClaudeMd(newValue); + } else { + await fs.rm(CLAUDE_MD_PATH, { force: true }); + } + } + } + + async destroy(plan: DestroyPlan): Promise { + if (plan.currentConfig.globalClaudeMd) { + await fs.rm(CLAUDE_MD_PATH, { force: true }); + } + + // Native uninstall: remove the binary and version files + await fs.rm(path.join(os.homedir(), '.local', 'bin', 'claude'), { force: true }); + await fs.rm(path.join(os.homedir(), '.local', 'share', 'claude'), { recursive: true, force: true }); + } + + private async writeClaudeMd(content: string): Promise { + await fs.mkdir(CLAUDE_DIR, { recursive: true }); + const resolved = await resolveClaudeMdContent(content); + await fs.writeFile(CLAUDE_MD_PATH, resolved, 'utf8'); + } +} + +function isRemoteUrl(value: string): boolean { + return value.startsWith('https://') || value.startsWith('http://') || value.startsWith('codify://'); +} + +async function resolveClaudeMdContent(content: string): Promise { + if (content.startsWith('codify://')) { + const regex = /codify:\/\/(.*):(.*)/; + const [, documentId, fileId] = regex.exec(content) ?? []; + if (!documentId || !fileId) { + throw new Error(`Invalid codify URL for claudeMd: ${content}`); + } + const credentials = await CodifyCliSender.getCodifyCliCredentials(); + const response = await fetch(`https://api.codifycli.com/v1/documents/${documentId}/file/${fileId}`, { + headers: { Authorization: `Bearer ${credentials}` }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch claudeMd from ${content}: ${response.statusText}`); + } + return response.text(); + } + + if (content.startsWith('https://') || content.startsWith('http://')) { + const response = await fetch(content); + if (!response.ok) { + throw new Error(`Failed to fetch claudeMd from ${content}: ${response.statusText}`); + } + return response.text(); + } + + return content; +} diff --git a/src/resources/claude-code/mcp-servers-parameter.ts b/src/resources/claude-code/mcp-servers-parameter.ts new file mode 100644 index 0000000..c06c90b --- /dev/null +++ b/src/resources/claude-code/mcp-servers-parameter.ts @@ -0,0 +1,75 @@ +import { ArrayStatefulParameter, Plan } from '@codifycli/plugin-core'; +import { StringIndexedObject } from '@codifycli/schemas'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { untildify } from '../../utils/untildify.js'; +import { McpServer } from './claude-code.js'; + +export function resolveClaudeJsonPath(directory?: string): string { + if (directory) return path.join(untildify(directory), '.claude.json'); + return path.join(os.homedir(), '.claude.json'); +} + +export class McpServersParameter extends ArrayStatefulParameter { + override getSettings() { + return { + type: 'array' as const, + isElementEqual: (a: McpServer, b: McpServer) => a.name === b.name, + }; + } + + async refresh(_desired: McpServer[] | null, config: Partial): Promise { + const configPath = resolveClaudeJsonPath(config['directory'] as string | undefined); + try { + const content = await fs.readFile(configPath, 'utf8'); + const parsed = JSON.parse(content) as { mcpServers?: Record }; + + if (!parsed.mcpServers || typeof parsed.mcpServers !== 'object') { + return []; + } + + return Object.entries(parsed.mcpServers).map(([name, serverConfig]) => ({ + name, + ...(serverConfig as object), + })) as McpServer[]; + } catch { + return []; + } + } + + async addItem(item: McpServer, plan: Plan): Promise { + const { name, ...serverConfig } = item; + await this.mutateMcpServers((servers) => { + servers[name] = serverConfig; + }, plan.desiredConfig?.['directory'] as string | undefined); + } + + async removeItem(item: McpServer, plan: Plan): Promise { + const directory = (plan.currentConfig?.['directory'] ?? plan.desiredConfig?.['directory']) as string | undefined; + await this.mutateMcpServers((servers) => { + delete servers[item.name]; + }, directory); + } + + private async mutateMcpServers( + mutate: (servers: Record) => void, + directory?: string, + ): Promise { + const configPath = resolveClaudeJsonPath(directory); + let config: Record = {}; + try { + const content = await fs.readFile(configPath, 'utf8'); + config = JSON.parse(content) as Record; + } catch { /* file may not exist yet */ } + + if (!config['mcpServers'] || typeof config['mcpServers'] !== 'object') { + config['mcpServers'] = {}; + } + + mutate(config['mcpServers'] as Record); + + await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8'); + } +} diff --git a/src/resources/claude-code/settings-parameter.ts b/src/resources/claude-code/settings-parameter.ts new file mode 100644 index 0000000..aacb85f --- /dev/null +++ b/src/resources/claude-code/settings-parameter.ts @@ -0,0 +1,76 @@ +import { ParameterSetting, Plan, StatefulParameter } from '@codifycli/plugin-core'; +import { StringIndexedObject } from '@codifycli/schemas'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { untildify } from '../../utils/untildify.js'; + +type Settings = Record; + +export function resolveSettingsPath(directory?: string): string { + if (directory) return path.join(untildify(directory), '.claude', 'settings.json'); + return path.join(os.homedir(), '.claude', 'settings.json'); +} + +export class SettingsParameter extends StatefulParameter { + getSettings(): ParameterSetting { + return { type: 'object' }; + } + + override async refresh(_desired: Settings | null, config: Partial): Promise { + const filePath = resolveSettingsPath(config['directory'] as string | undefined); + try { + const content = await fs.readFile(filePath, 'utf8'); + return JSON.parse(content) as Settings; + } catch { + return null; + } + } + + async add(valueToAdd: Settings, plan: Plan): Promise { + await this.mergeIntoFile(valueToAdd, plan.desiredConfig?.['directory'] as string | undefined); + } + + async modify(newValue: Settings, previousValue: Settings, plan: Plan): Promise { + const filePath = resolveSettingsPath(plan.desiredConfig?.['directory'] as string | undefined); + let existing: Settings = {}; + try { + existing = JSON.parse(await fs.readFile(filePath, 'utf8')); + } catch { /* file may not exist */ } + + for (const key of Object.keys(previousValue)) { + if (!(key in newValue)) { + delete existing[key]; + } + } + + Object.assign(existing, newValue); + + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(existing, null, 2)); + } + + async remove(valueToRemove: Settings, plan: Plan): Promise { + const directory = (plan.currentConfig?.['directory'] ?? plan.desiredConfig?.['directory']) as string | undefined; + const filePath = resolveSettingsPath(directory); + try { + const existing = JSON.parse(await fs.readFile(filePath, 'utf8')) as Settings; + for (const key of Object.keys(valueToRemove)) { + delete existing[key]; + } + await fs.writeFile(filePath, JSON.stringify(existing, null, 2)); + } catch { /* nothing to do if file doesn't exist */ } + } + + private async mergeIntoFile(settings: Settings, directory?: string): Promise { + const filePath = resolveSettingsPath(directory); + let existing: Settings = {}; + try { + existing = JSON.parse(await fs.readFile(filePath, 'utf8')); + } catch { /* file may not exist yet */ } + + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, JSON.stringify({ ...existing, ...settings }, null, 2)); + } +} diff --git a/test/claude-code/claude-code-project.test.ts b/test/claude-code/claude-code-project.test.ts new file mode 100644 index 0000000..f422cc5 --- /dev/null +++ b/test/claude-code/claude-code-project.test.ts @@ -0,0 +1,161 @@ +import { PluginTester } from '@codifycli/plugin-test'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const TEST_DIR = path.join(os.tmpdir(), 'codify-claude-code-project-test'); +const CLAUDE_MD_PATH = path.join(TEST_DIR, '.claude', 'CLAUDE.md'); +const CLAUDE_SETTINGS_PATH = path.join(TEST_DIR, '.claude', 'settings.json'); +const CLAUDE_JSON_PATH = path.join(TEST_DIR, '.claude.json'); + +describe('claude-code-project resource integration tests', async () => { + const pluginPath = path.resolve('./src/index.ts'); + + beforeAll(async () => { + await fs.mkdir(TEST_DIR, { recursive: true }); + }); + + afterAll(async () => { + await fs.rm(TEST_DIR, { recursive: true, force: true }); + }); + + it('Can manage claudeMd for a project directory', { timeout: 120_000 }, async () => { + const initialContent = '# Project Instructions\n\nAlways write tests.'; + const modifiedContent = '# Project Instructions\n\nAlways write tests.\nPrefer TypeScript.'; + + await PluginTester.fullTest( + pluginPath, + [{ type: 'claude-code-project', directory: TEST_DIR, claudeMd: initialContent }], + { + validateApply: async () => { + const content = await fs.readFile(CLAUDE_MD_PATH, 'utf8'); + expect(content).toBe(initialContent); + }, + testModify: { + modifiedConfigs: [{ type: 'claude-code-project', directory: TEST_DIR, claudeMd: modifiedContent }], + validateModify: async () => { + const content = await fs.readFile(CLAUDE_MD_PATH, 'utf8'); + expect(content).toBe(modifiedContent); + }, + }, + validateDestroy: async () => { + const exists = await fs.access(CLAUDE_MD_PATH).then(() => true).catch(() => false); + expect(exists).toBe(false); + }, + }, + ); + }); + + it('Can manage per-project settings', { timeout: 120_000 }, async () => { + const initialSettings = { + editorMode: 'vim', + spinnerTipsEnabled: false, + }; + + const modifiedSettings = { + editorMode: 'normal', + spinnerTipsEnabled: false, + }; + + await PluginTester.fullTest( + pluginPath, + [{ type: 'claude-code-project', directory: TEST_DIR, settings: initialSettings }], + { + validateApply: async () => { + const content = await fs.readFile(CLAUDE_SETTINGS_PATH, 'utf8'); + const parsed = JSON.parse(content); + expect(parsed.editorMode).toBe('vim'); + expect(parsed.spinnerTipsEnabled).toBe(false); + }, + testModify: { + modifiedConfigs: [{ type: 'claude-code-project', directory: TEST_DIR, settings: modifiedSettings }], + validateModify: async () => { + const content = await fs.readFile(CLAUDE_SETTINGS_PATH, 'utf8'); + const parsed = JSON.parse(content); + expect(parsed.editorMode).toBe('normal'); + }, + }, + validateDestroy: async () => { + try { + const content = await fs.readFile(CLAUDE_SETTINGS_PATH, 'utf8'); + const parsed = JSON.parse(content); + expect(parsed.editorMode).toBeUndefined(); + } catch { + // file removed entirely is also acceptable + } + }, + }, + ); + }); + + it('Can manage per-project MCP servers', { timeout: 120_000 }, async () => { + const mcpServer = { + name: 'test-filesystem', + type: 'stdio' as const, + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'], + }; + + await PluginTester.fullTest( + pluginPath, + [{ type: 'claude-code-project', directory: TEST_DIR, mcpServers: [mcpServer] }], + { + validateApply: async () => { + const content = await fs.readFile(CLAUDE_JSON_PATH, 'utf8'); + const config = JSON.parse(content); + expect(config.mcpServers).toBeDefined(); + expect(config.mcpServers['test-filesystem']).toBeDefined(); + expect(config.mcpServers['test-filesystem'].command).toBe('npx'); + }, + validateDestroy: async () => { + try { + const content = await fs.readFile(CLAUDE_JSON_PATH, 'utf8'); + const config = JSON.parse(content); + expect(config.mcpServers?.['test-filesystem']).toBeUndefined(); + } catch { + // file not existing is also acceptable + } + }, + }, + ); + }); + + it('Does not affect global settings when managing per-project settings', { timeout: 120_000 }, async () => { + const globalSettingsPath = path.join(os.homedir(), '.claude', 'settings.json'); + + let globalSettingsBefore: string | null = null; + try { + globalSettingsBefore = await fs.readFile(globalSettingsPath, 'utf8'); + } catch { /* may not exist */ } + + await PluginTester.fullTest( + pluginPath, + [{ type: 'claude-code-project', directory: TEST_DIR, settings: { editorMode: 'vim' } }], + { + validateApply: async () => { + // Per-project settings written + const content = await fs.readFile(CLAUDE_SETTINGS_PATH, 'utf8'); + expect(JSON.parse(content).editorMode).toBe('vim'); + + // Global settings unchanged + try { + const globalContent = await fs.readFile(globalSettingsPath, 'utf8'); + expect(globalContent).toBe(globalSettingsBefore); + } catch { + expect(globalSettingsBefore).toBeNull(); + } + }, + validateDestroy: async () => { + // Global settings still unchanged after destroy + try { + const globalContent = await fs.readFile(globalSettingsPath, 'utf8'); + expect(globalContent).toBe(globalSettingsBefore); + } catch { + expect(globalSettingsBefore).toBeNull(); + } + }, + }, + ); + }); +}); diff --git a/test/claude-code/claude-code.test.ts b/test/claude-code/claude-code.test.ts new file mode 100644 index 0000000..4ce5f4c --- /dev/null +++ b/test/claude-code/claude-code.test.ts @@ -0,0 +1,138 @@ +import { PluginTester, testSpawn } from '@codifycli/plugin-test'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; + +const CLAUDE_MD_PATH = path.join(os.homedir(), '.claude', 'CLAUDE.md'); +const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json'); +const CLAUDE_GLOBAL_CONFIG = path.join(os.homedir(), '.claude.json'); + +describe('claude-code resource integration tests', async () => { + const pluginPath = path.resolve('./src/index.ts'); + + it('Can install claude-code', { timeout: 300_000 }, async () => { + const claudeBin = path.join(os.homedir(), '.local', 'bin', 'claude'); + await PluginTester.fullTest( + pluginPath, + [{ type: 'claude-code' }], + { + validateApply: async () => { + const exists = await fs.access(claudeBin).then(() => true).catch(() => false); + expect(exists).toBe(true); + }, + validateDestroy: async () => { + const exists = await fs.access(claudeBin).then(() => true).catch(() => false); + expect(exists).toBe(false); + }, + }, + ); + }); + + it('Can manage settings', { timeout: 300_000 }, async () => { + const initialSettings = { + editorMode: 'vim', + spinnerTipsEnabled: false, + }; + + const modifiedSettings = { + editorMode: 'normal', + spinnerTipsEnabled: false, + }; + + await PluginTester.fullTest( + pluginPath, + [{ type: 'claude-code', settings: initialSettings }], + { + validateApply: async () => { + const content = await fs.readFile(CLAUDE_SETTINGS_PATH, 'utf8'); + const parsed = JSON.parse(content); + expect(parsed.editorMode).toBe('vim'); + expect(parsed.spinnerTipsEnabled).toBe(false); + }, + testModify: { + modifiedConfigs: [{ type: 'claude-code', settings: modifiedSettings }], + validateModify: async () => { + const content = await fs.readFile(CLAUDE_SETTINGS_PATH, 'utf8'); + const parsed = JSON.parse(content); + expect(parsed.editorMode).toBe('normal'); + }, + }, + validateDestroy: async () => { + try { + const content = await fs.readFile(CLAUDE_SETTINGS_PATH, 'utf8'); + const parsed = JSON.parse(content); + expect(parsed.editorMode).toBeUndefined(); + } catch { + // file removed entirely is also acceptable + } + }, + }, + ); + }); + + it('Can manage globalClaudeMd', { timeout: 300_000 }, async () => { + const initialContent = '# Global Instructions\n\nAlways write tests.'; + const modifiedContent = '# Global Instructions\n\nAlways write tests.\nPrefer TypeScript.'; + + await PluginTester.fullTest( + pluginPath, + [{ type: 'claude-code', globalClaudeMd: initialContent }], + { + validateApply: async () => { + const content = await fs.readFile(CLAUDE_MD_PATH, 'utf8'); + expect(content).toBe(initialContent); + }, + testModify: { + modifiedConfigs: [{ type: 'claude-code', globalClaudeMd: modifiedContent }], + validateModify: async () => { + const content = await fs.readFile(CLAUDE_MD_PATH, 'utf8'); + expect(content).toBe(modifiedContent); + }, + }, + validateDestroy: async () => { + const exists = await fs.access(CLAUDE_MD_PATH).then(() => true).catch(() => false); + expect(exists).toBe(false); + }, + }, + ); + }); + + it('Can manage MCP servers', { timeout: 300_000 }, async () => { + const mcpServer = { + name: 'test-filesystem', + type: 'stdio' as const, + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'], + }; + + await PluginTester.fullTest( + pluginPath, + [{ type: 'claude-code', mcpServers: [mcpServer] }], + { + validateApply: async () => { + const content = await fs.readFile(CLAUDE_GLOBAL_CONFIG, 'utf8'); + const config = JSON.parse(content); + expect(config.mcpServers).toBeDefined(); + expect(config.mcpServers['test-filesystem']).toBeDefined(); + expect(config.mcpServers['test-filesystem'].command).toBe('npx'); + }, + validateDestroy: async () => { + try { + const content = await fs.readFile(CLAUDE_GLOBAL_CONFIG, 'utf8'); + const config = JSON.parse(content); + expect(config.mcpServers?.['test-filesystem']).toBeUndefined(); + } catch { + // file not existing is also acceptable + } + }, + }, + ); + }); + + afterAll(async () => { + // Best-effort cleanup in case tests left claude installed + await testSpawn('claude --uninstall --force'); + await testSpawn('rm -f ~/.local/bin/claude'); + }, 60_000); +});