This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This is a Codify plugin that provides 50+ declarative system configuration resources (Homebrew, Git, shell aliases, Python environments, etc.) built on the @codifycli/plugin-core framework. Users write JSON configurations describing their desired system state, and the framework generates and executes plans to achieve that state.
# Build the plugin (compiles TypeScript, bundles with Rollup, generates schemas.json)
npm run build
# Run all tests (unit + integration)
npm test
# Run unit tests only (fast - tests in src/**/*.test.ts)
npm run test:unit
# Run integration tests only (slow - full lifecycle tests in test/**/*.test.ts)
npm run test:integration
# Run integration tests in development mode
npm run test:integration:dev
# Deploy to Cloudflare R2
npm run deploy
# Deploy beta version
npm run deploy:betaRunning a single test:
# Unit test
npx vitest src/resources/shell/path/path-resource.test.ts
# Integration test
npx vitest test/shell/path.test.tsThis plugin uses a Resource-based architecture where:
- Each resource type (git, homebrew, alias, etc.) extends the
Resource<ConfigType>base class from@codifycli/plugin-core - Resources are registered in
src/index.tsviaPlugin.create('default', [resource instances]) - Resources implement 5 core lifecycle methods:
getSettings()- Define schema, parameters, dependencies, OS supportrefresh()- Read current system statecreate()- Create new resourcemodify()- Modify existing resource (optional)destroy()- Remove resource
All resources are registered in /src/index.ts:
runPlugin(Plugin.create('default', [
new GitResource(),
new HomebrewResource(),
new AliasResource(),
// ... 50+ more resources
]))Every resource follows this pattern:
export class MyResource extends Resource<MyConfig> {
getSettings(): ResourceSettings<MyConfig> {
return {
id: 'unique-id',
operatingSystems: [OS.Darwin, OS.Linux],
schema: JSONSchema or ZodSchema,
parameterSettings: { /* ... */ },
dependencies: ['other-resource-ids'],
allowMultiple: { /* ... */ }
}
}
async refresh(params): Promise<Partial<MyConfig> | null> {
// Returns null if resource doesn't exist
// Returns object with current state if it exists
}
async create(plan): Promise<void> { /* ... */ }
async modify(pc, plan): Promise<void> { /* ... */ }
async destroy(plan): Promise<void> { /* ... */ }
}1. Simple Singleton (e.g., shell/alias/alias-resource.ts):
- One resource instance per config entry
- Uses
allowMultiple.identifyingParameters: ['alias']to support multiple aliases - Each unique identifying parameter value becomes a separate resource
2. Multi-Declaration (e.g., shell/aliases/aliases-resource.ts):
- Manages multiple items in a single resource (array of aliases)
- Uses Zod schemas for type safety
- Implements
declarationsOnlymode for stateless/stateful behavior
3. Complex with Auto-Discovery (e.g., git/repository/git-repository.ts):
- Supports multiple configuration modes (single repo vs multiple repos)
- Uses
allowMultiple.matcher()for custom matching logic - Uses
allowMultiple.findAllParameters()for system discovery - Declares
dependenciesto ensure prerequisites are met
CRITICAL DISTINCTION:
- Framework only manages explicitly declared items in the config
- System state is filtered to match declarations
- Safer default - won't accidentally capture unwanted system state
- Example: Only manage the paths/aliases the user explicitly listed
Implementation:
parameterSettings: {
paths: {
filterInStatelessMode: (desired, current) =>
current.filter((c) => desired.some((d) => d === c))
}
}- Framework manages complete state of resource
- Tracks what changed over time (additions/removals)
- Uses
StatefulParameterclasses withadd(),modify(),remove()methods - Example: Homebrew formulae - track all installed packages
Implementation:
parameterSettings: {
formulae: {
type: 'stateful',
definition: new FormulaeParameter(),
order: 2
}
}Two approaches are supported:
1. JSON Schema (traditional):
// Separate .json file
import Schema from './my-resource-schema.json'
export interface MyConfig extends StringIndexedObject {
field: string
}
getSettings() {
return { schema: Schema }
}2. Zod Schema (preferred):
// Single source of truth - schema and types in sync
export const schema = z.object({
field: z.string(),
optional: z.boolean().optional(),
})
export type MyConfig = z.infer<typeof schema>
getSettings() {
return { schema }
}Zod is preferred because types are automatically inferred from the schema, preventing drift between validation and TypeScript types.
- Fast tests for parsing, regex, data transformation
- No system calls
- Test individual functions in isolation
- Full lifecycle tests against real system
- Uses
PluginTester.fullTest()from@codifycli/plugin-test - Tests create → modify → destroy flow
- Includes validation callbacks
- Always use
testSpawnfrom@codifycli/plugin-testfor shell commands in validation callbacks.testSpawnsources the user's shell RC (.zshrc,.bashrc) before running the command, so PATH and shell aliases are available — just like a real terminal session. Never useexecSyncin integration tests.
Integration Test Pattern:
import { PluginTester, testSpawn } from '@codifycli/plugin-test'
await PluginTester.fullTest(pluginPath, [
{ type: 'alias', alias: 'my-alias', value: 'ls -l' }
], {
validateApply: async () => {
// Verify resource was created
},
testModify: {
modifiedConfigs: [{ type: 'alias', alias: 'my-alias', value: 'pwd' }],
validateModify: async () => {
// Verify modification succeeded
}
},
validateDestroy: async () => {
// Verify resource was removed
}
})- Global
beforeAllsaves shell RC state and ensures prerequisites (Xcode, Homebrew on macOS) - Global
afterAllrestores shell RC to original state - Platform-specific setup using
TestUtils
The @codifycli/plugin-core framework provides:
Shell/PTY Access:
const $ = getPty()
// Safe spawn (never throws, returns status)
const { data, status } = await $.spawnSafe('command')
if (status === SpawnStatus.ERROR) { /* handle */ }
// Regular spawn (throws on error)
const { data } = await $.spawn('command', {
interactive: true,
cwd: '/path',
requiresRoot: true,
env: { VAR: 'value' }
})Shell RC sourcing differs by lifecycle method. During refresh, the framework uses a BackgroundPty that automatically sources the user's shell RC, so PATH and shell functions are available without any extra options. During all other lifecycle methods (create, modify, destroy), the RC is not sourced automatically — pass { interactive: true } when the command needs PATH entries or shell aliases that come from the RC file (e.g. a tool that was just installed by adding itself to .zshrc).
// refresh — shell RC sourced automatically, no option needed
const result = await $.spawnSafe('my-tool --version')
// create/modify/destroy — must opt in to get sourced shell
await $.spawn('my-tool configure', { interactive: true })Never use sudo inside $.spawn or $.spawnSafe. Use { requiresRoot: true } in the options instead. The framework handles privilege escalation through the parent process.
// Wrong
await $.spawn('sudo rm -f /usr/local/bin/ollama');
// Correct
await $.spawn('rm -f /usr/local/bin/ollama', { requiresRoot: true });File Operations:
await FileUtils.addToStartupFile(lineToAdd)
await FileUtils.addToShellRc(lineToAdd)
await FileUtils.addPathToPrimaryShellRc(pathValue, prepend)
await FileUtils.removeLineFromFile(filePath, lineContent)
await FileUtils.fileExists(path)
await FileUtils.dirExists(path)OS Detection:
Utils.isMacOS()
Utils.isLinux()
Utils.isWindows()Package Installation:
Always use Utils.installViaPkgMgr(pkg) from @codifycli/plugin-core to install system packages. This is platform-agnostic and automatically dispatches to the correct package manager (Homebrew on macOS, apt on Debian/Ubuntu, etc.). Never hardcode package manager calls like brew install, apt-get install -y, or sudo apt install in resource code.
// Correct — works on macOS and Linux
await Utils.installViaPkgMgr('curl');
await Utils.uninstallViaPkgMgr('curl');
// Wrong — hardcoded to a specific platform/package manager
await $.spawn('sudo apt-get install -y curl');
await $.spawn('brew install curl');This applies to prerequisite checks too. When a resource needs a system dependency (e.g. curl, git, make), always install via Utils.installViaPkgMgr rather than spawning a package manager directly.
Imports — Utils from plugin-core vs local utils:
Always import Utils from @codifycli/plugin-core, not from ../../utils or ../../../utils. The local src/utils/ module contains macOS-specific helpers (findApplication, isArmArch, isRosetta2Installed, downloadUrlIntoFile, etc.) that are only needed when those specific capabilities are required. For everything else — OS detection, package management, shell utilities — use the plugin-core Utils.
// Correct
import { Utils } from '@codifycli/plugin-core';
// Only use local utils when you specifically need macOS/spotlight helpers
import { Utils as LocalUtils } from '../../../utils/index.js';The build process (scripts/build.ts) does:
- Removes
dist/folder - Runs Rollup to compile TypeScript → ES modules
- Forks the built plugin and queries it for all resource schemas
- Merges each resource schema with base
ResourceSchema - Rebuilds with Rollup → CommonJS
- Writes
dist/schemas.jsoncontaining all resource schemas
The dist/schemas.json file is used by the CLI for validation and documentation.
Deployment (scripts/deploy.ts) uploads the built plugin to Cloudflare R2:
- Production:
plugins/{name}/{version}/index.js - Beta:
plugins/{name}/beta/index.js
The Codify Editor supports auto-complete for certain resource parameters (e.g. Homebrew formula names, Node.js versions). These completions are pre-fetched by a Cloudflare Workers cron job that lives in completions-cron/.
- Create
src/resources/<category>/<resource>/completions/<type>.<param>.ts - Export a default async function returning
Promise<string[]>— fetch the values, return them, nothing else - The filename determines the Supabase metadata automatically:
homebrew.formulae.ts→resource_type=homebrew,parameter_path=/formulae
- Run
npm run build:completionsto regenerate the index
npm run build:completions # regenerate completions-cron/src/__generated__/completions-index.ts
npm run deploy:completions # build + deploy to Cloudflare Workerssrc/resources/**/completions/*.ts ← per-resource fetch scripts (return string[])
↓ npm run build:completions
completions-cron/src/__generated__/completions-index.ts ← AUTO-GENERATED, do not edit
completions-cron/src/index.ts ← orchestrator: Supabase writes, scheduled handler
↓ wrangler deploy
Cloudflare Workers (runs daily at 05:00 UTC)
See completions-cron/README.md for full details.
Simple boolean:
allowMultiple: trueWith identifying parameters:
allowMultiple: {
identifyingParameters: ['path'] // Each unique 'path' = different resource
}With custom matcher and auto-discovery:
allowMultiple: {
matcher: (desired, current) => desired.directory === current.directory,
async findAllParameters() {
// Discover all instances on system
return [{ directory: '...' }, ...]
}
}parameterSettings: {
// Boolean setting (not tracked in state)
skipAlreadyInstalledCasks: {
type: 'boolean',
default: true,
setting: true
},
// Directory path
directory: {
type: 'directory'
},
// Modifiable array
paths: {
type: 'array',
itemType: 'directory',
canModify: true,
isElementEqual: (a, b) => a === b,
filterInStatelessMode: (desired, current) => /* ... */
},
// Stateful parameter with custom handler
formulae: {
type: 'stateful',
definition: new FormulaeParameter(),
order: 2
}
}If a parameter has its own independent state on the system (e.g. a list of installed packages, a JSON settings file, a set of config keys), implement it as a StatefulParameter rather than handling it inline in create/modify/destroy. This keeps the main resource class clean and gives the framework full visibility into the parameter's lifecycle.
Rule of thumb: if you find yourself reading current state, diffing, and writing back inside modify() on the resource, it should be a StatefulParameter instead.
export class MyParameter extends StatefulParameter<MyConfig, ValueType> {
getSettings(): ParameterSetting { ... }
async refresh(desired, config): Promise<ValueType | null> { /* read current state */ }
async add(value, plan): Promise<void> { /* apply from scratch */ }
async modify(newValue, previousValue, plan): Promise<void> { /* diff and update */ }
async remove(value, plan): Promise<void> { /* clean up */ }
}// Wrong — inline state management clutters the resource
async modify(pc, plan) {
if (pc.name === 'settings') {
const current = JSON.parse(await fs.readFile(settingsPath, 'utf8'));
await fs.writeFile(settingsPath, JSON.stringify({ ...current, ...pc.newValue }));
}
}
// Correct — delegate to a StatefulParameter
parameterSettings: {
settings: { type: 'stateful', definition: new SettingsParameter(), order: 1 }
}Every resource should have a defaultConfig and exampleConfigs. These are surfaced in the Codify Editor to help users get started quickly.
defaultConfig — pre-fills the resource form with sensible starting values:
- Use Syncthing's/asdf's/AWS's own documented defaults where applicable
- For required fields with no sensible default (e.g.
deviceId,plugin,awsAccessKeyId), use the placeholder string'<Replace me here!'> - For optional array fields that default to empty (e.g.
plugins,aliases,paths), set them to[] - Omit fields that are purely user-specific (e.g. paths, names, credentials) — don't guess
- If the resource declares
operatingSystems: [OS.Darwin]oroperatingSystems: [OS.Linux](i.e. only one OS, not both), do NOT addostodefaultConfig(it's not on the typed config interface). Instead, add the correctosvalue only to the config entries insideexampleConfigs. Skip entirely when the resource supports both OS. - The
osfield values come from theResourceOsenum in@codifycli/schemas(../codify-schemas/src/types/index.ts): use'macOS'for Darwin,'linux'for Linux,'windows'for Windows (e.g.os: ['macOS'], notos: ['darwin']).
exampleConfigs — up to two named examples (example1, example2):
example1: a substantive example showing the most common real-world use case with meaningful configuration — not a trivial "just install it" with no parametersexample2: either a more advanced single-resource variant, OR a multi-resource example that shows the full end-to-end setup (e.g. install the tool + configure it)- Multi-resource examples (configs array with multiple types) are especially useful when the resource
dependsOnanother — show installing the dependency too - Every example needs a
title(short, noun-phrase) and adescription(one sentence explaining what it does and why) - Use realistic but obviously-placeholder values for sensitive fields (
'<Replace me here!'>), not real credentials - Don't add step-numbering ("Step 1 of 3") in descriptions — it doesn't make sense when viewed from a single resource page
- If the resource is OS-specific (only Darwin or only Linux), add the correct
osvalue to each config entry in the example so the editor filters it correctly (e.g.os: ['macOS'])
Structure:
import { ExampleConfig } from '@codifycli/plugin-core';
const defaultConfig: Partial<MyConfig> = {
someField: 'sensible-default',
optionalArray: [],
// Add os: ['macOS'] or os: ['linux'] if operatingSystems is not [OS.Darwin, OS.Linux]
}
const exampleBasic: ExampleConfig = {
title: 'Basic my-resource setup',
description: 'One sentence explaining what this example does and who it is for.',
configs: [{
type: 'my-resource',
someField: 'example-value',
// Add os: ['macOS'] or os: ['linux'] if the resource is OS-specific
}]
}
const exampleWithDependency: ExampleConfig = {
title: 'Full my-resource setup',
description: 'Install the prerequisite and configure my-resource in one go.',
configs: [
{ type: 'prerequisite-resource' },
{ type: 'my-resource', someField: 'example-value' },
]
}
// Inside getSettings():
return {
id: 'my-resource',
defaultConfig,
exampleConfigs: {
example1: exampleBasic,
example2: exampleWithDependency,
},
// ...
}When there is a shared multi-resource example (e.g. the asdf full-install example used across asdf, asdf-plugin, and asdf-install): define it once in a separate examples.ts file in the resource folder and spread it into exampleConfigs using ...exampleSharedConfigs. Use a consistent description across all three rather than per-resource step labels.
Resources can declare dependencies on other resources:
getSettings() {
return {
dependencies: ['ssh-key', 'git'] // Apply these first
}
}The framework automatically validates dependencies exist and orders execution.
null= Resource doesn't exist on system{}= Resource exists with no state to track- Return
nullif refresh fails or resource not found
- File paths are case-insensitive
- Use
.toLowerCase()when comparing paths inallowMultiple.matcher() - Xcode Command Line Tools required for many operations
- Homebrew commonly used for package management
- File paths are case-sensitive
- Multiple package managers (apt, yum, dnf, snap)
- Shell RC files vary by distribution
- Always declare
operatingSystemsingetSettings() - Use
Utils.isMacOS(),Utils.isLinux()for platform-specific logic - Use
FileUtilsfor cross-platform file operations - Test on both macOS and Linux when possible
- Create directory:
src/resources/category/resource-name/ - Create schema file (JSON or Zod):
resource-name-schema.jsonor inline Zod - Create resource class extending
Resource<ConfigType> - Implement all required lifecycle methods
- Register in
src/index.ts - Create integration test in
test/category/resource-name.test.ts - Run
npm run testto validate
Core:
/src/index.ts- Resource registration/codify.json- Example configuration
Build:
/scripts/build.ts- Build process with schema collection/scripts/deploy.ts- Deployment to Cloudflare R2/scripts/generate-completions-index.ts- Generates completions-cron entry index/rollup.config.js- Bundling configuration/tsconfig.json- TypeScript config (ES2024, strict mode)/vitest.config.ts- Test runner config
Completions cron:
/completions-cron/src/index.ts- Cloudflare Workers scheduled handler/completions-cron/src/__generated__/completions-index.ts- Auto-generated, do not edit/completions-cron/wrangler.toml- Worker config (schedule, env vars)/completions-cron/README.md- Full documentation
Testing:
/test/setup.ts- Global test setup/teardown/test/test-utils.ts- Test helpers
Example Resources (by complexity):
- Simple:
src/resources/shell/alias/alias-resource.ts - Multi-item:
src/resources/shell/aliases/aliases-resource.ts - Complex:
src/resources/git/repository/git-repository.ts - Stateful:
src/resources/homebrew/homebrew.ts