diff --git a/Projects/python-project/requirements.txt b/Projects/python-project/requirements.txt new file mode 100644 index 0000000..1ed654e --- /dev/null +++ b/Projects/python-project/requirements.txt @@ -0,0 +1 @@ +ffmpeg==1.4 \ No newline at end of file diff --git a/csv_credentials b/csv_credentials new file mode 100644 index 0000000..86adc5b --- /dev/null +++ b/csv_credentials @@ -0,0 +1,2 @@ +Access key ID,Secret access key +AKIA,zhKpjk 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/src/index.ts b/src/index.ts index 076647c..1bb7857 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ 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 { OllamaResource } from './resources/ollama/ollama.js'; import { PgcliResource } from './resources/pgcli/pgcli.js'; import { Pip } from './resources/python/pip/pip.js'; @@ -108,6 +109,7 @@ runPlugin(Plugin.create( new SnapResource(), new TartResource(), new TartVmResource(), + new ClaudeCodeResource(), new OllamaResource(), new SyncthingResource(), new SyncthingDeviceResource(), diff --git a/src/resources/claude-code/claude-code.ts b/src/resources/claude-code/claude-code.ts new file mode 100644 index 0000000..8f53c4b --- /dev/null +++ b/src/resources/claude-code/claude-code.ts @@ -0,0 +1,222 @@ +import { + CreatePlan, + DestroyPlan, + ExampleConfig, + ModifyPlan, + ParameterChange, + Resource, + ResourceSettings, + SpawnStatus, + Utils, + 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 to write to ~/.claude/CLAUDE.md. Claude Code reads this at the start of ' + + 'every session, making it the ideal place for global coding standards and preferences.', + ), + 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 $ = getPty(); + const { status } = await $.spawnSafe('which claude'); + if (status !== SpawnStatus.SUCCESS) { + return null; + } + + const result: Partial = {}; + + if (parameters.globalClaudeMd !== undefined) { + 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 { + const $ = getPty(); + + if (plan.currentConfig.globalClaudeMd) { + await fs.rm(CLAUDE_MD_PATH, { force: true }); + } + + // Attempt graceful uninstall via the CLI, fall back to binary removal + const { status } = await $.spawnSafe('claude --uninstall --force'); + if (status !== SpawnStatus.SUCCESS) { + const { data, status: whichStatus } = await $.spawnSafe('which claude'); + if (whichStatus === SpawnStatus.SUCCESS) { + const binaryPath = data.trim(); + await fs.rm(binaryPath, { force: true }); + + if (Utils.isLinux()) { + // The install script may have created a systemd service + await $.spawnSafe('systemctl stop claude-code', { requiresRoot: true }); + await $.spawnSafe('systemctl disable claude-code', { requiresRoot: true }); + } + } + } + } + + private async writeClaudeMd(content: string): Promise { + await fs.mkdir(CLAUDE_DIR, { recursive: true }); + await fs.writeFile(CLAUDE_MD_PATH, content, 'utf8'); + } +} 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..f6b7fc7 --- /dev/null +++ b/src/resources/claude-code/mcp-servers-parameter.ts @@ -0,0 +1,66 @@ +import { ArrayStatefulParameter, Plan } from '@codifycli/plugin-core'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { ClaudeCodeConfig, McpServer } from './claude-code.js'; + +const CLAUDE_GLOBAL_CONFIG = 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): Promise { + try { + const content = await fs.readFile(CLAUDE_GLOBAL_CONFIG, 'utf8'); + const config = JSON.parse(content) as { mcpServers?: Record }; + + if (!config.mcpServers || typeof config.mcpServers !== 'object') { + return []; + } + + return Object.entries(config.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; + }); + } + + async removeItem(item: McpServer, _plan: Plan): Promise { + await this.mutateMcpServers((servers) => { + delete servers[item.name]; + }); + } + + private async mutateMcpServers( + mutate: (servers: Record) => void, + ): Promise { + let config: Record = {}; + try { + const content = await fs.readFile(CLAUDE_GLOBAL_CONFIG, '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(CLAUDE_GLOBAL_CONFIG, 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..a72c9fe --- /dev/null +++ b/src/resources/claude-code/settings-parameter.ts @@ -0,0 +1,68 @@ +import { ParameterSetting, StatefulParameter } from '@codifycli/plugin-core'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { ClaudeCodeConfig } from './claude-code.js'; + +const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json'); + +type Settings = Record; + +export class SettingsParameter extends StatefulParameter { + getSettings(): ParameterSetting { + return { type: 'object' }; + } + + override async refresh(): Promise { + try { + const content = await fs.readFile(SETTINGS_PATH, 'utf8'); + return JSON.parse(content) as Settings; + } catch { + return null; + } + } + + async add(valueToAdd: Settings): Promise { + await this.mergeIntoFile(valueToAdd); + } + + async modify(newValue: Settings, previousValue: Settings): Promise { + const filePath = SETTINGS_PATH; + 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): Promise { + try { + const existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf8')) as Settings; + for (const key of Object.keys(valueToRemove)) { + delete existing[key]; + } + await fs.writeFile(SETTINGS_PATH, JSON.stringify(existing, null, 2)); + } catch { /* nothing to do if file doesn't exist */ } + } + + private async mergeIntoFile(settings: Settings): Promise { + let existing: Settings = {}; + try { + existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf8')); + } catch { /* file may not exist yet */ } + + await fs.mkdir(path.dirname(SETTINGS_PATH), { recursive: true }); + await fs.writeFile(SETTINGS_PATH, JSON.stringify({ ...existing, ...settings }, null, 2)); + } +} diff --git a/test/claude-code/claude-code.test.ts b/test/claude-code/claude-code.test.ts new file mode 100644 index 0000000..6890645 --- /dev/null +++ b/test/claude-code/claude-code.test.ts @@ -0,0 +1,136 @@ +import { SpawnStatus } from '@codifycli/plugin-core'; +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 () => { + await PluginTester.fullTest( + pluginPath, + [{ type: 'claude-code' }], + { + validateApply: async () => { + expect(await testSpawn('which claude')).toMatchObject({ status: SpawnStatus.SUCCESS }); + }, + validateDestroy: async () => { + expect(await testSpawn('which claude')).toMatchObject({ status: SpawnStatus.ERROR }); + }, + }, + ); + }); + + 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); +});