From 19a7c033edc4fbf6160aafc39a2498ca625d223b Mon Sep 17 00:00:00 2001 From: kevinwang5658 <20214115+kevinwang5658@users.noreply.github.com> Date: Sat, 16 May 2026 16:29:31 +0000 Subject: [PATCH 1/5] feat: Add claude-code resource (auto-generated from issue #39) --- Projects/python-project/requirements.txt | 1 + csv_credentials | 2 + docs/resources/(resources)/claude-code.mdx | 99 ++++++++ src/index.ts | 2 + src/resources/claude-code/claude-code.ts | 222 ++++++++++++++++++ .../claude-code/mcp-servers-parameter.ts | 66 ++++++ .../claude-code/settings-parameter.ts | 68 ++++++ test/claude-code/claude-code.test.ts | 136 +++++++++++ 8 files changed, 596 insertions(+) create mode 100644 Projects/python-project/requirements.txt create mode 100644 csv_credentials create mode 100644 docs/resources/(resources)/claude-code.mdx create mode 100644 src/resources/claude-code/claude-code.ts create mode 100644 src/resources/claude-code/mcp-servers-parameter.ts create mode 100644 src/resources/claude-code/settings-parameter.ts create mode 100644 test/claude-code/claude-code.test.ts 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); +}); From 333290f635462c8e57e7c59d26b2a16ff0d94efd Mon Sep 17 00:00:00 2001 From: kevinwang Date: Wed, 3 Jun 2026 12:50:34 -0400 Subject: [PATCH 2/5] feat: added claude code project resource. This can manage any folder. Fix uninstall. --- .../(resources)/claude-code-project.mdx | 95 +++++++++ src/index.ts | 2 + .../claude-code/claude-code-project.ts | 188 ++++++++++++++++++ src/resources/claude-code/claude-code.ts | 29 +-- .../claude-code/mcp-servers-parameter.ts | 37 ++-- .../claude-code/settings-parameter.ts | 44 ++-- test/claude-code/claude-code-project.test.ts | 161 +++++++++++++++ test/claude-code/claude-code.test.ts | 8 +- 8 files changed, 507 insertions(+), 57 deletions(-) create mode 100644 docs/resources/(resources)/claude-code-project.mdx create mode 100644 src/resources/claude-code/claude-code-project.ts create mode 100644 test/claude-code/claude-code-project.test.ts diff --git a/docs/resources/(resources)/claude-code-project.mdx b/docs/resources/(resources)/claude-code-project.mdx new file mode 100644 index 0000000..a1c4045 --- /dev/null +++ b/docs/resources/(resources)/claude-code-project.mdx @@ -0,0 +1,95 @@ +--- +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 to write to `/.claude/CLAUDE.md`. 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"] + } + ] + } +] +``` + +### 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/src/index.ts b/src/index.ts index 1bb7857..d0aee60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ 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'; @@ -110,6 +111,7 @@ runPlugin(Plugin.create( 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..342ca0f --- /dev/null +++ b/src/resources/claude-code/claude-code-project.ts @@ -0,0 +1,188 @@ +import { + 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, McpServer } 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 to write to /.claude/CLAUDE.md.'), + 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 setting:true 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) { + 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 }); + await fs.writeFile(resolveClaudeMdPath(directory), content, 'utf8'); + } +} diff --git a/src/resources/claude-code/claude-code.ts b/src/resources/claude-code/claude-code.ts index 8f53c4b..d84d854 100644 --- a/src/resources/claude-code/claude-code.ts +++ b/src/resources/claude-code/claude-code.ts @@ -6,8 +6,6 @@ import { ParameterChange, Resource, ResourceSettings, - SpawnStatus, - Utils, getPty, z, } from '@codifycli/plugin-core'; @@ -141,9 +139,10 @@ export class ClaudeCodeResource extends Resource { } async refresh(parameters: Partial): Promise | null> { - const $ = getPty(); - const { status } = await $.spawnSafe('which claude'); - if (status !== SpawnStatus.SUCCESS) { + const claudeBin = path.join(os.homedir(), '.local', 'bin', 'claude'); + try { + await fs.access(claudeBin); + } catch { return null; } @@ -192,27 +191,13 @@ export class ClaudeCodeResource extends Resource { } 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 }); - } - } - } + // 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 { diff --git a/src/resources/claude-code/mcp-servers-parameter.ts b/src/resources/claude-code/mcp-servers-parameter.ts index f6b7fc7..c06c90b 100644 --- a/src/resources/claude-code/mcp-servers-parameter.ts +++ b/src/resources/claude-code/mcp-servers-parameter.ts @@ -1,13 +1,18 @@ 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 { ClaudeCodeConfig, McpServer } from './claude-code.js'; +import { untildify } from '../../utils/untildify.js'; +import { McpServer } from './claude-code.js'; -const CLAUDE_GLOBAL_CONFIG = path.join(os.homedir(), '.claude.json'); +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 { +export class McpServersParameter extends ArrayStatefulParameter { override getSettings() { return { type: 'array' as const, @@ -15,16 +20,17 @@ export class McpServersParameter extends ArrayStatefulParameter { + async refresh(_desired: McpServer[] | null, config: Partial): Promise { + const configPath = resolveClaudeJsonPath(config['directory'] as string | undefined); try { - const content = await fs.readFile(CLAUDE_GLOBAL_CONFIG, 'utf8'); - const config = JSON.parse(content) as { mcpServers?: Record }; + const content = await fs.readFile(configPath, 'utf8'); + const parsed = JSON.parse(content) as { mcpServers?: Record }; - if (!config.mcpServers || typeof config.mcpServers !== 'object') { + if (!parsed.mcpServers || typeof parsed.mcpServers !== 'object') { return []; } - return Object.entries(config.mcpServers).map(([name, serverConfig]) => ({ + return Object.entries(parsed.mcpServers).map(([name, serverConfig]) => ({ name, ...(serverConfig as object), })) as McpServer[]; @@ -33,25 +39,28 @@ export class McpServersParameter extends ArrayStatefulParameter): Promise { + 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 { + 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(CLAUDE_GLOBAL_CONFIG, 'utf8'); + const content = await fs.readFile(configPath, 'utf8'); config = JSON.parse(content) as Record; } catch { /* file may not exist yet */ } @@ -61,6 +70,6 @@ export class McpServersParameter extends ArrayStatefulParameter); - await fs.writeFile(CLAUDE_GLOBAL_CONFIG, JSON.stringify(config, null, 2), 'utf8'); + 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 index a72c9fe..aacb85f 100644 --- a/src/resources/claude-code/settings-parameter.ts +++ b/src/resources/claude-code/settings-parameter.ts @@ -1,34 +1,39 @@ -import { ParameterSetting, StatefulParameter } from '@codifycli/plugin-core'; +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 { ClaudeCodeConfig } from './claude-code.js'; - -const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json'); +import { untildify } from '../../utils/untildify.js'; type Settings = Record; -export class SettingsParameter extends StatefulParameter { +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(): Promise { + override async refresh(_desired: Settings | null, config: Partial): Promise { + const filePath = resolveSettingsPath(config['directory'] as string | undefined); try { - const content = await fs.readFile(SETTINGS_PATH, 'utf8'); + const content = await fs.readFile(filePath, 'utf8'); return JSON.parse(content) as Settings; } catch { return null; } } - async add(valueToAdd: Settings): Promise { - await this.mergeIntoFile(valueToAdd); + async add(valueToAdd: Settings, plan: Plan): Promise { + await this.mergeIntoFile(valueToAdd, plan.desiredConfig?.['directory'] as string | undefined); } - async modify(newValue: Settings, previousValue: Settings): Promise { - const filePath = SETTINGS_PATH; + 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')); @@ -46,23 +51,26 @@ export class SettingsParameter extends StatefulParameter { + 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(SETTINGS_PATH, 'utf8')) as Settings; + const existing = JSON.parse(await fs.readFile(filePath, 'utf8')) as Settings; for (const key of Object.keys(valueToRemove)) { delete existing[key]; } - await fs.writeFile(SETTINGS_PATH, JSON.stringify(existing, null, 2)); + await fs.writeFile(filePath, JSON.stringify(existing, null, 2)); } catch { /* nothing to do if file doesn't exist */ } } - private async mergeIntoFile(settings: Settings): Promise { + private async mergeIntoFile(settings: Settings, directory?: string): Promise { + const filePath = resolveSettingsPath(directory); let existing: Settings = {}; try { - existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf8')); + existing = JSON.parse(await fs.readFile(filePath, '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)); + 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 index 6890645..4ce5f4c 100644 --- a/test/claude-code/claude-code.test.ts +++ b/test/claude-code/claude-code.test.ts @@ -1,4 +1,3 @@ -import { SpawnStatus } from '@codifycli/plugin-core'; import { PluginTester, testSpawn } from '@codifycli/plugin-test'; import fs from 'node:fs/promises'; import os from 'node:os'; @@ -13,15 +12,18 @@ 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 () => { - expect(await testSpawn('which claude')).toMatchObject({ status: SpawnStatus.SUCCESS }); + const exists = await fs.access(claudeBin).then(() => true).catch(() => false); + expect(exists).toBe(true); }, validateDestroy: async () => { - expect(await testSpawn('which claude')).toMatchObject({ status: SpawnStatus.ERROR }); + const exists = await fs.access(claudeBin).then(() => true).catch(() => false); + expect(exists).toBe(false); }, }, ); From c30cdc4b04793eb3f5cfd65cd4c9b0da8a85fc0c Mon Sep 17 00:00:00 2001 From: kevinwang Date: Wed, 3 Jun 2026 14:23:40 -0400 Subject: [PATCH 3/5] feat: added support for codify files for claude md. --- .../(resources)/claude-code-project.mdx | 26 +++++++- .../claude-code/claude-code-project.ts | 59 ++++++++++++++++--- src/resources/claude-code/claude-code.ts | 53 ++++++++++++++--- 3 files changed, 122 insertions(+), 16 deletions(-) diff --git a/docs/resources/(resources)/claude-code-project.mdx b/docs/resources/(resources)/claude-code-project.mdx index a1c4045..ee4cb07 100644 --- a/docs/resources/(resources)/claude-code-project.mdx +++ b/docs/resources/(resources)/claude-code-project.mdx @@ -12,7 +12,7 @@ The claude-code-project resource manages **per-project** Claude Code configurati - `/.claude/settings.json` - `/.claude.json` -- **claudeMd**: *(string, optional)* Content to write to `/.claude/CLAUDE.md`. 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. +- **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 @@ -67,6 +67,30 @@ The claude-code-project resource manages **per-project** Claude Code configurati ] ``` +### 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" diff --git a/src/resources/claude-code/claude-code-project.ts b/src/resources/claude-code/claude-code-project.ts index 342ca0f..8bade65 100644 --- a/src/resources/claude-code/claude-code-project.ts +++ b/src/resources/claude-code/claude-code-project.ts @@ -1,4 +1,5 @@ import { + CodifyCliSender, CreatePlan, DestroyPlan, ExampleConfig, @@ -15,7 +16,7 @@ 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, McpServer } from './claude-code.js'; +import { mcpServerSchema } from './claude-code.js'; const schema = z .object({ @@ -28,7 +29,10 @@ const schema = z claudeMd: z .string() .optional() - .describe('Content to write to /.claude/CLAUDE.md.'), + .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() @@ -133,15 +137,21 @@ export class ClaudeCodeProjectResource extends Resource return null; } - // Start from parameters so setting:true fields (directory) are present in the result, + // 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) { - try { - result.claudeMd = await fs.readFile(resolveClaudeMdPath(parameters.directory), 'utf8'); - } catch { - result.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; + } } } @@ -183,6 +193,39 @@ export class ClaudeCodeProjectResource extends Resource private async writeClaudeMd(content: string, directory: string): Promise { const claudeDir = resolveClaudeDir(directory); await fs.mkdir(claudeDir, { recursive: true }); - await fs.writeFile(resolveClaudeMdPath(directory), content, 'utf8'); + 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 index d84d854..286aad3 100644 --- a/src/resources/claude-code/claude-code.ts +++ b/src/resources/claude-code/claude-code.ts @@ -1,4 +1,5 @@ import { + CodifyCliSender, CreatePlan, DestroyPlan, ExampleConfig, @@ -56,8 +57,9 @@ const schema = 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.', + '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()) @@ -149,10 +151,14 @@ export class ClaudeCodeResource extends Resource { const result: Partial = {}; if (parameters.globalClaudeMd !== undefined) { - try { - result.globalClaudeMd = await fs.readFile(CLAUDE_MD_PATH, 'utf8'); - } catch { - result.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; + } } } @@ -202,6 +208,39 @@ export class ClaudeCodeResource extends Resource { private async writeClaudeMd(content: string): Promise { await fs.mkdir(CLAUDE_DIR, { recursive: true }); - await fs.writeFile(CLAUDE_MD_PATH, content, 'utf8'); + 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; } From 5b3ddc374e03961878bf358897c9c8db47c2be21 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Wed, 3 Jun 2026 15:32:12 -0400 Subject: [PATCH 4/5] feat: improved deploy script --- package.json | 2 +- scripts/deploy.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 +} From 8d832b7e3d8632754c22cdfa38d64746f6b49140 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Wed, 3 Jun 2026 15:36:30 -0400 Subject: [PATCH 5/5] fix: remove files --- Projects/python-project/requirements.txt | 1 - csv_credentials | 2 -- 2 files changed, 3 deletions(-) delete mode 100644 Projects/python-project/requirements.txt delete mode 100644 csv_credentials diff --git a/Projects/python-project/requirements.txt b/Projects/python-project/requirements.txt deleted file mode 100644 index 1ed654e..0000000 --- a/Projects/python-project/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -ffmpeg==1.4 \ No newline at end of file diff --git a/csv_credentials b/csv_credentials deleted file mode 100644 index 86adc5b..0000000 --- a/csv_credentials +++ /dev/null @@ -1,2 +0,0 @@ -Access key ID,Secret access key -AKIA,zhKpjk