From 190295249a4806eebbc3976ddafccc94c9e981f5 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Fri, 6 Mar 2026 17:33:37 -0500 Subject: [PATCH] feat: POC for an ampel create policy command Signed-off-by: Jennifer Power --- cmd/ampel-plugin/README.md | 44 +++++++ cmd/ampel-plugin/docs/MIGRATION.md | 63 ++++++++++ cmd/ampel-plugin/docs/STRATEGY.md | 8 +- cmd/ampel-plugin/main.go | 19 +++ cmd/ampel-plugin/templates/command.md | 172 ++++++++++++++++++++++++++ cmd/ampel-plugin/templates/skill.md | 169 +++++++++++++++++++++++++ docs/PLUGIN_GUIDE.md | 52 +++++++- docs/PLUGIN_TEMPLATE.md | 90 ++++++++++++++ pkg/plugin/agents.go | 91 ++++++++++++++ pkg/plugin/init.go | 118 ++++++++++++++++++ 10 files changed, 822 insertions(+), 4 deletions(-) create mode 100644 cmd/ampel-plugin/docs/MIGRATION.md create mode 100644 cmd/ampel-plugin/templates/command.md create mode 100644 cmd/ampel-plugin/templates/skill.md create mode 100644 docs/PLUGIN_TEMPLATE.md create mode 100644 pkg/plugin/agents.go create mode 100644 pkg/plugin/init.go diff --git a/cmd/ampel-plugin/README.md b/cmd/ampel-plugin/README.md index 071ea460..e80fedfe 100644 --- a/cmd/ampel-plugin/README.md +++ b/cmd/ampel-plugin/README.md @@ -94,6 +94,8 @@ The plugin uses granular AMPEL policy files (one JSON file per control) stored i Sample policy files are available in the [complytime-demos](https://github.com/complytime/complytime-demos) repository under `base_ansible_env/files/ampel-policies/`. +**Creating AMPEL Policies**: Use `complyctl-provider-ampel init --tool ` to generate AI agent skill definitions for interactive policy creation. See [docs/MIGRATION.md](./docs/MIGRATION.md) for migration from manual `gemara2ampel` script. + ### Generate When the plugin receives the `generate` command from complyctl, it will: @@ -196,6 +198,48 @@ The plugin is discovered automatically by complyctl — no manifest files or che To use the plugin with `complyctl`, see the quick start [guide](../../docs/QUICK_START.md). +## Plugin Commands + +The AMPEL plugin supports two command modes: + +### Default Behavior (serve) + +The plugin starts the gRPC server for runtime policy execution by default. When invoked by complyctl or run directly, it handles `Describe`, `Generate`, and `Scan` RPCs: + +```bash +complyctl-provider-ampel +``` + +The plugin handles `Describe`, `Generate`, and `Scan` RPCs from complyctl. + +### `init --tool ` Command + +Installs AI agent integration artifacts for creating AMPEL policies. Supported agents: + +- `cursor`: Installs Cursor command to `.cursor/commands/ampel-create-policy.md` and skill to `.cursor/skills/ampel-create-policy/SKILL.md` +- `opencode`: Appends skill definition to `.cursorrules` in workspace root +- `claude-code`: Installs Claude Code tool definition to `.claude/ampel-create-policy-tool.md` + +**Usage:** + +```bash +# Install Cursor command and skill (/ampel-create-policy) +complyctl-provider-ampel init --tool cursor + +# Install OpenCode skill definition +complyctl-provider-ampel init --tool opencode + +# Install Claude Code tool definition +complyctl-provider-ampel init --tool claude-code +``` + +The `init` command: +- Creates necessary directories (`.cursor/`, `.claude/`) if they don't exist +- Writes agent-specific skill definitions to the correct locations +- Provides feedback on what was installed + +After installation, AI coding assistants (OpenCode, Cursor, Claude Code) can help users create AMPEL policies from Gemara artifacts interactively. + ### Using complytime-demos with a Fedora 43 VM The [complytime-demos](https://github.com/complytime/complytime-demos) repository provides an automated way to set up a complete environment with complyctl, the ampel-plugin, and all required tools inside a Fedora 43 VM using Vagrant and Ansible. diff --git a/cmd/ampel-plugin/docs/MIGRATION.md b/cmd/ampel-plugin/docs/MIGRATION.md new file mode 100644 index 00000000..f3d75620 --- /dev/null +++ b/cmd/ampel-plugin/docs/MIGRATION.md @@ -0,0 +1,63 @@ +# Migration from gemara2ampel to init --tool + +This document describes how to migrate from using the manual `gemara2ampel` script to using AI-assisted policy creation with the `init --tool` command. + +## Overview + +Previously, creating AMPEL policies from Gemara artifacts required: +1. Running the `gemara2ampel` script manually +2. Converting Gemara artifacts to AMPEL format +3. Manually editing and validating policies + +With the new `init --tool` command, you can: +1. Generate AI agent skill definitions for your preferred tool (OpenCode, Cursor, Claude Code) +2. Use AI assistants to help create AMPEL policies interactively +3. Validate policies against the AMPEL schema automatically + +## Migration Steps + +### Step 1: Install Skill Definitions + +Choose your AI coding assistant and install the appropriate skill definition: + +```bash +# For OpenCode (installs to .cursorrules) +complyctl-provider-ampel init --tool opencode + +# For Cursor (installs to .cursor/commands/ampel-create-policy.md and .cursor/skills/ampel-create-policy/SKILL.md) +complyctl-provider-ampel init --tool cursor + +# For Claude Code (installs to .claude/ampel-policy-tool.json) +complyctl-provider-ampel init --tool claude-code +``` + +The `init` command automatically creates the necessary directories and installs the skill definitions in the correct locations. + +### Step 3: Use AI Assistant to Create Policies + +Instead of running `gemara2ampel` manually, use your AI assistant: + +- **OpenCode**: Ask it to create an AMPEL policy from a Gemara requirement +- **Cursor**: Use the `/ampel-create-policy` slash command +- **Claude Code**: Invoke the `create_ampel_policy` tool + +The AI assistant will: +- Understand the AMPEL policy format +- Convert Gemara requirements to AMPEL policies +- Validate against the schema +- Provide guidance on policy structure + +### Step 4: Validate Policies + +Policies created with AI assistance should follow the AMPEL schema. The skill definitions include the complete JSON Schema for validation. + +## Benefits + +- **Interactive**: Get help from AI as you create policies +- **Validated**: Schema validation ensures correct structure +- **Documented**: Skill definitions include conversion guidelines +- **Consistent**: AI follows the same patterns every time + +## gemara2ampel Script Status + +The `gemara2ampel` script remains available in the [complytime-demos](https://github.com/complytime/complytime-demos) repository for batch conversion workflows. For interactive policy creation, prefer using `init --tool` with your AI coding assistant. diff --git a/cmd/ampel-plugin/docs/STRATEGY.md b/cmd/ampel-plugin/docs/STRATEGY.md index 89ac39c4..48829ada 100644 --- a/cmd/ampel-plugin/docs/STRATEGY.md +++ b/cmd/ampel-plugin/docs/STRATEGY.md @@ -18,6 +18,8 @@ During the `generate` phase, the plugin receives assessment configurations from This granular approach is also aligned with how the [Gemara2Ampel](https://github.com/complytime/complytime-demos/tree/main/tools/gemara2ampel) tool works: Gemara Layer 3 policies map naturally to individual AMPEL policy files, and the workspace mode (`-w`) of Gemara2Ampel already produces one file per policy. When complyctl adopts Gemara as its policy source, this granular structure will allow the plugin to consume Gemara-generated policies without changes to the matching and merging logic. +**Note**: For interactive policy creation, use `complyctl-provider-ampel init --tool ` to generate AI agent skill definitions. See [MIGRATION.md](./MIGRATION.md) for details. + ## Multi-Target Scanning The ampel-plugin introduces multi-target scanning to complyctl. Unlike the existing openscap-plugin, which scans the local system it runs on, the ampel-plugin scans remote repositories defined in the `complytime.yaml` configuration. @@ -36,8 +38,10 @@ Together, these capabilities position complyctl as the layer that connects organ ## Next Actions -1. **Review and update the Gemara2Ampel tool to work with granular AMPEL policies** - The Gemara2Ampel converter currently supports workspace mode for generating individual policy files. It should be reviewed to ensure its output aligns with the granular policy format expected by the ampel-plugin (policy ID, meta.controls, tenets structure), so that Gemara-generated policies can be used directly as input to the plugin without manual adjustments. +1. **Use AI-assisted policy creation with `init --tool`** + For interactive policy creation, use `complyctl-provider-ampel init --tool ` to generate AI agent skill definitions. This enables AI coding assistants (Cursor, OpenCode, Claude Code) to help create AMPEL policies from Gemara artifacts. See [MIGRATION.md](./MIGRATION.md) for migration guidance. + + The Gemara2Ampel converter remains available in [complytime-demos](https://github.com/complytime/complytime-demos) for batch conversion workflows. Its workspace mode (`-w`) produces individual policy files that align with the granular policy format expected by the ampel-plugin (policy ID, meta.controls, tenets structure). 2. **Evolve the plugin API alongside complyctl** As complyctl's Gemara integration matures, the plugin's `Generate` phase may need to handle additional policy metadata or parameters. The matching and merging logic in the `convert` package is isolated for this purpose. The `server.Generate` method should be updated as the API evolves. diff --git a/cmd/ampel-plugin/main.go b/cmd/ampel-plugin/main.go index f39d40aa..010a8573 100644 --- a/cmd/ampel-plugin/main.go +++ b/cmd/ampel-plugin/main.go @@ -1,11 +1,30 @@ package main import ( + _ "embed" + "github.com/complytime/complyctl/cmd/ampel-plugin/server" "github.com/complytime/complyctl/pkg/plugin" ) +//go:embed templates/command.md +var commandTemplate string + +//go:embed templates/skill.md +var skillTemplate string + func main() { + // Register init command handler + if plugin.RegisterInit(plugin.InitTemplates{ + CommandTemplate: commandTemplate, + SkillTemplate: skillTemplate, + CommandName: "ampel-create-policy", + SkillName: "ampel-create-policy", + }) { + return + } + + // Default behavior: serve the plugin ampelPlugin := server.New() plugin.Serve(ampelPlugin) } diff --git a/cmd/ampel-plugin/templates/command.md b/cmd/ampel-plugin/templates/command.md new file mode 100644 index 00000000..428d8821 --- /dev/null +++ b/cmd/ampel-plugin/templates/command.md @@ -0,0 +1,172 @@ +--- +name: /ampel-create-policy +id: ampel-create-policy +category: Policy +description: Create an AMPEL policy from a Gemara requirement +--- + +Create an AMPEL policy from a Gemara requirement. + +I'll help you convert a Gemara requirement to an AMPEL policy file with the correct structure, including policy metadata, control references, and verification tenets. + +--- + +**Input**: The user should provide: +- Gemara requirement ID (e.g., 'ac-3') +- Compliance framework and control references (e.g., NIST-800-53 AC-3) +- Description of what the policy should verify + +**Steps** + +1. **Gather requirement information** + - Extract or ask for the Gemara requirement ID + - Identify compliance control references (framework, class, id) + - Understand what the policy should verify + +2. **Create AMPEL policy structure** + - Policy id: Convert requirement ID to kebab-case (e.g., 'ac-3' → 'ac-3-access-control-enforcement') + - meta.description: Clear description of what the policy verifies + - meta.controls: Array of control references with framework, class, and id + +3. **Define verification tenets** + - Create tenets array with verification checks + - Each tenet should have: + - id: Unique tenet identifier + - code: CEL expression that evaluates the attestation data + - predicates.types: Attestation predicate type URIs + - assessment.message: Success message + - error.message and error.guidance: Failure remediation + +4. **Validate policy structure** + - Ensure all required fields are present: id, meta, tenets + - Verify meta has description and controls + - Check each tenet has id, code, and predicates fields + +5. **Write policy file** + - Save as JSON file with kebab-case name matching policy id + - Place in appropriate AMPEL policy directory + +**Output** + +After creating the policy: +- Policy file location +- Summary of policy structure +- List of tenets created +- Next steps (e.g., test with ampel verify) + +**AMPEL Policy Structure** + +AMPEL policies are JSON files with the following structure: + +```json +{ + "id": "BP-1.01", + "meta": { + "description": "Validate branch protection settings require pull requests", + "controls": [ + { "framework": "repo-branch-protection", "class": "source-code", "id": "BP-1" } + ] + }, + "tenets": [ + { + "id": "01", + "code": "has(predicates[0].data.values) ? predicates[0].data.values.exists(rule, rule.type == \"update\") : false", + "predicates": { + "types": ["http://github.com/carabiner-dev/snappy/specs/branch-rules.yaml"] + }, + "assessment": { + "message": "Direct pushes are disabled in default branch. PR required." + }, + "error": { + "message": "Direct pushes are enabled so PRs are not required.", + "guidance": "Create a branch ruleset protecting your default branch and enable \"Restrict updates\"" + } + } + ] +} +``` + +**Input Data Structure (Snappy Attestations)** + +AMPEL policies evaluate in-toto attestations produced by snappy. The attestation structure is: + +```json +{ + "_type": "https://in-toto.io/Statement/v1", + "subject": [...], + "predicateType": "http://github.com/carabiner-dev/snappy/specs/branch-rules.yaml", + "predicate": { + "data": { + "values": [ + { + "type": "update", + "parameters": {} + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "require_last_push_approval": true, + "require_code_owner_review": false + } + }, + { + "type": "non_fast_forward", + "parameters": {} + } + ] + } + } +} +``` + +**How Predicates Work** + +1. Each tenet specifies `predicates.types` array with predicate type URIs (e.g., `http://github.com/carabiner-dev/snappy/specs/branch-rules.yaml`) +2. AMPEL matches attestations by predicate type URI +3. Matched predicates are available in the `predicates` array (indexed by match order) +4. Access attestation data via `predicates[0].data.values` (array of rule objects) + +**CEL Expression Patterns** + +The `code` field uses CEL (Common Expression Language) to evaluate attestation data: + +- **Check if data exists**: `has(predicates[0].data.values)` +- **Find rule by type**: `predicates[0].data.values.exists(rule, rule.type == "update")` +- **Check rule parameters**: `predicates[0].data.values.exists(rule, rule.type == "pull_request" && rule.parameters.required_approving_review_count >= 1)` +- **Multiple conditions**: `predicates[0].data.values.exists(rule, rule.type == "pull_request" && rule.parameters.dismiss_stale_reviews_on_push == true)` + +**Common Rule Types (GitHub Branch Protection)** + +- `"update"`: Restricts direct pushes (requires PR) +- `"pull_request"`: Pull request requirements + - `parameters.required_approving_review_count`: Minimum approvals (integer) + - `parameters.dismiss_stale_reviews_on_push`: Dismiss stale reviews (boolean) + - `parameters.require_last_push_approval`: Require last push approval (boolean) + - `parameters.require_code_owner_review`: Require code owner review (boolean) +- `"non_fast_forward"`: Blocks force pushes + +**Example CEL Expressions** + +```cel +// Check if direct pushes are disabled +has(predicates[0].data.values) ? predicates[0].data.values.exists(rule, rule.type == "update") : false + +// Check minimum approval count +has(predicates[0].data.values) ? predicates[0].data.values.exists(rule, rule.type == "pull_request" && rule.parameters.required_approving_review_count >= 1) : false + +// Check multiple PR requirements +has(predicates[0].data.values) ? predicates[0].data.values.exists(rule, rule.type == "pull_request" && rule.parameters.dismiss_stale_reviews_on_push == true && rule.parameters.require_last_push_approval == true) : false + +// Check force push blocking +has(predicates[0].data.values) ? predicates[0].data.values.exists(rule, rule.type == "non_fast_forward") : false +``` + +**Gemara to AMPEL Conversion Guidelines** + +- Map Gemara requirement ID to AMPEL policy id (kebab-case) +- Convert Gemara control references to meta.controls array format +- Transform Gemara assessment requirements into AMPEL tenets +- Each tenet represents a specific verification check +- Use CEL expressions to evaluate snappy attestation data structure diff --git a/cmd/ampel-plugin/templates/skill.md b/cmd/ampel-plugin/templates/skill.md new file mode 100644 index 00000000..20d7c02c --- /dev/null +++ b/cmd/ampel-plugin/templates/skill.md @@ -0,0 +1,169 @@ +# AMPEL Policy Creation Skill + +This skill enables creating AMPEL (Attestation Metadata Policy Expression Language) policies from Gemara requirements and compliance controls. + +## Overview + +AMPEL policies define verification rules for in-toto attestations. They evaluate attestation data using CEL (Common Expression Language) expressions to determine compliance with security controls. + +## Core Concepts + +### Policy Structure + +AMPEL policies are JSON files with three main sections: + +1. **Policy ID**: Unique identifier (kebab-case) +2. **Metadata**: Description and control references +3. **Tenets**: Array of verification checks + +### Tenet Structure + +Each tenet represents a specific verification check: + +- **id**: Unique tenet identifier (typically sequential: "01", "02", etc.) +- **code**: CEL expression that evaluates attestation data +- **predicates.types**: Array of predicate type URIs that this tenet matches +- **assessment.message**: Success message when tenet passes +- **error.message**: Failure message when tenet fails +- **error.guidance**: Remediation guidance for failures + +### Predicate Matching + +AMPEL matches attestations by predicate type URI: + +1. Each tenet specifies `predicates.types` array +2. AMPEL finds attestations matching those URIs +3. Matched predicates are available in `predicates` array (indexed by match order) +4. Access data via `predicates[0].data.values` + +## AMPEL Policy Template + +```json +{ + "id": "policy-id-kebab-case", + "meta": { + "description": "Clear description of what this policy verifies", + "controls": [ + { + "framework": "framework-name", + "class": "control-class", + "id": "control-id" + } + ] + }, + "tenets": [ + { + "id": "01", + "code": "has(predicates[0].data.values) ? predicates[0].data.values.exists(rule, rule.type == \"rule-type\") : false", + "predicates": { + "types": ["http://github.com/carabiner-dev/snappy/specs/spec-name.yaml"] + }, + "assessment": { + "message": "Success message describing what passed" + }, + "error": { + "message": "Failure message describing what failed", + "guidance": "Actionable remediation guidance" + } + } + ] +} +``` + +## CEL Expression Patterns + +### Basic Checks + +```cel +// Check if data exists +has(predicates[0].data.values) + +// Find rule by type +predicates[0].data.values.exists(rule, rule.type == "update") + +// Check rule parameters +predicates[0].data.values.exists(rule, rule.type == "pull_request" && rule.parameters.required_approving_review_count >= 1) + +// Multiple conditions +predicates[0].data.values.exists(rule, rule.type == "pull_request" && rule.parameters.dismiss_stale_reviews_on_push == true) +``` + +### Safe Evaluation Pattern + +Always use safe evaluation to handle missing data: + +```cel +has(predicates[0].data.values) ? predicates[0].data.values.exists(rule, rule.type == "update") : false +``` + +## Common Snappy Specs + +### GitHub Branch Protection + +**Predicate Type**: `http://github.com/carabiner-dev/snappy/specs/branch-rules.yaml` + +**Rule Types**: +- `"update"`: Restricts direct pushes (requires PR) +- `"pull_request"`: Pull request requirements + - `parameters.required_approving_review_count`: Minimum approvals (integer) + - `parameters.dismiss_stale_reviews_on_push`: Dismiss stale reviews (boolean) + - `parameters.require_last_push_approval`: Require last push approval (boolean) + - `parameters.require_code_owner_review`: Require code owner review (boolean) +- `"non_fast_forward"`: Blocks force pushes + +**Example Tenet**: + +```json +{ + "id": "01", + "code": "has(predicates[0].data.values) ? predicates[0].data.values.exists(rule, rule.type == \"update\") : false", + "predicates": { + "types": ["http://github.com/carabiner-dev/snappy/specs/branch-rules.yaml"] + }, + "assessment": { + "message": "Direct pushes are disabled. Pull requests required." + }, + "error": { + "message": "Direct pushes are enabled. Pull requests are not required.", + "guidance": "Create a branch protection rule and enable 'Restrict updates' to require pull requests" + } +} +``` + +## Conversion Workflow + +### From Gemara Requirement + +1. **Extract Requirement ID**: Convert to kebab-case for policy id +2. **Map Controls**: Convert Gemara control references to `meta.controls` format +3. **Identify Verification Points**: Break down requirement into specific checks +4. **Create Tenets**: One tenet per verification check +5. **Write CEL Expressions**: Evaluate attestation data structure +6. **Add Messages**: Provide clear success/failure messages and guidance + +### Best Practices + +- **One Check Per Tenet**: Each tenet should verify a single, specific condition +- **Clear Messages**: Assessment and error messages should be actionable +- **Safe Evaluation**: Always check for data existence before accessing +- **Descriptive IDs**: Use sequential IDs ("01", "02") that map to requirement sections +- **Control Mapping**: Include all relevant compliance framework references + +## File Naming Convention + +- Policy files: `{policy-id}.json` (matches policy id field) +- Location: `{workspace}/ampel/granular-policies/` (default) +- Example: `ac-3-access-control-enforcement.json` + +## Validation Checklist + +Before saving a policy, verify: + +- [ ] Policy id is kebab-case and matches filename +- [ ] meta.description clearly describes verification purpose +- [ ] meta.controls includes all relevant framework references +- [ ] Each tenet has unique id +- [ ] Each tenet has valid CEL expression in code field +- [ ] Each tenet specifies predicates.types array +- [ ] Assessment and error messages are clear and actionable +- [ ] CEL expressions use safe evaluation pattern diff --git a/docs/PLUGIN_GUIDE.md b/docs/PLUGIN_GUIDE.md index a1e7a8b8..e9dfc6b0 100644 --- a/docs/PLUGIN_GUIDE.md +++ b/docs/PLUGIN_GUIDE.md @@ -67,15 +67,51 @@ The canonical protobuf definition lives at `api/plugin/plugin.proto`. Key types: | `ConfidenceLevel` | Enum: NOT_SET, UNDETERMINED, LOW, MEDIUM, HIGH | | `Result` | Enum: UNSPECIFIED, PASSED, FAILED, SKIPPED, ERROR | +## Plugin Commands + +Plugins support an optional development command: + +- **Default behavior**: Starts the gRPC server for runtime policy execution (handles `Describe`, `Generate`, `Scan` RPCs) +- **`init --tool `**: Generates AI agent integration artifacts for the specified tool (cursor, opencode, claude-code) + +Plugins use `plugin.RegisterInit()` to register the init command handler. If no command is provided or the command is unrecognized, the plugin serves by default. + +### Init Command Registration + +Plugins register the init command using `plugin.RegisterInit()` with templates: + +```go +if plugin.RegisterInit(plugin.InitTemplates{ + CommandTemplate: commandTemplate, // Markdown template for command definition + SkillTemplate: skillTemplate, // Markdown template for skill definition + CommandName: "my-command", // Kebab-case command name + SkillName: "my-skill", // Optional: defaults to CommandName +}) { + return // Command handled, exit +} +``` + +### Init Command Installation + +The `init --tool ` command installs skill definitions directly to the correct locations: + +- **Cursor**: Writes to `.cursor/commands/{command-name}.md` and `.cursor/skills/{skill-name}/SKILL.md` +- **OpenCode**: Appends to `.cursorrules` in workspace root +- **Claude Code**: Writes to `.claude/{command-name}-tool.md` + +The command automatically detects the workspace root (by finding `.git` directory or using current directory) and installs files accordingly. + ## Authoring a Plugin (Go) -Use `plugin.Serve()` to register and start the gRPC server. The handshake is handled automatically. +Plugins serve by default. Use `plugin.Serve()` to register and start the gRPC server. The handshake is handled automatically. Optionally, plugins can register an `init` command to set up development tooling. ```go package main import ( "context" + "fmt" + "os" "github.com/complytime/complyctl/pkg/plugin" ) @@ -121,6 +157,16 @@ func (p *myPlugin) Scan(_ context.Context, req *plugin.ScanRequest) (*plugin.Sca } func main() { + // Register init command handler (optional) + if plugin.RegisterInit(plugin.InitTemplates{ + CommandTemplate: commandTemplate, // Embed or load your command template + SkillTemplate: skillTemplate, // Embed or load your skill template + CommandName: "my-command", + }) { + return // Command handled, exit + } + + // Default behavior: serve the plugin plugin.Serve(&myPlugin{}) } ``` @@ -168,7 +214,9 @@ targets: ## Reference Implementation -See `cmd/test-plugin/main.go` for a complete working example. Build with: +See `cmd/test-plugin/main.go` for a complete working example. See `cmd/ampel-plugin/main.go` for an example implementing the default serve behavior and optional `init` command. See `docs/PLUGIN_TEMPLATE.md` for a template showing the command structure. + +Build with: ```bash make build-test-plugin diff --git a/docs/PLUGIN_TEMPLATE.md b/docs/PLUGIN_TEMPLATE.md new file mode 100644 index 00000000..74d4983a --- /dev/null +++ b/docs/PLUGIN_TEMPLATE.md @@ -0,0 +1,90 @@ +# Plugin Command Template + +This template shows how to implement the default serve behavior and optional `init` command in a complyctl plugin. + +## Basic Structure + +```go +package main + +import ( + _ "embed" + + "github.com/complytime/complyctl/pkg/plugin" + // Import your plugin implementation +) + +//go:embed templates/command.md +var commandTemplate string + +//go:embed templates/skill.md +var skillTemplate string + +func main() { + // Register init command handler (optional) + if plugin.RegisterInit(plugin.InitTemplates{ + CommandTemplate: commandTemplate, + SkillTemplate: skillTemplate, + CommandName: "my-command", + SkillName: "my-skill", // Optional: defaults to CommandName + }) { + return // Command handled, exit + } + + // Default behavior: serve the plugin + myPlugin := NewMyPlugin() + plugin.Serve(myPlugin) +} +``` + +## Default Behavior (Serve) + +The plugin serves by default, starting the gRPC server that handles runtime policy execution: + +```go +// Default behavior: serve the plugin +myPlugin := NewMyPlugin() +plugin.Serve(myPlugin) +``` + +This is called automatically by complyctl when the plugin is discovered and loaded, or when the plugin is run without arguments. + +## Init Command + +The `init` command generates AI agent integration artifacts. It is handled automatically by `plugin.RegisterInit()`. + +### Template Files + +Create two template files in your plugin: + +1. **`templates/command.md`**: Command definition template (Cursor format) + - Contains the command metadata and instructions + - See `cmd/ampel-plugin/templates/command.md` for an example + +2. **`templates/skill.md`**: Skill definition template + - Contains the skill documentation and guidelines + - See `cmd/ampel-plugin/templates/skill.md` for an example + +### Usage + +Users run: +```bash +complyctl-provider-myplugin init --tool cursor +``` + +The library handles: +- Parsing the `--tool` flag +- Finding the workspace root +- Creating necessary directories +- Writing files to agent-specific locations +- Providing user feedback + +### Supported Agents + +- **cursor**: Installs to `.cursor/commands/` and `.cursor/skills/` +- **opencode**: Appends to `.cursorrules` +- **claude-code**: Writes to `.claude/` + +## Reference Implementation + +See `cmd/ampel-plugin/main.go` and `cmd/ampel-plugin/templates/` for a complete working example. diff --git a/pkg/plugin/agents.go b/pkg/plugin/agents.go new file mode 100644 index 00000000..e65d2585 --- /dev/null +++ b/pkg/plugin/agents.go @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 + +package plugin + +import ( + "fmt" + "os" + "path/filepath" +) + +// installCursor installs Cursor agent integration files. +func installCursor(workspaceRoot string, templates InitTemplates) error { + cursorDir := filepath.Join(workspaceRoot, ".cursor") + commandsDir := filepath.Join(cursorDir, "commands") + skillsDir := filepath.Join(cursorDir, "skills") + skillDir := filepath.Join(skillsDir, templates.SkillName) + + // Create directories + if err := os.MkdirAll(commandsDir, 0755); err != nil { + return fmt.Errorf("failed to create commands directory: %w", err) + } + if err := os.MkdirAll(skillDir, 0755); err != nil { + return fmt.Errorf("failed to create skills directory: %w", err) + } + + // Create command file + commandPath := filepath.Join(commandsDir, templates.CommandName+".md") + if err := os.WriteFile(commandPath, []byte(templates.CommandTemplate), 0644); err != nil { + return fmt.Errorf("failed to write command file: %w", err) + } + + // Create skill file + skillPath := filepath.Join(skillDir, "SKILL.md") + if err := os.WriteFile(skillPath, []byte(templates.SkillTemplate), 0644); err != nil { + return fmt.Errorf("failed to write skill file: %w", err) + } + + fmt.Fprintf(os.Stdout, "✓ Installed Cursor command: %s\n", commandPath) + fmt.Fprintf(os.Stdout, "✓ Installed Cursor skill: %s\n", skillPath) + fmt.Fprintf(os.Stdout, " Command: /%s\n", templates.CommandName) + return nil +} + +// installOpenCode installs OpenCode agent integration files. +func installOpenCode(workspaceRoot string, templates InitTemplates) error { + // OpenCode uses .cursorrules file in workspace root + cursorRulesPath := filepath.Join(workspaceRoot, ".cursorrules") + + // Append to existing .cursorrules or create new + var content []byte + if existing, err := os.ReadFile(cursorRulesPath); err == nil { + content = existing + // Add separator if file doesn't end with newline + if len(content) > 0 && content[len(content)-1] != '\n' { + content = append(content, '\n') + } + content = append(content, '\n') + } + + // Append skill template + content = append(content, []byte(templates.SkillTemplate)...) + content = append(content, '\n') + + if err := os.WriteFile(cursorRulesPath, content, 0644); err != nil { + return fmt.Errorf("failed to write .cursorrules file: %w", err) + } + + fmt.Fprintf(os.Stdout, "✓ Installed OpenCode integration: %s\n", cursorRulesPath) + return nil +} + +// installClaudeCode installs Claude Code agent integration files. +func installClaudeCode(workspaceRoot string, templates InitTemplates) error { + claudeDir := filepath.Join(workspaceRoot, ".claude") + + // Create .claude directory if it doesn't exist + if err := os.MkdirAll(claudeDir, 0755); err != nil { + return fmt.Errorf("failed to create .claude directory: %w", err) + } + + // Claude Code uses JSON tool definition + // For now, we'll create a markdown file - plugins can extend this + toolPath := filepath.Join(claudeDir, templates.CommandName+"-tool.md") + if err := os.WriteFile(toolPath, []byte(templates.SkillTemplate), 0644); err != nil { + return fmt.Errorf("failed to write Claude Code tool file: %w", err) + } + + fmt.Fprintf(os.Stdout, "✓ Installed Claude Code tool: %s\n", toolPath) + fmt.Fprintf(os.Stdout, " Note: Claude Code may require JSON format. Check Claude Code documentation.\n") + return nil +} diff --git a/pkg/plugin/init.go b/pkg/plugin/init.go new file mode 100644 index 00000000..587b8654 --- /dev/null +++ b/pkg/plugin/init.go @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: Apache-2.0 + +package plugin + +import ( + "flag" + "fmt" + "os" + "path/filepath" +) + +// InitTemplates defines the templates a plugin provides for agent integration. +type InitTemplates struct { + // CommandTemplate is the markdown template for the command definition. + // Required for Cursor agent. + CommandTemplate string + + // SkillTemplate is the markdown template for the skill definition. + // Required for Cursor agent. + SkillTemplate string + + // CommandName is the kebab-case name of the command (e.g., "ampel-create-policy"). + // Used to generate file paths and command references. + CommandName string + + // SkillName is the kebab-case name of the skill directory (e.g., "ampel-create-policy"). + // If empty, defaults to CommandName. + SkillName string +} + +// RegisterInit registers the init command handler for a plugin. +// Plugins should call this from main() before calling Serve(). +// If os.Args[1] == "init", it handles the command and exits. +// Otherwise, it returns false and the plugin should continue to Serve(). +func RegisterInit(templates InitTemplates) bool { + if len(os.Args) < 2 || os.Args[1] != "init" { + return false + } + + if err := runInit(os.Args[2:], templates); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + return true +} + +// runInit executes the init command with the given arguments and templates. +func runInit(args []string, templates InitTemplates) error { + fs := flag.NewFlagSet("init", flag.ContinueOnError) + tool := fs.String("tool", "", "AI agent tool (cursor, opencode, claude-code)") + + if err := fs.Parse(args); err != nil { + return err + } + + if *tool == "" { + fmt.Fprintf(os.Stderr, "Error: --tool flag is required\n") + fmt.Fprintf(os.Stderr, "Usage: init --tool \n") + fmt.Fprintf(os.Stderr, "\nSupported tools:\n") + fmt.Fprintf(os.Stderr, " - cursor\n") + fmt.Fprintf(os.Stderr, " - opencode\n") + fmt.Fprintf(os.Stderr, " - claude-code\n") + return fmt.Errorf("--tool flag required") + } + + // Validate templates + if templates.CommandTemplate == "" { + return fmt.Errorf("CommandTemplate is required") + } + if templates.CommandName == "" { + return fmt.Errorf("CommandName is required") + } + if templates.SkillName == "" { + templates.SkillName = templates.CommandName + } + + // Find workspace root + workspaceRoot, err := findWorkspaceRoot() + if err != nil { + return fmt.Errorf("failed to find workspace root: %w", err) + } + + // Route to agent-specific handler + switch *tool { + case "cursor": + return installCursor(workspaceRoot, templates) + case "opencode": + return installOpenCode(workspaceRoot, templates) + case "claude-code": + return installClaudeCode(workspaceRoot, templates) + default: + return fmt.Errorf("unsupported tool: %s", *tool) + } +} + +// findWorkspaceRoot finds the workspace root directory. +// It looks for .git directory or uses current directory. +func findWorkspaceRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + + // Walk up to find .git directory + for { + gitPath := filepath.Join(dir, ".git") + if _, err := os.Stat(gitPath); err == nil { + return dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + // Reached root, use current directory + return dir, nil + } + dir = parent + } +}