From b1d565e25a01d45ed151b1e898af14428f9d1229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Fri, 13 Mar 2026 14:06:04 +0000 Subject: [PATCH 01/48] refactor: rename release job from manual-release [skip ci] --- .github/workflows/release.yaml | 4 ++-- cinzel/jobs.hcl | 4 ++-- cinzel/workflows.hcl | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 013c8aa..f8a9303 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -14,8 +14,8 @@ on: permissions: contents: read jobs: - manual-release: - name: Manual release + release: + name: Release permissions: contents: write runs-on: ubuntu-latest diff --git a/cinzel/jobs.hcl b/cinzel/jobs.hcl index a1e3e32..9cdf20f 100644 --- a/cinzel/jobs.hcl +++ b/cinzel/jobs.hcl @@ -50,8 +50,8 @@ job "release-packages" { ] } -job "manual-release" { - name = "Manual release" +job "release" { + name = "Release" timeout_minutes = 20 diff --git a/cinzel/workflows.hcl b/cinzel/workflows.hcl index 35dcc6b..3094c2e 100644 --- a/cinzel/workflows.hcl +++ b/cinzel/workflows.hcl @@ -67,6 +67,6 @@ workflow "release" { } jobs = [ - job.manual-release, + job.release, ] } From a89d1e901366c865db79ca4418c39586aa773f24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Mon, 16 Mar 2026 16:25:28 +0000 Subject: [PATCH 02/48] docs: brainstorm cinzel assist AI workflow gen --- ...at-cinzel-assist-ai-workflow-generation.md | 283 ++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 docs/brainstorms/2026-03-16-feat-cinzel-assist-ai-workflow-generation.md diff --git a/docs/brainstorms/2026-03-16-feat-cinzel-assist-ai-workflow-generation.md b/docs/brainstorms/2026-03-16-feat-cinzel-assist-ai-workflow-generation.md new file mode 100644 index 0000000..6eb0373 --- /dev/null +++ b/docs/brainstorms/2026-03-16-feat-cinzel-assist-ai-workflow-generation.md @@ -0,0 +1,283 @@ +# Brainstorm: `cinzel assist` — AI-powered workflow generation + +**Date**: 2026-03-16 +**Status**: brainstorm + +## Summary + +Add an `assist` subcommand to cinzel that takes a natural language prompt and generates HCL workflow definitions. Works across all providers (GitHub, GitLab, etc.). + +## Core approach: YAML-then-unparse + +The LLM generates standard CI/CD YAML (which it already knows well), then cinzel's existing unparse engine converts it to HCL. This avoids teaching the LLM cinzel's custom HCL schema. + +``` +User prompt → LLM → provider YAML → cinzel unparse → HCL files +``` + +The unparse step acts as both converter and validator — if the YAML is invalid for the provider, unparse will reject it. + +## CLI interface + +Mirrors the existing parse/unparse pattern: + +```sh +cinzel github assist --prompt "golang PR with tests and linting" --output-directory ./cinzel +cinzel gitlab assist --prompt "node.js pipeline with docker build" --output-directory ./cinzel +``` + +Flags: +- `--prompt` or positional argument — the natural language description +- `--output-directory` — where to write HCL files (default: `./cinzel`) +- `--provider` — AI provider: `anthropic` or `openai` (default: from config/env) +- `--model` — model override (default: from config/env) +- `--dry-run` — output to stdout instead of writing files + +After generating HCL, the user runs their normal workflow: + +```sh +cinzel github assist --prompt "golang PR workflow" # → ./cinzel/*.hcl +cinzel github parse --file ./cinzel --output-directory .github/workflows # → YAML +``` + +## AI provider support + +Two providers at launch: Anthropic and OpenAI. + +### Configuration + +Environment variables (simplest): + +```sh +export CINZEL_AI_PROVIDER=anthropic # anthropic | openai +export CINZEL_AI_MODEL=claude-sonnet-4-5-20250514 +export ANTHROPIC_API_KEY=sk-ant-... + +# or for OpenAI +export CINZEL_AI_PROVIDER=openai +export CINZEL_AI_MODEL=gpt-4o +export OPENAI_API_KEY=sk-... +``` + +Optional config file (`~/.config/cinzel/config.toml`): + +```toml +[ai] +provider = "anthropic" +model = "claude-sonnet-4-5-20250514" +``` + +API keys always come from environment variables, never stored in config files. + +### Provider interface + +```go +// internal/ai/provider.go +type Provider interface { + Generate(ctx context.Context, req GenerateRequest) (string, error) +} + +type GenerateRequest struct { + SystemPrompt string + UserPrompt string + Model string +} +``` + +Implementations: +- `internal/ai/anthropic.go` — uses `github.com/anthropics/anthropic-sdk-go` +- `internal/ai/openai.go` — uses `github.com/sashabaranov/go-openai` + +OpenAI-compatible API also covers local models (Ollama, LM Studio) via custom endpoint, but that's not a launch goal. + +## System prompt design + +The system prompt is provider-specific (github vs gitlab) and instructs the LLM to: + +1. Generate valid CI/CD YAML for the target provider +2. Use SHA-pinned action references where possible +3. Follow security best practices (least-privilege permissions, no hardcoded secrets) +4. Output only YAML, no markdown wrapping + +Example system prompt skeleton: + +``` +You are a CI/CD workflow generator for {provider}. +Generate valid {provider format} YAML based on the user's description. + +Rules: +- Output only valid YAML, no markdown code fences +- Use SHA-pinned action versions (not tags) +- Set minimum required permissions +- Use environment variables for secrets, never hardcode +- Include descriptive step names + +{provider-specific schema reference if needed} +``` + +The system prompt can include a curated example YAML for the provider to anchor the output format. + +## Flow detail + +``` +1. User runs: cinzel github assist --prompt "..." +2. cinzel reads existing ./cinzel/*.hcl for context +3. cinzel selects AI provider from config/env +4. cinzel builds system prompt for "github" provider + existing HCL context +5. LLM generates GitHub Actions YAML +6. cinzel runs unparse (YAML → HCL) internally +7. If unparse succeeds: write HCL to --output-directory +8. If unparse fails (retry ≤ 2): feed error back to LLM, go to step 5 +9. If unparse still fails: show error + raw YAML, suggest refining the prompt +``` + +## Provider-agnostic design + +The `assist` subcommand is registered per provider, but the AI layer is shared: + +``` +cinzel github assist → github system prompt → LLM → github YAML → github.Unparse() +cinzel gitlab assist → gitlab system prompt → LLM → gitlab YAML → gitlab.Unparse() +``` + +Each provider supplies: +- A system prompt tailored to its YAML format +- Its existing `Unparse()` function for validation and conversion + +The AI infrastructure (`internal/ai/`) is completely provider-agnostic. + +## Decisions made + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Generation strategy | YAML-then-unparse | LLMs know CI/CD YAML well; unparse validates and converts | +| AI providers | Anthropic + OpenAI | Covers majority of users | +| Subcommand name | `assist` | Neutral, works across providers | +| Output location | `./cinzel/assist/` | Non-destructive, user reviews before merging | +| Templates | No | cinzel is a tool, not a template library; LLM is the template engine | +| Config | Env vars + optional TOML | Simple, secure (no keys in files) | +| Privacy | Redact secrets/org names before sending | Never leak sensitive data to external APIs | +| Action versions | LLM outputs tags, `cinzel pin` resolves SHAs post-generation | LLMs don't know current SHAs; post-processing is reliable | +| Validation retry | Up to 2 retries with error feedback | Balances success rate vs cost | +| Testing | No unit tests for LLM output | Non-deterministic; validate via unparse success | +| Context injection | Redacted existing HCL (steps + variables only) | Gives LLM structural awareness without leaking data | + +## Post-processing pipeline + +After the LLM generates YAML and unparse converts to HCL, two post-processing steps run: + +### 1. Action version pinning (`cinzel pin`) + +The LLM outputs tag-based versions (`actions/checkout@v4`) because it doesn't know current SHAs. cinzel resolves them via GitHub API: + +``` +1. Walk HCL AST, find all `uses` blocks +2. For each action@tag: + GET /repos/{owner}/{action}/git/ref/tags/{tag} → SHA +3. Replace: version = "de0fac2e4500dabe..." +4. Add comment: // actions/checkout v4 +``` + +`cinzel pin` is also a standalone command — useful for any HCL file: + +```sh +cinzel github pin --file ./cinzel +``` + +### 2. Redaction restoration + +If context injection was used, restore redacted values in the output (see Privacy section below). + +### Full assist pipeline + +``` +existing HCL → redact → build prompt → LLM → YAML → strip fences → unparse → HCL → pin SHAs → restore redactions → write to ./cinzel/assist/ +``` + +## Privacy: context redaction + +Existing HCL files are valuable context but contain sensitive patterns. cinzel redacts before sending and restores after: + +**Redaction map (built automatically):** + +| Original | Sent to LLM | +|----------|-------------| +| `secrets.RELEASE_APP_ID` | `secrets.SECRET_1` | +| `secrets.RELEASE_PRIVATE_KEY` | `secrets.SECRET_2` | +| `yldio/cinzel` | `org/repo` | +| `homebrew-cinzel` | `secondary-repo` | +| `hello@yld.io` | `team@example.com` | + +**What gets redacted:** +- `secrets.*` references → numbered placeholders +- GitHub org/repo names → generic placeholders +- Email addresses → example.com +- URLs containing org names → sanitized + +**What stays visible** (the LLM needs this): +- Block structure (workflow, job, step shapes) +- Action references (`actions/checkout`, `jdx/mise-action`) +- Step names and ordering +- Attribute keys and types +- Control flow (`if`, `matrix`, `timeout`) + +The LLM sees the structural skeleton without any identifying information. + +## Output: non-destructive assist folder + +`assist` writes to `./cinzel/assist/` by default, never touching existing files: + +``` +cinzel/ + workflows.hcl # existing, untouched + jobs.hcl # existing, untouched + steps.hcl # existing, untouched + assist/ # AI-generated output + pr-workflow.hcl +``` + +The user reviews the generated HCL, then manually moves or merges into main files. This is non-destructive — running `assist` multiple times overwrites only the `assist/` folder. + +## Resolved questions + +1. **Context injection** — Yes. `assist` reads existing `./cinzel/*.hcl` files and includes them in the LLM prompt. This lets the LLM reference existing steps (e.g., `step.checkout`, `step.mise_setup`) instead of duplicating them, and avoids conflicting workflow/job names. + +2. **Validation feedback loop** — Yes. If unparse fails, cinzel feeds the error back to the LLM for a retry. Cap at 2 retries to avoid runaway costs. Flow: generate → unparse → fail → retry with error context → unparse → succeed or abort. + +3. **Multiple files** — Optimize LLM calls. If the prompt implies multiple workflows, generate them in a single LLM call (the YAML can contain multiple documents separated by `---`). Only split into separate calls if the single-call output fails validation. + +## Resolved: remaining questions + +4. **Refinement flow** — Both `--prompt` and `--refine` supported: + ```sh + cinzel github assist --prompt "golang PR with tests" # fresh generation + cinzel github assist --refine "add slack on failure" # iterates on previous + ``` + `--prompt` starts fresh (injects existing `./cinzel/*.hcl` as context). + `--refine` builds on previous output (injects both `./cinzel/*.hcl` AND `./cinzel/assist/*.hcl` as context). The LLM sees the full picture and modifies accordingly. `--refine` without a previous `assist/` output errors with "nothing to refine". + +5. **Cost transparency** — First run shows a confirmation: "This will call {provider} ({model}). API usage will incur costs. Continue? [y/N]". The `--acknowledge` flag bypasses this confirmation for CI or scripted use: + ```sh + cinzel github assist --prompt "..." --acknowledge + ``` + +6. **Streaming** — No streaming. Show a spinner/working message ("Generating workflow...") while waiting for the LLM response. Keep the UX simple. + +## New command: `cinzel pin` + +Standalone command to resolve action tags to SHAs. Works independently of `assist`: + +```sh +cinzel github pin --file ./cinzel # pin all actions in HCL files +cinzel github pin --file ./cinzel/assist # pin only assist output +``` + +This is useful for any HCL file, not just AI-generated ones. + +## Not in scope + +- Hosting an AI API (users bring their own keys) +- Local model support at launch (Ollama could work via OpenAI-compatible endpoint later) +- Template library +- Interactive/conversational mode (can be added later) +- Testing LLM output quality (non-deterministic; unparse validates structure) From 034a077f77afa28a83a88048e52a1540cd680d4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Mon, 16 Mar 2026 17:06:15 +0000 Subject: [PATCH 03/48] docs: brainstorm cinzel assist AI workflow gen --- ...at-cinzel-assist-ai-workflow-generation.md | 88 +++++++++++++++---- 1 file changed, 69 insertions(+), 19 deletions(-) diff --git a/docs/brainstorms/2026-03-16-feat-cinzel-assist-ai-workflow-generation.md b/docs/brainstorms/2026-03-16-feat-cinzel-assist-ai-workflow-generation.md index 6eb0373..243d0cb 100644 --- a/docs/brainstorms/2026-03-16-feat-cinzel-assist-ai-workflow-generation.md +++ b/docs/brainstorms/2026-03-16-feat-cinzel-assist-ai-workflow-generation.md @@ -46,28 +46,50 @@ Two providers at launch: Anthropic and OpenAI. ### Configuration -Environment variables (simplest): +Two levels of YAML config (consistent with existing `.cinzelrc.yaml`): + +**Project-level** (`.cinzelrc.yaml`, committed to git): + +```yaml +ai: + provider: anthropic + model: claude-sonnet-4-5-20250514 +``` + +Non-sensitive settings only. Sets the team's preferred provider and model. + +**User-level** (`os.UserConfigDir()/cinzel/config.yaml`, never committed): + +```yaml +ai: + provider: anthropic + model: claude-sonnet-4-5-20250514 + api_key: sk-ant-... +``` + +Holds API keys and personal overrides. Stored as plaintext on the filesystem (same pattern as `~/.config/gh/hosts.yml`, `~/.docker/config.json`). + +**Environment variables** (highest precedence, for CI/scripted use): ```sh -export CINZEL_AI_PROVIDER=anthropic # anthropic | openai +# Anthropic +export CINZEL_AI_PROVIDER=anthropic export CINZEL_AI_MODEL=claude-sonnet-4-5-20250514 export ANTHROPIC_API_KEY=sk-ant-... -# or for OpenAI +# or OpenAI export CINZEL_AI_PROVIDER=openai export CINZEL_AI_MODEL=gpt-4o export OPENAI_API_KEY=sk-... ``` -Optional config file (`~/.config/cinzel/config.toml`): +**Resolution order** (highest wins): -```toml -[ai] -provider = "anthropic" -model = "claude-sonnet-4-5-20250514" +``` +env vars → user config.yaml → project .cinzelrc.yaml ``` -API keys always come from environment variables, never stored in config files. +All config is YAML — no extra parsing layer beyond what cinzel already uses. ### Provider interface @@ -120,15 +142,19 @@ The system prompt can include a curated example YAML for the provider to anchor ## Flow detail ``` -1. User runs: cinzel github assist --prompt "..." -2. cinzel reads existing ./cinzel/*.hcl for context -3. cinzel selects AI provider from config/env -4. cinzel builds system prompt for "github" provider + existing HCL context -5. LLM generates GitHub Actions YAML -6. cinzel runs unparse (YAML → HCL) internally -7. If unparse succeeds: write HCL to --output-directory -8. If unparse fails (retry ≤ 2): feed error back to LLM, go to step 5 -9. If unparse still fails: show error + raw YAML, suggest refining the prompt +1. User runs: cinzel github assist --prompt "..." +2. cinzel reads existing ./cinzel/*.hcl, redacts sensitive patterns +3. cinzel selects AI provider from config/env +4. cinzel builds prompt: system prompt + redacted HCL context + user prompt +5. Show cost confirmation (or skip with --acknowledge) +6. LLM generates provider YAML (show "Generating workflow..." spinner) +7. Strip markdown fences if present +8. cinzel runs unparse (YAML → HCL) internally +9. If unparse fails (retry ≤ 2): feed error back to LLM, go to step 6 +10. If unparse still fails: show error + raw YAML, suggest refining the prompt +11. Pin action tags to SHAs via GitHub API +12. Restore redacted values in output +13. Write HCL to ./cinzel/assist/ ``` ## Provider-agnostic design @@ -162,6 +188,30 @@ The AI infrastructure (`internal/ai/`) is completely provider-agnostic. | Testing | No unit tests for LLM output | Non-deterministic; validate via unparse success | | Context injection | Redacted existing HCL (steps + variables only) | Gives LLM structural awareness without leaking data | +## Starter workflow grounding + +The system prompt instructs the LLM to base its output on official starter workflows when relevant. LLMs already know these templates from training data — no need for cinzel to fetch them at runtime. The one gap (stale action versions) is solved by `cinzel pin` in post-processing. + +System prompt includes: + +``` +When relevant, base your output on official starter workflows: +- GitHub: github.com/actions/starter-workflows +- GitLab: gitlab.com/gitlab-org/gitlab/-/tree/master/lib/gitlab/ci/templates +Use current best practices and action versions from these sources. +``` + +### Future enhancement: live template fetching + +If LLM output quality proves insufficient without real templates, cinzel could fetch them at runtime: + +| Provider | Repository | +|----------|-----------| +| GitHub | [actions/starter-workflows](https://github.com/actions/starter-workflows) | +| GitLab | [gitlab-org/gitlab/.../ci/templates](https://gitlab.com/gitlab-org/gitlab/-/tree/master/lib/gitlab/ci/templates) | + +This would involve keyword extraction from the prompt, fetching matching templates via API, caching under `os.UserCacheDir()/cinzel/templates/` (OS-agnostic), and injecting them into the prompt. Deferred because it adds HTTP client, caching, keyword matching, and coupling to external repo structures — significant complexity for marginal gain over what the LLM already knows. + ## Post-processing pipeline After the LLM generates YAML and unparse converts to HCL, two post-processing steps run: @@ -191,7 +241,7 @@ If context injection was used, restore redacted values in the output (see Privac ### Full assist pipeline ``` -existing HCL → redact → build prompt → LLM → YAML → strip fences → unparse → HCL → pin SHAs → restore redactions → write to ./cinzel/assist/ +existing HCL → redact → build prompt (system prompt + redacted context + user prompt) → LLM → YAML → strip fences → unparse → HCL → pin SHAs → restore redactions → write to ./cinzel/assist/ ``` ## Privacy: context redaction From 88f5ad24c7f3811b1de5d9c7c229e4d73ed5395b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Mon, 16 Mar 2026 17:10:20 +0000 Subject: [PATCH 04/48] docs: brainstorm cinzel assist AI workflow gen --- ...at-cinzel-assist-ai-workflow-generation.md | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/docs/brainstorms/2026-03-16-feat-cinzel-assist-ai-workflow-generation.md b/docs/brainstorms/2026-03-16-feat-cinzel-assist-ai-workflow-generation.md index 243d0cb..44384db 100644 --- a/docs/brainstorms/2026-03-16-feat-cinzel-assist-ai-workflow-generation.md +++ b/docs/brainstorms/2026-03-16-feat-cinzel-assist-ai-workflow-generation.md @@ -52,44 +52,55 @@ Two levels of YAML config (consistent with existing `.cinzelrc.yaml`): ```yaml ai: - provider: anthropic - model: claude-sonnet-4-5-20250514 + default: anthropic + providers: + anthropic: + model: claude-sonnet-4-5-20250514 + openai: + model: gpt-4o ``` -Non-sensitive settings only. Sets the team's preferred provider and model. +Non-sensitive settings only. Sets the team's preferred default and models per provider. **User-level** (`os.UserConfigDir()/cinzel/config.yaml`, never committed): ```yaml ai: - provider: anthropic - model: claude-sonnet-4-5-20250514 - api_key: sk-ant-... + default: anthropic + providers: + anthropic: + model: claude-sonnet-4-5-20250514 + api_key: sk-ant-... + openai: + model: gpt-4o + api_key: sk-... ``` -Holds API keys and personal overrides. Stored as plaintext on the filesystem (same pattern as `~/.config/gh/hosts.yml`, `~/.docker/config.json`). +Holds API keys and personal overrides. Both providers configured simultaneously — switch between them with `--provider` flag or by changing `default`. Stored as plaintext on the filesystem (same pattern as `~/.config/gh/hosts.yml`, `~/.docker/config.json`). **Environment variables** (highest precedence, for CI/scripted use): ```sh -# Anthropic -export CINZEL_AI_PROVIDER=anthropic -export CINZEL_AI_MODEL=claude-sonnet-4-5-20250514 -export ANTHROPIC_API_KEY=sk-ant-... - -# or OpenAI -export CINZEL_AI_PROVIDER=openai -export CINZEL_AI_MODEL=gpt-4o +export CINZEL_AI_DEFAULT=anthropic # which provider to use +export ANTHROPIC_API_KEY=sk-ant-... # provider-specific keys export OPENAI_API_KEY=sk-... ``` +**CLI flag** (highest precedence, per-invocation): + +```sh +cinzel github assist --prompt "..." # uses ai.default +cinzel github assist --prompt "..." --provider openai # override for this call +cinzel github assist --prompt "..." --provider anthropic --model claude-opus-4-20250514 # override both +``` + **Resolution order** (highest wins): ``` -env vars → user config.yaml → project .cinzelrc.yaml +CLI flags → env vars → user config.yaml → project .cinzelrc.yaml ``` -All config is YAML — no extra parsing layer beyond what cinzel already uses. +All config is YAML — no extra parsing layer beyond what cinzel already uses. Multiple providers can be configured simultaneously; `default` selects which one is used when no flag is passed. ### Provider interface From f6a0dea6c88e81b446837a983c806c3ca862b7a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Mon, 16 Mar 2026 18:32:31 +0000 Subject: [PATCH 05/48] docs: plan cinzel assist AI workflow generation --- ...nzel-assist-ai-workflow-generation-plan.md | 378 ++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 docs/plans/2026-03-16-feat-cinzel-assist-ai-workflow-generation-plan.md diff --git a/docs/plans/2026-03-16-feat-cinzel-assist-ai-workflow-generation-plan.md b/docs/plans/2026-03-16-feat-cinzel-assist-ai-workflow-generation-plan.md new file mode 100644 index 0000000..596203f --- /dev/null +++ b/docs/plans/2026-03-16-feat-cinzel-assist-ai-workflow-generation-plan.md @@ -0,0 +1,378 @@ +--- +title: "feat: cinzel assist — AI-powered workflow generation" +type: feat +status: active +date: 2026-03-16 +origin: docs/brainstorms/2026-03-16-feat-cinzel-assist-ai-workflow-generation.md +--- + +# cinzel assist — AI-powered workflow generation + +## Overview + +Add an `assist` subcommand to cinzel that takes a natural language prompt and generates HCL workflow definitions. The LLM generates standard CI/CD YAML, then cinzel's existing `Unparse` engine converts to HCL and validates the output. No template library — the LLM is the template engine; cinzel stays a pure conversion tool with an AI entry point. + +Works across all providers (`cinzel github assist`, `cinzel gitlab assist`). + +## Problem Statement / Motivation + +Writing CI/CD workflows from scratch requires deep knowledge of provider-specific YAML syntax, available actions, and best practices. cinzel already handles HCL-YAML conversion — adding an AI entry point lets users describe what they want in natural language and get valid, idiomatic HCL output. + +## Proposed Solution + +**v1 pipeline (minimal):** + +``` +prompt → LLM → YAML → strip fences → unparse (via temp file) → HCL → ./cinzel/assist/ +``` + +**Future pipeline (v2+):** + +``` +existing HCL → strip string literals → build prompt → LLM → YAML → strip fences → unparse → HCL → (optional) pin SHAs → ./cinzel/assist/ +``` + +## Technical Approach + +### Architecture + +`assist` lives in the **command layer** — it does NOT modify the `Provider` interface. It orchestrates: call AI → write YAML to temp file → call provider's existing `Unparse` → write HCL output. This avoids interface changes and uses the proven unparse path. + +**Why temp files instead of `UnparseBytes`**: Adding `UnparseBytes` to the `Provider` interface forces every provider to implement it (breaking change). Writing to a temp file and calling the existing `Unparse(ProviderOps)` is ~5 lines, requires zero interface changes, and the overhead is negligible for a command that already waits 10+ seconds for an LLM response. (Architecture review finding #1) + +### Implementation Phases + +#### Phase 1: End-to-end assist (Anthropic only) + +**Goal**: `cinzel github assist --prompt "..."` generates HCL files. Minimal viable pipeline. + +**Scope decisions** (from simplicity review): +- Anthropic only — defer OpenAI and provider interface until demand exists +- Env vars + CLI flags only — defer config files +- No context injection, no redaction — defer to Phase 2 +- No retry loop — show error + raw YAML, user refines prompt +- Temp file approach — no `Provider` interface changes +- Inline fence stripping and prompt constant — no separate files + +**Tasks**: +- Create `internal/ai/doc.go` (package doc comment) +- Create `internal/ai/anthropic.go`: + - Direct Anthropic SDK client (no provider interface abstraction yet) + - `Generate(ctx context.Context, systemPrompt, userPrompt, model string) (string, error)` + - Read `ANTHROPIC_API_KEY` from env var + - 120-second timeout via `context.WithTimeout` + - Spinner ("Generating workflow...") while waiting +- Register `assist` subcommand in `internal/command/command.go:addProvider()`: + - Flags: `--prompt`, `--output-directory` (default `./cinzel/assist/`), `--acknowledge`, `--dry-run` + - Cost confirmation: "This will call Anthropic (claude-sonnet-4-5-20250514). Continue? [y/N]" + - `--acknowledge` bypasses confirmation +- Build system prompt inline (constant string per CI provider): + - Instructs LLM to output only valid YAML, no markdown fences + - References official starter workflows (GitHub/GitLab) + - Requests step names, minimum permissions, no hardcoded secrets +- Strip markdown fences from LLM output (inline function, ~20 lines): + - Handle ` ```yaml `, ` ```yml `, bare ` ``` ` + - Handle multiple YAML documents (split on `---`) +- For multi-document YAML: split on `---`, write each document to a separate temp file, call `Unparse` in a loop +- Temp files must use `.yaml` extension (unparse uses extension for format detection) +- Write YAML to temp file → call `provider.Unparse(ProviderOps{File: tempFile, OutputDirectory: outputDir})` +- Clean up temp files via `defer os.Remove()` +- If unparse fails: show error + raw YAML, suggest refining prompt +- If LLM returns empty/whitespace response: "LLM returned empty response. Try a more specific prompt." +- `--dry-run`: still calls the LLM (costs money) but skips file writing, prints HCL to stdout. The cost confirmation prompt already covers this. +- Output to `--output-directory` (default `./cinzel/assist/`), create dir if needed + +**Acceptance criteria**: +- [ ] `cinzel github assist --prompt "golang PR with tests"` generates HCL in `./cinzel/assist/` +- [ ] `cinzel github assist --prompt "..." --output-directory ./custom` respects flag +- [ ] `cinzel github assist --prompt "..." --dry-run` prints HCL to stdout +- [ ] Markdown fences stripped reliably from LLM output +- [ ] `ANTHROPIC_API_KEY` not set → clear error with setup instructions +- [ ] Cost confirmation shown; `--acknowledge` bypasses +- [ ] Spinner displayed during LLM call +- [ ] Unparse failure → error + raw YAML shown (no retry in v1) +- [ ] API timeout at 120 seconds → clear timeout error +- [ ] Multi-document YAML (multiple workflows) generates multiple HCL files + +**Error handling**: +- Auth failure → "Invalid API key. Set ANTHROPIC_API_KEY env var." +- Rate limit → "API rate limited. Try again in a moment." +- Timeout → "LLM request timed out after 120s. Try a simpler prompt." +- Unparse failure → "Generated YAML could not be converted to HCL:\n{error}\n\nRaw YAML:\n{yaml}\n\nTry refining your prompt." +- Raw YAML error output must NOT contain any user HCL content (no context injection in v1, so this is inherently safe) + +**Files**: +- `internal/ai/doc.go` (NEW) +- `internal/ai/anthropic.go` (NEW) +- `internal/command/command.go` (MODIFY — add assist subcommand) +- `go.mod` (MODIFY — add `github.com/anthropics/anthropic-sdk-go`) + +**File count**: 2 new + 2 modified. + +#### Phase 2: Context injection + OpenAI + +**Goal**: Existing HCL context improves output quality. OpenAI as second provider. Config file support. + +**Prerequisite**: Phase 1 shipped and validated with real usage. + +**Tasks**: + +**AI provider interface + OpenAI**: +- Extract `internal/ai/provider.go` interface from the concrete Anthropic client: + ```go + type Provider interface { + Generate(ctx context.Context, req GenerateRequest) (string, error) + } + ``` +- Create `internal/ai/openai.go` using `github.com/openai/openai-go` +- Add `--provider` and `--model` CLI flags +- Consider build tags (`//go:build ai`) to make AI deps optional — now two SDK deps + +**Config file support**: +- Create `internal/ai/config.go`: + - Load `ai:` section from `.cinzelrc.yaml` (project-level, non-sensitive only) + - Load `os.UserConfigDir()/cinzel/config.yaml` (user-level, API keys) + - Create user config with `0600` permissions (security review H1) + - Warn if permissions are more permissive than `0600` + - Hard error if `.cinzelrc.yaml` contains `*_API_KEY` or `*_SECRET` patterns (security review H1) + - Resolution: CLI flags > env vars > user config > project config + +**Context injection (string-stripped)**: +- Strip all string literal values from existing HCL before injecting as context. The LLM needs block structure (step/job/workflow shapes, attribute names, nesting) — not actual values. This eliminates secrets, org names, tokens, emails, and all other sensitive data without any redaction/restoration engine. + - Replace all `= "..."` values with `= "..."` (literal ellipsis) + - Replace heredoc content with `...` (use HCL AST traversal, not regex — heredocs have complex syntax) + - Strip all HCL comments (may contain internal URLs, team names, proprietary notes) + - Preserve: block types, block labels, attribute names, block nesting + - Block labels (e.g., `step "checkout_release_with_credentials"`) are preserved — accepted residual risk. They reveal naming conventions but not secrets; comparable to function names in public code. + - ~30 lines of code using `hclsyntax` AST walk, no bidirectional mapping, no restoration step +- Stripping must have unit tests: + - String literal values replaced with `"..."` + - Heredoc content replaced with `...` + - Comments stripped entirely + - Block labels preserved (`step "checkout"` stays) + - Attribute names preserved (`name`, `value`, `action`) + - Block nesting preserved + - Real-world fixture: strip `cinzel/steps.hcl` and verify no secret patterns, org names, or emails survive + - Golden test: stripped output compared against `.golden` file for stability +- No restoration needed — the LLM output is fresh YAML that goes through unparse. Nothing from the stripped context needs to be injected back. +- Context injection: + - Read `./cinzel/*.hcl` (steps + variables primarily) + - Cap context at 8000 tokens to prevent oversized payloads (security review H3) + - Warn if context truncated + - `--no-context` flag to skip injection entirely +- Move system prompt to `internal/ai/prompt.go`: + - Build per CI provider (github/gitlab) + - Include stripped HCL context + +**`--refine` flag**: +- Reads `./cinzel/assist/*.hcl` as additional context alongside `./cinzel/*.hcl` +- Can coexist with `--prompt` (architecture review #5): `--refine` adds previous assist output as context, `--prompt` provides the new instruction +- Without `--prompt`: error "provide a prompt describing the refinement" +- Without previous `assist/` output: error "nothing to refine — run assist --prompt first" + +**Acceptance criteria**: +- [ ] `--provider openai` works with `OPENAI_API_KEY` +- [ ] Multiple providers configured in user config with `default:` selector +- [ ] User config created with `0600` permissions +- [ ] API key in `.cinzelrc.yaml` → hard error +- [ ] Existing steps referenced by LLM instead of duplicated +- [ ] String literals stripped from HCL context (no sensitive data sent) +- [ ] `--no-context` skips injection +- [ ] Context capped at 8000 tokens with truncation warning +- [ ] `--refine "add caching"` includes previous assist output as context + +**Files**: +- `internal/ai/provider.go` (NEW) +- `internal/ai/openai.go` (NEW) +- `internal/ai/config.go` (NEW) +- `internal/ai/prompt.go` (NEW — extracted from inline) +- `internal/command/command.go` (MODIFY) +- `internal/command/config.go` (MODIFY — accept ai: section) +- `go.mod` (MODIFY — add `github.com/openai/openai-go`) + +#### Phase 3: `cinzel pin` (standalone command) + +**Goal**: Resolve action tags to SHAs. Standalone command, independently useful. + +**Prerequisite**: Shipped separately from assist. Does not depend on Phase 2. + +**Tasks**: +- Create `internal/pin/doc.go` and `internal/pin/pin.go`: + - Define `Resolver` interface for testable GitHub API calls (architecture review #3): + ```go + type Resolver interface { + ResolveTag(ctx context.Context, owner, repo, tag string) (string, error) + } + ``` + - HTTP implementation: `GET /repos/{owner}/{action}/git/ref/tags/{tag}` → SHA + - Support `GITHUB_TOKEN` env var for authenticated requests (5000/hr vs 60/hr) (security review M4) + - Cache resolved SHAs in `os.UserCacheDir()/cinzel/pins/` with 24h TTL + - Walk HCL AST, find `uses` blocks with `action` + `version` attributes + - Replace `version` value, add comment `// {action} {tag}` + - Failures: fall back to unpinned tag with warning (private actions, nonexistent tags, rate limits) +- Register `cinzel pin --file ` command +- Optionally integrate into assist pipeline (runs after unparse, before write) — opt-in via `--pin` flag on assist + +**Acceptance criteria**: +- [ ] `cinzel github pin --file ./cinzel` resolves all action tags to SHAs +- [ ] `GITHUB_TOKEN` used for authenticated requests when available +- [ ] Pin failures → warning + fallback to tag (not fatal) +- [ ] Resolved SHAs cached for 24h +- [ ] `cinzel github assist --prompt "..." --pin` pins after generation + +**Files**: +- `internal/pin/doc.go` (NEW) +- `internal/pin/pin.go` (NEW) +- `internal/command/command.go` (MODIFY — register pin command) + +#### Phase 4: Retry loop + hardening + +**Goal**: Improve success rate for complex prompts. Ship only if failure rates warrant it. + +**Prerequisite**: Phase 1 shipped, real failure data collected. + +**Tasks**: +- Implement retry loop in assist: + - If unparse fails, sanitize error message (remove any HCL content, security review M2) + - Feed sanitized error back to LLM as context + - Max 2 retries, show "Unparse failed, retrying (1/2)..." + - Cost confirmation updated to mention potential 3x cost on retry +- Partial failure for multi-workflow: write successes, report failures, exit non-zero +- Typed errors: distinguish retryable (invalid YAML) from non-retryable (auth, timeout) + +**Acceptance criteria**: +- [ ] Retry with error context improves success rate (measured) +- [ ] Retry error context sanitized (no HCL content leakage) +- [ ] Partial multi-workflow failure handled correctly +- [ ] Exit codes: 0 = success, 1 = all failed, 2 = partial success + +## System-Wide Impact + +### Interaction Graph + +v1: `assist` calls: Anthropic SDK → write temp file → `provider.Unparse()` → `fsutil.WriteFile()`. Isolated pipeline, no interface changes. + +v2+: adds strip string literals → `ai.Provider.Generate()` → optional `pin.Pin()`. + +### Error Propagation + +- AI API errors (auth, rate limit, timeout) → user-facing message with actionable instructions +- Unparse errors → v1: show error + raw YAML. v2+: retry with sanitized error context +- Pin errors → non-fatal warning, output written with unpinned tags +- v1 sends no context — inherently safe against data leakage + +### State Lifecycle Risks + +- `./cinzel/assist/` overwritten on each run — no orphaned state +- `--refine` reads from `assist/` — clear error if deleted between runs +- Temp files cleaned up via `defer os.Remove()` — no leak on error paths +- No database, no persistent state beyond file output + +### API Surface Parity + +- `assist` registered alongside `parse`/`unparse` in `addProvider()` — consistent flag patterns +- `--output-directory` supported (architecture review #6) +- `pin` registered as sibling command — same `--file` flag + +## Acceptance Criteria + +### Functional Requirements (v1) + +- [ ] `cinzel github assist --prompt "golang PR with tests"` generates valid HCL in `./cinzel/assist/` +- [ ] `--output-directory` customizes output location +- [ ] `--dry-run` prints HCL to stdout without writing files +- [ ] Cost confirmation shown before API call; `--acknowledge` bypasses +- [ ] `ANTHROPIC_API_KEY` missing → clear setup instructions +- [ ] Multi-document YAML from LLM split and unparsed individually +- [ ] Empty LLM response → clear error message +- [ ] `--dry-run` calls LLM but skips file write (cost confirmation still applies) +- [ ] Markdown fences stripped from LLM output + +### Functional Requirements (v2+) + +- [ ] `--provider openai` with `OPENAI_API_KEY` +- [ ] Config file support with `default:` provider selector +- [ ] Context injection with string literal stripping +- [ ] `--refine` with `--prompt` for iterative generation +- [ ] `--no-context` to skip context injection +- [ ] `cinzel github pin --file ./cinzel` resolves action tags to SHAs + +### Non-Functional Requirements + +- [ ] API timeout: 120 seconds +- [ ] User config file created with `0600` permissions +- [ ] Config paths use `os.UserConfigDir()` / `os.UserCacheDir()` (OS-agnostic) +- [ ] Every new `.go` file has copyright header and `doc.go` +- [ ] No unit tests for LLM output (non-deterministic); validate via unparse success +- [ ] Spinner displayed during LLM call (no streaming) + +### Security Requirements + +- [ ] API keys never in `.cinzelrc.yaml` — hard error if detected +- [ ] User config file `0600` permissions enforced +- [ ] v2+: string literals stripped from HCL context before LLM call (no sensitive values sent) +- [ ] v2+: retry error context sanitized (no HCL content) + +## Dependencies & Prerequisites + +### Phase 1 + +| Package | Purpose | +|---------|---------| +| `github.com/anthropics/anthropic-sdk-go` | Anthropic API client | + +### Phase 2 (deferred) + +| Package | Purpose | +|---------|---------| +| `github.com/openai/openai-go` | OpenAI API client | + +### No Prerequisites + +Phase 1 requires zero interface changes, zero config parser changes. Only adds new files + modifies command registration. + +## Risk Analysis & Mitigation + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| LLM generates invalid YAML | High | Medium | v1: show error + raw YAML. v2+: retry with error context | +| Sensitive data in context | Medium | High | v2: strip all string literals from HCL. v1: no context sent | +| API key in project config | Medium | High | Hard error on detection, not just warning | +| GitHub API rate limits (pin) | Medium | Low | `GITHUB_TOKEN` support + SHA caching + graceful fallback | +| AI SDK dependency bloat | Low | Medium | Future: build tags for optional AI deps | +| Raw error output leaks data | Medium | Medium | v1: no context = no leakage. v2: only stripped structure sent | + +## Security Considerations + +- **v1 is inherently safe**: no context injection means no user data sent to LLM. Only the user's prompt is sent. +- **API keys**: env vars only in v1. v2 adds user config with `0600` enforcement + hard error on project config. +- **Context privacy (v2)**: all string literal values stripped from HCL before injection. LLM sees block structure only — no secrets, tokens, org names, or any values. No restoration needed since output is fresh YAML. +- **TLS**: SDK defaults (system CA bundle). v2+ with custom endpoints: enforce HTTPS scheme. +- **No logging**: prompts and responses never written to disk. +- **Error output**: v1 safe (no context). v2+ only sends stripped structure — no values to leak. + +## Sources & References + +### Origin + +- **Brainstorm document**: [docs/brainstorms/2026-03-16-feat-cinzel-assist-ai-workflow-generation.md](../brainstorms/2026-03-16-feat-cinzel-assist-ai-workflow-generation.md) — Key decisions: YAML-then-unparse strategy, non-destructive `./cinzel/assist/` output, env var config, no templates. + +### Review Findings Addressed + +- **Simplicity review**: v1 reduced from 10 new files to 2. Deferred OpenAI, config files, context injection, retry, pin to later phases. +- **Architecture review**: temp files instead of `UnparseBytes` (no interface change), `--output-directory` on assist, `--refine` coexists with `--prompt`, `Resolver` interface for pin testability, `.yaml` extension on temp files, multi-doc split-then-loop. +- **Security review**: string literal stripping replaces entire redaction engine (C1, C2), `0600` config permissions (H1), context size cap (H3), `GITHUB_TOKEN` for pin (M4), retry context sanitization (M2). + +### Internal References + +- CLI command registration: `internal/command/command.go:79` (`addProvider()`) +- Config loading: `internal/command/config.go:16` (`.cinzelrc.yaml`) +- Unparse entry point: `provider/github/github.go:144` (`Unparse()`) +- File writing: `internal/fsutil/` (`WriteFile()`) +- Provider interface: `provider/provider.go` + +### Learnings Applied + +- Config input precedence: `docs/solutions/logic-errors/config-input-precedence-ignored-for-parse.md` +- YAML string quoting rules: `docs/solutions/developer-experience/yaml-string-quoting-rules.md` +- Single YAML unmarshal pattern: `docs/solutions/patterns/critical-patterns.md` From eeb6c6564b8a8c50c594c51ddc5f87e412a907ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Mon, 16 Mar 2026 18:40:28 +0000 Subject: [PATCH 06/48] feat: add assist command for AI workflow gen Adds `cinzel assist --prompt "..."` that calls Anthropic to generate CI/CD YAML, then runs it through the existing unparse pipeline to produce HCL files in ./cinzel/assist/. --- go.mod | 5 + go.sum | 14 +++ internal/ai/anthropic.go | 134 +++++++++++++++++++++++++++ internal/ai/anthropic_test.go | 61 +++++++++++++ internal/ai/doc.go | 5 + internal/command/assist_test.go | 78 ++++++++++++++++ internal/command/command.go | 157 ++++++++++++++++++++++++++++++++ 7 files changed, 454 insertions(+) create mode 100644 internal/ai/anthropic.go create mode 100644 internal/ai/anthropic_test.go create mode 100644 internal/ai/doc.go create mode 100644 internal/command/assist_test.go diff --git a/go.mod b/go.mod index a682ee2..392e242 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ tool ( ) require ( + github.com/anthropics/anthropic-sdk-go v1.27.0 github.com/goccy/go-yaml v1.19.2 github.com/hashicorp/hcl/v2 v2.24.0 github.com/urfave/cli/v3 v3.7.0 @@ -24,6 +25,10 @@ require ( github.com/google/addlicense v1.2.0 // indirect github.com/google/licensecheck v0.3.1 // indirect github.com/google/safehtml v0.1.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/pkgsite v0.0.0-20260309224630-59cb58a64684 // indirect golang.org/x/tools/go/expect v0.1.1-deprecated // indirect diff --git a/go.sum b/go.sum index 1039670..70c2f44 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQ github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w= github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg= +github.com/anthropics/anthropic-sdk-go v1.27.0 h1:0CWbmBq5ofGAjF2H6lefCNRbnaUMGiTKO+lb7RLhDbI= +github.com/anthropics/anthropic-sdk-go v1.27.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/ashanbrown/forbidigo v1.6.0 h1:D3aewfM37Yb3pxHujIPSpTf6oQk9sc9WZi8gerOIVIY= @@ -98,6 +100,8 @@ github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42 github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= @@ -401,6 +405,16 @@ github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpR github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= github.com/tetafro/godot v1.5.0 h1:aNwfVI4I3+gdxjMgYPus9eHmoBeJIbnajOyqZYStzuw= github.com/tetafro/godot v1.5.0/go.mod h1:2oVxTBSftRTh4+MVfUaUXR6bn2GDXCaMcOG4Dk3rfio= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk5r6+hJnar67cgpDIz/iyD+rfl5r2Vk= github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460= github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M= diff --git a/internal/ai/anthropic.go b/internal/ai/anthropic.go new file mode 100644 index 0000000..767d481 --- /dev/null +++ b/internal/ai/anthropic.go @@ -0,0 +1,134 @@ +// Copyright 2026 YLD Limited +// SPDX-License-Identifier: Apache-2.0 + +package ai + +import ( + "context" + "errors" + "fmt" + "os" + "regexp" + "strings" + "time" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" +) + +const ( + defaultModel = "claude-sonnet-4-5-20250514" + apiTimeout = 120 * time.Second + apiKeyEnvVar = "ANTHROPIC_API_KEY" + maxTokens = 4096 +) + +var errMissingAPIKey = errors.New( + "ANTHROPIC_API_KEY environment variable is not set.\n\n" + + "Set it with:\n" + + " export ANTHROPIC_API_KEY=sk-ant-...\n\n" + + "Get your key at https://console.anthropic.com/settings/keys", +) + +var errEmptyResponse = errors.New("LLM returned empty response. Try a more specific prompt") + +// Generate calls the Anthropic API with the given system and user prompts, +// returning the text response. +func Generate(ctx context.Context, systemPrompt, userPrompt, model string) (string, error) { + apiKey := os.Getenv(apiKeyEnvVar) + if apiKey == "" { + return "", errMissingAPIKey + } + + if model == "" { + model = defaultModel + } + + ctx, cancel := context.WithTimeout(ctx, apiTimeout) + defer cancel() + + client := anthropic.NewClient(option.WithAPIKey(apiKey)) + + message, err := client.Messages.New(ctx, anthropic.MessageNewParams{ + Model: model, + MaxTokens: maxTokens, + System: []anthropic.TextBlockParam{ + {Text: systemPrompt}, + }, + Messages: []anthropic.MessageParam{ + anthropic.NewUserMessage(anthropic.NewTextBlock(userPrompt)), + }, + }) + if err != nil { + return "", classifyError(err) + } + + text := extractText(message) + if strings.TrimSpace(text) == "" { + return "", errEmptyResponse + } + + return text, nil +} + +func extractText(msg *anthropic.Message) string { + var parts []string + + for _, block := range msg.Content { + if block.Type == "text" { + parts = append(parts, block.Text) + } + } + + return strings.Join(parts, "\n") +} + +func classifyError(err error) error { + msg := err.Error() + + switch { + case strings.Contains(msg, "authentication") || strings.Contains(msg, "401"): + return fmt.Errorf("invalid API key. Set %s env var: %w", apiKeyEnvVar, err) + case strings.Contains(msg, "rate_limit") || strings.Contains(msg, "429"): + return fmt.Errorf("API rate limited. Try again in a moment: %w", err) + case errors.Is(err, context.DeadlineExceeded): + return fmt.Errorf("LLM request timed out after %s. Try a simpler prompt", apiTimeout) + default: + return fmt.Errorf("LLM API error: %w", err) + } +} + +// StripFences removes markdown code fences from LLM output, returning clean YAML. +func StripFences(s string) string { + fencePattern := regexp.MustCompile("(?s)```(?:ya?ml)?\\s*\n(.*?)```") + + matches := fencePattern.FindAllStringSubmatch(s, -1) + if len(matches) > 0 { + var parts []string + for _, m := range matches { + parts = append(parts, strings.TrimSpace(m[1])) + } + + return strings.Join(parts, "\n---\n") + } + + return strings.TrimSpace(s) +} + +// SystemPrompt returns the system prompt for the given CI provider name. +func SystemPrompt(providerName string) string { + return fmt.Sprintf(`You are a CI/CD workflow generator for %s. + +Generate valid %s YAML based on the user's description. + +Rules: +- Output ONLY valid YAML. No markdown code fences, no explanations, no commentary. +- Use current action versions (tags like @v4, not SHAs). +- Set minimum required permissions. +- Use environment variables for secrets (e.g. secrets.MY_SECRET), never hardcode values. +- Include descriptive step names and IDs. +- Follow %s best practices and conventions. +- When relevant, base your output on official starter workflows. +- If the request implies multiple workflows, separate them with --- (YAML document separator). +- Each YAML document should be a complete, valid workflow.`, providerName, providerName, providerName) +} diff --git a/internal/ai/anthropic_test.go b/internal/ai/anthropic_test.go new file mode 100644 index 0000000..4b1e037 --- /dev/null +++ b/internal/ai/anthropic_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 YLD Limited +// SPDX-License-Identifier: Apache-2.0 + +package ai + +import ( + "testing" +) + +func TestStripFences(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "no fences", + input: "name: test\non:\n push:", + want: "name: test\non:\n push:", + }, + { + name: "yaml fence", + input: "```yaml\nname: test\non:\n push:\n```", + want: "name: test\non:\n push:", + }, + { + name: "yml fence", + input: "```yml\nname: test\n```", + want: "name: test", + }, + { + name: "bare fence", + input: "```\nname: test\n```", + want: "name: test", + }, + { + name: "fence with surrounding text", + input: "Here is the workflow:\n\n```yaml\nname: test\n```\n\nHope this helps!", + want: "name: test", + }, + { + name: "multiple fences joined with separator", + input: "```yaml\nname: workflow1\n```\n\n```yaml\nname: workflow2\n```", + want: "name: workflow1\n---\nname: workflow2", + }, + { + name: "whitespace only input", + input: " \n\n ", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := StripFences(tt.input) + if got != tt.want { + t.Errorf("StripFences():\ngot: %q\nwant: %q", got, tt.want) + } + }) + } +} diff --git a/internal/ai/doc.go b/internal/ai/doc.go new file mode 100644 index 0000000..c4be008 --- /dev/null +++ b/internal/ai/doc.go @@ -0,0 +1,5 @@ +// Copyright 2026 YLD Limited +// SPDX-License-Identifier: Apache-2.0 + +// Package ai provides LLM integration for AI-assisted workflow generation. +package ai diff --git a/internal/command/assist_test.go b/internal/command/assist_test.go new file mode 100644 index 0000000..dad6455 --- /dev/null +++ b/internal/command/assist_test.go @@ -0,0 +1,78 @@ +// Copyright 2026 YLD Limited +// SPDX-License-Identifier: Apache-2.0 + +package command + +import ( + "testing" +) + +func TestSplitYAMLDocuments(t *testing.T) { + tests := []struct { + name string + input string + want int + }{ + { + name: "single document", + input: "name: test\non:\n push:", + want: 1, + }, + { + name: "two documents", + input: "name: workflow1\non:\n push:\n---\nname: workflow2\non:\n pull_request:", + want: 2, + }, + { + name: "leading separator ignored", + input: "---\nname: test", + want: 1, + }, + { + name: "three documents", + input: "name: a\n---\nname: b\n---\nname: c", + want: 3, + }, + { + name: "empty input", + input: "", + want: 0, + }, + { + name: "whitespace only", + input: " \n\n ", + want: 0, + }, + { + name: "separator with whitespace", + input: "name: a\n --- \nname: b", + want: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := splitYAMLDocuments(tt.input) + if len(got) != tt.want { + t.Errorf("splitYAMLDocuments() returned %d documents, want %d\ndocs: %v", len(got), tt.want, got) + } + }) + } +} + +func TestSplitYAMLDocumentsContent(t *testing.T) { + input := "name: workflow1\non:\n push:\n---\nname: workflow2\non:\n pull_request:" + docs := splitYAMLDocuments(input) + + if len(docs) != 2 { + t.Fatalf("expected 2 documents, got %d", len(docs)) + } + + if got := docs[0]; got != "name: workflow1\non:\n push:\n" { + t.Errorf("doc[0]:\ngot: %q\nwant: %q", got, "name: workflow1\non:\n push:\n") + } + + if got := docs[1]; got != "name: workflow2\non:\n pull_request:\n" { + t.Errorf("doc[1]:\ngot: %q\nwant: %q", got, "name: workflow2\non:\n pull_request:\n") + } +} diff --git a/internal/command/command.go b/internal/command/command.go index 104b75c..2258b90 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -4,12 +4,17 @@ package command import ( + "bufio" "context" "fmt" "io" "net/mail" + "os" + "path/filepath" + "strings" "github.com/urfave/cli/v3" + "github.com/yldio/cinzel/internal/ai" "github.com/yldio/cinzel/internal/cinzelerror" "github.com/yldio/cinzel/provider" ) @@ -181,6 +186,158 @@ func (cmd *Cli) addProvider(p provider.Provider) *cli.Command { }, }, }, + cmd.assistCommand(p), }, } } + +const defaultAssistOutputDir = "cinzel/assist" + +func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { + return &cli.Command{ + Name: "assist", + Usage: "Generate HCL workflow definitions from a natural language prompt", + Action: func(ctx context.Context, c *cli.Command) error { + prompt := c.String("prompt") + if prompt == "" { + return fmt.Errorf("--prompt is required") + } + + outputDir := c.String("output-directory") + if outputDir == "" { + outputDir = defaultAssistOutputDir + } + + dryRun := c.Bool("dry-run") + acknowledge := c.Bool("acknowledge") + + if !acknowledge { + if err := confirmCost(cmd.Writer, os.Stdin); err != nil { + return err + } + } + + _, _ = fmt.Fprintf(cmd.Writer, "Generating workflow...\n") + + systemPrompt := ai.SystemPrompt(p.GetProviderName()) + + response, err := ai.Generate(ctx, systemPrompt, prompt, "") + if err != nil { + return err + } + + yamlContent := ai.StripFences(response) + + return cmd.unparseAndWrite(p, yamlContent, outputDir, dryRun) + }, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "prompt", + Aliases: []string{"p"}, + Usage: "Natural language description of the workflow", + Required: true, + }, + &cli.StringFlag{ + Name: "output-directory", + Value: "", + Usage: "Generated HCL files are created in `DIRECTORY` (default: cinzel/assist)", + }, + &cli.BoolFlag{ + Name: "dry-run", + Value: false, + Usage: "Output to stdout instead of writing files", + }, + &cli.BoolFlag{ + Name: "acknowledge", + Value: false, + Usage: "Bypass the cost confirmation prompt", + }, + }, + } +} + +func (cmd *Cli) unparseAndWrite(p provider.Provider, yamlContent, outputDir string, dryRun bool) error { + docs := splitYAMLDocuments(yamlContent) + + for i, doc := range docs { + doc = strings.TrimSpace(doc) + if doc == "" { + continue + } + + tmpFile, err := os.CreateTemp("", "cinzel-assist-*.yaml") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + + tmpPath := tmpFile.Name() + + if _, err := tmpFile.WriteString(doc); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + + return fmt.Errorf("failed to write temp file: %w", err) + } + + tmpFile.Close() + + err = p.Unparse(provider.ProviderOps{ + File: tmpPath, + OutputDirectory: outputDir, + DryRun: dryRun, + }) + + os.Remove(tmpPath) + + if err != nil { + return fmt.Errorf( + "generated YAML could not be converted to HCL (document %d):\n%s\n\nRaw YAML:\n%s\n\nTry refining your prompt", + i+1, err, doc, + ) + } + } + + if !dryRun { + absDir, _ := filepath.Abs(outputDir) + _, _ = fmt.Fprintf(cmd.Writer, "HCL files written to %s\n", absDir) + } + + return nil +} + +func splitYAMLDocuments(s string) []string { + var docs []string + var current strings.Builder + + for _, line := range strings.Split(s, "\n") { + if strings.TrimSpace(line) == "---" && current.Len() > 0 { + docs = append(docs, current.String()) + current.Reset() + + continue + } + + current.WriteString(line) + current.WriteString("\n") + } + + if strings.TrimSpace(current.String()) != "" { + docs = append(docs, current.String()) + } + + return docs +} + +func confirmCost(w io.Writer, r io.Reader) error { + _, _ = fmt.Fprintf(w, "This will call Anthropic (claude-sonnet-4-5-20250514). API usage will incur costs.\nContinue? [y/N] ") + + scanner := bufio.NewScanner(r) + if scanner.Scan() { + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer == "y" || answer == "yes" { + return nil + } + } + + return fmt.Errorf("cancelled") +} From 30cf8f57c0c42a7d0c8fc4be0b8900c3c359d882 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Mon, 16 Mar 2026 18:47:15 +0000 Subject: [PATCH 07/48] feat: add OpenAI provider and AI provider interface --- go.mod | 1 + go.sum | 2 + internal/ai/anthropic.go | 115 ++++++------------ internal/ai/openai.go | 77 ++++++++++++ internal/ai/provider.go | 103 ++++++++++++++++ .../{anthropic_test.go => provider_test.go} | 0 internal/ai/strip.go | 109 +++++++++++++++++ internal/command/command.go | 45 ++++++- 8 files changed, 368 insertions(+), 84 deletions(-) create mode 100644 internal/ai/openai.go create mode 100644 internal/ai/provider.go rename internal/ai/{anthropic_test.go => provider_test.go} (100%) create mode 100644 internal/ai/strip.go diff --git a/go.mod b/go.mod index 392e242..85b5214 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/anthropics/anthropic-sdk-go v1.27.0 github.com/goccy/go-yaml v1.19.2 github.com/hashicorp/hcl/v2 v2.24.0 + github.com/openai/openai-go/v3 v3.28.0 github.com/urfave/cli/v3 v3.7.0 github.com/zclconf/go-cty v1.18.0 github.com/zclconf/go-cty-yaml v1.2.0 diff --git a/go.sum b/go.sum index 70c2f44..8096d07 100644 --- a/go.sum +++ b/go.sum @@ -295,6 +295,8 @@ github.com/onsi/ginkgo/v2 v2.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0 github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM= github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU= github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= +github.com/openai/openai-go/v3 v3.28.0 h1:2+FfrCVMdGXSQrBv1tLWtokm+BU7+3hJ/8rAHPQ63KM= +github.com/openai/openai-go/v3 v3.28.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= diff --git a/internal/ai/anthropic.go b/internal/ai/anthropic.go index 767d481..0a6d734 100644 --- a/internal/ai/anthropic.go +++ b/internal/ai/anthropic.go @@ -8,70 +8,75 @@ import ( "errors" "fmt" "os" - "regexp" "strings" - "time" "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/option" ) const ( - defaultModel = "claude-sonnet-4-5-20250514" - apiTimeout = 120 * time.Second - apiKeyEnvVar = "ANTHROPIC_API_KEY" - maxTokens = 4096 + anthropicDefaultModel = "claude-sonnet-4-5-20250514" + anthropicAPIKeyEnvVar = "ANTHROPIC_API_KEY" ) -var errMissingAPIKey = errors.New( +var errMissingAnthropicKey = errors.New( "ANTHROPIC_API_KEY environment variable is not set.\n\n" + "Set it with:\n" + " export ANTHROPIC_API_KEY=sk-ant-...\n\n" + "Get your key at https://console.anthropic.com/settings/keys", ) -var errEmptyResponse = errors.New("LLM returned empty response. Try a more specific prompt") +// Anthropic implements the Provider interface using the Anthropic API. +type Anthropic struct { + apiKey string +} -// Generate calls the Anthropic API with the given system and user prompts, -// returning the text response. -func Generate(ctx context.Context, systemPrompt, userPrompt, model string) (string, error) { - apiKey := os.Getenv(apiKeyEnvVar) +// NewAnthropic creates an Anthropic provider, reading the API key from the +// environment or the provided key string. +func NewAnthropic(apiKey string) (*Anthropic, error) { if apiKey == "" { - return "", errMissingAPIKey + apiKey = os.Getenv(anthropicAPIKeyEnvVar) } - if model == "" { - model = defaultModel + if apiKey == "" { + return nil, errMissingAnthropicKey } - ctx, cancel := context.WithTimeout(ctx, apiTimeout) - defer cancel() + return &Anthropic{apiKey: apiKey}, nil +} + +// Name returns the provider name. +func (a *Anthropic) Name() string { + return "anthropic" +} + +// Generate calls the Anthropic Messages API. +func (a *Anthropic) Generate(ctx context.Context, req GenerateRequest) (string, error) { + model := req.Model + if model == "" { + model = anthropicDefaultModel + } - client := anthropic.NewClient(option.WithAPIKey(apiKey)) + client := anthropic.NewClient(option.WithAPIKey(a.apiKey)) message, err := client.Messages.New(ctx, anthropic.MessageNewParams{ - Model: model, - MaxTokens: maxTokens, + Model: model, + MaxTokens: DefaultMaxTokens, System: []anthropic.TextBlockParam{ - {Text: systemPrompt}, + {Text: req.SystemPrompt}, }, Messages: []anthropic.MessageParam{ - anthropic.NewUserMessage(anthropic.NewTextBlock(userPrompt)), + anthropic.NewUserMessage(anthropic.NewTextBlock(req.UserPrompt)), }, }) if err != nil { - return "", classifyError(err) + return "", fmt.Errorf("anthropic API: %w", err) } - text := extractText(message) - if strings.TrimSpace(text) == "" { - return "", errEmptyResponse - } - - return text, nil + return extractAnthropicText(message), nil } -func extractText(msg *anthropic.Message) string { +func extractAnthropicText(msg *anthropic.Message) string { var parts []string for _, block := range msg.Content { @@ -82,53 +87,3 @@ func extractText(msg *anthropic.Message) string { return strings.Join(parts, "\n") } - -func classifyError(err error) error { - msg := err.Error() - - switch { - case strings.Contains(msg, "authentication") || strings.Contains(msg, "401"): - return fmt.Errorf("invalid API key. Set %s env var: %w", apiKeyEnvVar, err) - case strings.Contains(msg, "rate_limit") || strings.Contains(msg, "429"): - return fmt.Errorf("API rate limited. Try again in a moment: %w", err) - case errors.Is(err, context.DeadlineExceeded): - return fmt.Errorf("LLM request timed out after %s. Try a simpler prompt", apiTimeout) - default: - return fmt.Errorf("LLM API error: %w", err) - } -} - -// StripFences removes markdown code fences from LLM output, returning clean YAML. -func StripFences(s string) string { - fencePattern := regexp.MustCompile("(?s)```(?:ya?ml)?\\s*\n(.*?)```") - - matches := fencePattern.FindAllStringSubmatch(s, -1) - if len(matches) > 0 { - var parts []string - for _, m := range matches { - parts = append(parts, strings.TrimSpace(m[1])) - } - - return strings.Join(parts, "\n---\n") - } - - return strings.TrimSpace(s) -} - -// SystemPrompt returns the system prompt for the given CI provider name. -func SystemPrompt(providerName string) string { - return fmt.Sprintf(`You are a CI/CD workflow generator for %s. - -Generate valid %s YAML based on the user's description. - -Rules: -- Output ONLY valid YAML. No markdown code fences, no explanations, no commentary. -- Use current action versions (tags like @v4, not SHAs). -- Set minimum required permissions. -- Use environment variables for secrets (e.g. secrets.MY_SECRET), never hardcode values. -- Include descriptive step names and IDs. -- Follow %s best practices and conventions. -- When relevant, base your output on official starter workflows. -- If the request implies multiple workflows, separate them with --- (YAML document separator). -- Each YAML document should be a complete, valid workflow.`, providerName, providerName, providerName) -} diff --git a/internal/ai/openai.go b/internal/ai/openai.go new file mode 100644 index 0000000..6556e6d --- /dev/null +++ b/internal/ai/openai.go @@ -0,0 +1,77 @@ +// Copyright 2026 YLD Limited +// SPDX-License-Identifier: Apache-2.0 + +package ai + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" +) + +const ( + openaiDefaultModel = "gpt-4o" + openaiAPIKeyEnvVar = "OPENAI_API_KEY" +) + +var errMissingOpenAIKey = errors.New( + "OPENAI_API_KEY environment variable is not set.\n\n" + + "Set it with:\n" + + " export OPENAI_API_KEY=sk-...\n\n" + + "Get your key at https://platform.openai.com/api-keys", +) + +// OpenAI implements the Provider interface using the OpenAI API. +type OpenAI struct { + apiKey string +} + +// NewOpenAI creates an OpenAI provider, reading the API key from the +// environment or the provided key string. +func NewOpenAI(apiKey string) (*OpenAI, error) { + if apiKey == "" { + apiKey = os.Getenv(openaiAPIKeyEnvVar) + } + + if apiKey == "" { + return nil, errMissingOpenAIKey + } + + return &OpenAI{apiKey: apiKey}, nil +} + +// Name returns the provider name. +func (o *OpenAI) Name() string { + return "openai" +} + +// Generate calls the OpenAI Chat Completions API. +func (o *OpenAI) Generate(ctx context.Context, req GenerateRequest) (string, error) { + model := req.Model + if model == "" { + model = openaiDefaultModel + } + + client := openai.NewClient(option.WithAPIKey(o.apiKey)) + + completion, err := client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{ + Model: model, + Messages: []openai.ChatCompletionMessageParamUnion{ + openai.SystemMessage(req.SystemPrompt), + openai.UserMessage(req.UserPrompt), + }, + }) + if err != nil { + return "", fmt.Errorf("openai API: %w", err) + } + + if len(completion.Choices) == 0 { + return "", nil + } + + return completion.Choices[0].Message.Content, nil +} diff --git a/internal/ai/provider.go b/internal/ai/provider.go new file mode 100644 index 0000000..0ada918 --- /dev/null +++ b/internal/ai/provider.go @@ -0,0 +1,103 @@ +// Copyright 2026 YLD Limited +// SPDX-License-Identifier: Apache-2.0 + +package ai + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + "time" +) + +const ( + // DefaultTimeout is the maximum time to wait for an LLM response. + DefaultTimeout = 120 * time.Second + + // DefaultMaxTokens is the maximum number of tokens in the LLM response. + DefaultMaxTokens = 4096 +) + +var errEmptyResponse = errors.New("LLM returned empty response. Try a more specific prompt") + +// GenerateRequest holds the parameters for an LLM generation call. +type GenerateRequest struct { + SystemPrompt string + UserPrompt string + Model string +} + +// Provider defines the interface for LLM providers. +type Provider interface { + Generate(ctx context.Context, req GenerateRequest) (string, error) + Name() string +} + +// GenerateWithTimeout calls the provider with a timeout and validates the response. +func GenerateWithTimeout(ctx context.Context, p Provider, req GenerateRequest) (string, error) { + ctx, cancel := context.WithTimeout(ctx, DefaultTimeout) + defer cancel() + + response, err := p.Generate(ctx, req) + if err != nil { + return "", classifyError(err, p.Name()) + } + + if strings.TrimSpace(response) == "" { + return "", errEmptyResponse + } + + return response, nil +} + +func classifyError(err error, providerName string) error { + msg := err.Error() + + switch { + case strings.Contains(msg, "authentication") || strings.Contains(msg, "401"): + return fmt.Errorf("invalid API key for %s: %w", providerName, err) + case strings.Contains(msg, "rate_limit") || strings.Contains(msg, "429"): + return fmt.Errorf("API rate limited. Try again in a moment: %w", err) + case errors.Is(err, context.DeadlineExceeded): + return fmt.Errorf("LLM request timed out after %s. Try a simpler prompt", DefaultTimeout) + default: + return fmt.Errorf("LLM API error (%s): %w", providerName, err) + } +} + +// StripFences removes markdown code fences from LLM output, returning clean YAML. +func StripFences(s string) string { + fencePattern := regexp.MustCompile("(?s)```(?:ya?ml)?\\s*\n(.*?)```") + + matches := fencePattern.FindAllStringSubmatch(s, -1) + if len(matches) > 0 { + var parts []string + for _, m := range matches { + parts = append(parts, strings.TrimSpace(m[1])) + } + + return strings.Join(parts, "\n---\n") + } + + return strings.TrimSpace(s) +} + +// SystemPrompt returns the system prompt for the given CI provider name. +func SystemPrompt(providerName string) string { + return fmt.Sprintf(`You are a CI/CD workflow generator for %s. + +Generate valid %s YAML based on the user's description. + +Rules: +- Output ONLY valid YAML. No markdown code fences, no explanations, no commentary. +- Use current action versions (tags like @v4, not SHAs). +- Set minimum required permissions. +- Use environment variables for secrets (e.g. secrets.MY_SECRET), never hardcode values. +- Include descriptive step names and IDs. +- Follow %s best practices and conventions. +- When relevant, base your output on official starter workflows. +- If the request implies multiple workflows, separate them with --- (YAML document separator). +- Each YAML document should be a complete, valid workflow.`, providerName, providerName, providerName) +} diff --git a/internal/ai/anthropic_test.go b/internal/ai/provider_test.go similarity index 100% rename from internal/ai/anthropic_test.go rename to internal/ai/provider_test.go diff --git a/internal/ai/strip.go b/internal/ai/strip.go new file mode 100644 index 0000000..6667426 --- /dev/null +++ b/internal/ai/strip.go @@ -0,0 +1,109 @@ +// Copyright 2026 YLD Limited +// SPDX-License-Identifier: Apache-2.0 + +package ai + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" +) + +const ( + maxContextTokens = 8000 + bytesPerToken = 4 + maxContextBytes = maxContextTokens * bytesPerToken + strippedLiteral = `"..."` + strippedHeredoc = "..." +) + +// StripHCLContext reads HCL files from the given directory, strips all string +// literal values, heredoc content, and comments, then returns the structural +// skeleton. Returns an empty string if the directory doesn't exist or has no +// HCL files. +func StripHCLContext(dir string) (string, bool) { + entries, err := os.ReadDir(dir) + if err != nil { + return "", false + } + + var parts []string + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".hcl") { + continue + } + + path := filepath.Join(dir, entry.Name()) + + content, err := os.ReadFile(path) + if err != nil { + continue + } + + stripped := stripHCLFile(content, entry.Name()) + if stripped != "" { + parts = append(parts, fmt.Sprintf("# %s\n%s", entry.Name(), stripped)) + } + } + + if len(parts) == 0 { + return "", false + } + + result := strings.Join(parts, "\n\n") + truncated := false + + if len(result) > maxContextBytes { + result = result[:maxContextBytes] + truncated = true + } + + return result, truncated +} + +func stripHCLFile(src []byte, filename string) string { + file, diags := hclsyntax.ParseConfig(src, filename, hcl.Pos{Line: 1, Column: 1}) + if diags.HasErrors() { + return "" + } + + body, ok := file.Body.(*hclsyntax.Body) + if !ok { + return "" + } + + var b strings.Builder + + stripBody(&b, body, 0) + + return strings.TrimSpace(b.String()) +} + +func stripBody(b *strings.Builder, body *hclsyntax.Body, indent int) { + prefix := strings.Repeat(" ", indent) + + for _, attr := range body.Attributes { + fmt.Fprintf(b, "%s%s = %s\n", prefix, attr.Name, strippedLiteral) + } + + for _, block := range body.Blocks { + fmt.Fprintf(b, "%s%s", prefix, block.Type) + + for _, label := range block.Labels { + fmt.Fprintf(b, " %q", label) + } + + fmt.Fprintf(b, " {\n") + + if block.Body != nil { + stripBody(b, block.Body, indent+1) + } + + fmt.Fprintf(b, "%s}\n", prefix) + } +} diff --git a/internal/command/command.go b/internal/command/command.go index 2258b90..d003d3a 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -211,8 +211,16 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { dryRun := c.Bool("dry-run") acknowledge := c.Bool("acknowledge") + aiProviderName := c.String("provider") + model := c.String("model") + + aiProvider, err := resolveAIProvider(aiProviderName, "") + if err != nil { + return err + } + if !acknowledge { - if err := confirmCost(cmd.Writer, os.Stdin); err != nil { + if err := confirmCost(cmd.Writer, os.Stdin, aiProvider.Name(), model); err != nil { return err } } @@ -221,7 +229,11 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { systemPrompt := ai.SystemPrompt(p.GetProviderName()) - response, err := ai.Generate(ctx, systemPrompt, prompt, "") + response, err := ai.GenerateWithTimeout(ctx, aiProvider, ai.GenerateRequest{ + SystemPrompt: systemPrompt, + UserPrompt: prompt, + Model: model, + }) if err != nil { return err } @@ -252,6 +264,16 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { Value: false, Usage: "Bypass the cost confirmation prompt", }, + &cli.StringFlag{ + Name: "provider", + Value: "anthropic", + Usage: "AI provider: anthropic or openai", + }, + &cli.StringFlag{ + Name: "model", + Value: "", + Usage: "Model override (default: provider-specific)", + }, }, } } @@ -328,8 +350,23 @@ func splitYAMLDocuments(s string) []string { return docs } -func confirmCost(w io.Writer, r io.Reader) error { - _, _ = fmt.Fprintf(w, "This will call Anthropic (claude-sonnet-4-5-20250514). API usage will incur costs.\nContinue? [y/N] ") +func resolveAIProvider(name, apiKey string) (ai.Provider, error) { + switch strings.ToLower(name) { + case "anthropic", "": + return ai.NewAnthropic(apiKey) + case "openai": + return ai.NewOpenAI(apiKey) + default: + return nil, fmt.Errorf("unknown AI provider %q. Supported: anthropic, openai", name) + } +} + +func confirmCost(w io.Writer, r io.Reader, providerName, model string) error { + if model == "" { + model = "default" + } + + _, _ = fmt.Fprintf(w, "This will call %s (%s). API usage will incur costs.\nContinue? [y/N] ", providerName, model) scanner := bufio.NewScanner(r) if scanner.Scan() { From e3c83f790672ae6d42683a43d76ab495cf58f950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Mon, 16 Mar 2026 18:52:03 +0000 Subject: [PATCH 08/48] feat: add --refine flag for iterative generation --- internal/ai/strip_test.go | 252 ++++++++++++++++++++++++++++++++++++ internal/command/command.go | 62 ++++++++- 2 files changed, 307 insertions(+), 7 deletions(-) create mode 100644 internal/ai/strip_test.go diff --git a/internal/ai/strip_test.go b/internal/ai/strip_test.go new file mode 100644 index 0000000..b5a920a --- /dev/null +++ b/internal/ai/strip_test.go @@ -0,0 +1,252 @@ +// Copyright 2026 YLD Limited +// SPDX-License-Identifier: Apache-2.0 + +package ai + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestStripHCLFile(t *testing.T) { + tests := []struct { + name string + input string + contains []string + excludes []string + }{ + { + name: "string literals replaced", + input: `step "checkout" { + name = "Checkout" + run = "echo hello" +}`, + contains: []string{`name = "..."`}, + excludes: []string{"Checkout", "echo hello"}, + }, + { + name: "block labels preserved", + input: `step "release_app_token" { + name = "Create release app token" +}`, + contains: []string{`step "release_app_token"`}, + excludes: []string{"Create release app token"}, + }, + { + name: "attribute names preserved", + input: `step "test" { + name = "Run tests" + if = "always" + timeout_minutes = "5" +}`, + contains: []string{"name =", "if =", "timeout_minutes ="}, + excludes: []string{"Run tests", "always"}, + }, + { + name: "nested blocks preserved", + input: `step "checkout" { + uses { + action = "actions/checkout" + version = "abc123" + } + with { + name = "fetch-depth" + value = "0" + } +}`, + contains: []string{"uses {", "with {", `step "checkout"`}, + excludes: []string{"actions/checkout", "abc123", "fetch-depth"}, + }, + { + name: "comments stripped", + input: `// This is a secret comment about internal infrastructure +// team@yld.io maintains this +step "test" { + name = "Test" +}`, + contains: []string{`step "test"`}, + excludes: []string{"secret comment", "team@yld.io", "infrastructure"}, + }, + { + name: "heredoc content stripped", + input: `step "deploy" { + run = < maxContextBytes { + t.Errorf("expected result to be at most %d bytes, got %d", maxContextBytes, len(result)) + } +} + +func TestStripHCLFileRealFixture(t *testing.T) { + // Test against the real steps.hcl fixture if it exists + fixturePath := "../../cinzel/steps.hcl" + src, err := os.ReadFile(fixturePath) + + if err != nil { + t.Skip("cinzel/steps.hcl not found, skipping real fixture test") + } + + got := stripHCLFile(src, "steps.hcl") + + if got == "" { + t.Fatal("expected non-empty result from real fixture") + } + + // Verify no sensitive patterns survive + sensitivePatterns := []string{ + "secrets.", + "@yld", + "yldio", + "sk-ant-", + "RELEASE_APP_ID", + "RELEASE_PRIVATE_KEY", + } + + for _, pattern := range sensitivePatterns { + if strings.Contains(got, pattern) { + t.Errorf("sensitive pattern %q found in stripped output:\n%s", pattern, got) + } + } + + // Verify structural elements preserved + if !strings.Contains(got, "step") { + t.Error("expected 'step' block type in stripped output") + } +} diff --git a/internal/command/command.go b/internal/command/command.go index d003d3a..d1ec289 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -199,8 +199,10 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { Usage: "Generate HCL workflow definitions from a natural language prompt", Action: func(ctx context.Context, c *cli.Command) error { prompt := c.String("prompt") - if prompt == "" { - return fmt.Errorf("--prompt is required") + refine := c.String("refine") + + if prompt == "" && refine == "" { + return fmt.Errorf("--prompt is required (or use --refine to iterate on previous output)") } outputDir := c.String("output-directory") @@ -229,9 +231,42 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { systemPrompt := ai.SystemPrompt(p.GetProviderName()) + if !c.Bool("no-context") { + contextDir := c.String("context-dir") + if contextDir == "" { + contextDir = "cinzel" + } + + hclContext, truncated := ai.StripHCLContext(contextDir) + if hclContext != "" { + systemPrompt += "\n\nExisting HCL structure (values stripped for privacy):\n\n" + hclContext + } + + if truncated { + _, _ = fmt.Fprintf(cmd.Writer, "warning: HCL context truncated to fit token limit\n") + } + } + + userPrompt := prompt + + if refine != "" { + assistContext, _ := ai.StripHCLContext(outputDir) + if assistContext == "" { + return fmt.Errorf("nothing to refine — run assist --prompt first to generate initial output in %s", outputDir) + } + + systemPrompt += "\n\nPrevious assist output (to be refined):\n\n" + assistContext + + if prompt != "" { + userPrompt = refine + "\n\nOriginal request: " + prompt + } else { + userPrompt = refine + } + } + response, err := ai.GenerateWithTimeout(ctx, aiProvider, ai.GenerateRequest{ SystemPrompt: systemPrompt, - UserPrompt: prompt, + UserPrompt: userPrompt, Model: model, }) if err != nil { @@ -244,10 +279,13 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { }, Flags: []cli.Flag{ &cli.StringFlag{ - Name: "prompt", - Aliases: []string{"p"}, - Usage: "Natural language description of the workflow", - Required: true, + Name: "prompt", + Aliases: []string{"p"}, + Usage: "Natural language description of the workflow", + }, + &cli.StringFlag{ + Name: "refine", + Usage: "Refine previous assist output with additional instructions", }, &cli.StringFlag{ Name: "output-directory", @@ -274,6 +312,16 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { Value: "", Usage: "Model override (default: provider-specific)", }, + &cli.BoolFlag{ + Name: "no-context", + Value: false, + Usage: "Skip injecting existing HCL as context", + }, + &cli.StringFlag{ + Name: "context-dir", + Value: "", + Usage: "Directory to read existing HCL from (default: cinzel)", + }, }, } } From 76919c348f3aa75ae2d745067f5a3b56add1d153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Mon, 16 Mar 2026 19:05:51 +0000 Subject: [PATCH 09/48] fix: distinguish quota exceeded from rate limit --- internal/ai/provider.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/ai/provider.go b/internal/ai/provider.go index 0ada918..9ea393b 100644 --- a/internal/ai/provider.go +++ b/internal/ai/provider.go @@ -58,6 +58,8 @@ func classifyError(err error, providerName string) error { switch { case strings.Contains(msg, "authentication") || strings.Contains(msg, "401"): return fmt.Errorf("invalid API key for %s: %w", providerName, err) + case strings.Contains(msg, "insufficient_quota") || strings.Contains(msg, "billing"): + return fmt.Errorf("API quota exceeded for %s. Check your plan and billing at your provider's dashboard: %w", providerName, err) case strings.Contains(msg, "rate_limit") || strings.Contains(msg, "429"): return fmt.Errorf("API rate limited. Try again in a moment: %w", err) case errors.Is(err, context.DeadlineExceeded): From c0a55035ecdd1e46b2f12cc7914579e700136e1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Mon, 16 Mar 2026 19:30:26 +0000 Subject: [PATCH 10/48] feat: show token usage after LLM generation --- internal/ai/anthropic.go | 10 +++++++--- internal/ai/openai.go | 15 ++++++++++----- internal/ai/provider.go | 22 +++++++++++++++++----- internal/command/command.go | 5 ++++- 4 files changed, 38 insertions(+), 14 deletions(-) diff --git a/internal/ai/anthropic.go b/internal/ai/anthropic.go index 0a6d734..4c51566 100644 --- a/internal/ai/anthropic.go +++ b/internal/ai/anthropic.go @@ -51,7 +51,7 @@ func (a *Anthropic) Name() string { } // Generate calls the Anthropic Messages API. -func (a *Anthropic) Generate(ctx context.Context, req GenerateRequest) (string, error) { +func (a *Anthropic) Generate(ctx context.Context, req GenerateRequest) (GenerateResponse, error) { model := req.Model if model == "" { model = anthropicDefaultModel @@ -70,10 +70,14 @@ func (a *Anthropic) Generate(ctx context.Context, req GenerateRequest) (string, }, }) if err != nil { - return "", fmt.Errorf("anthropic API: %w", err) + return GenerateResponse{}, fmt.Errorf("anthropic API: %w", err) } - return extractAnthropicText(message), nil + return GenerateResponse{ + Text: extractAnthropicText(message), + InputTokens: message.Usage.InputTokens, + OutputTokens: message.Usage.OutputTokens, + }, nil } func extractAnthropicText(msg *anthropic.Message) string { diff --git a/internal/ai/openai.go b/internal/ai/openai.go index 6556e6d..ed231f5 100644 --- a/internal/ai/openai.go +++ b/internal/ai/openai.go @@ -50,7 +50,7 @@ func (o *OpenAI) Name() string { } // Generate calls the OpenAI Chat Completions API. -func (o *OpenAI) Generate(ctx context.Context, req GenerateRequest) (string, error) { +func (o *OpenAI) Generate(ctx context.Context, req GenerateRequest) (GenerateResponse, error) { model := req.Model if model == "" { model = openaiDefaultModel @@ -66,12 +66,17 @@ func (o *OpenAI) Generate(ctx context.Context, req GenerateRequest) (string, err }, }) if err != nil { - return "", fmt.Errorf("openai API: %w", err) + return GenerateResponse{}, fmt.Errorf("openai API: %w", err) } - if len(completion.Choices) == 0 { - return "", nil + var text string + if len(completion.Choices) > 0 { + text = completion.Choices[0].Message.Content } - return completion.Choices[0].Message.Content, nil + return GenerateResponse{ + Text: text, + InputTokens: completion.Usage.PromptTokens, + OutputTokens: completion.Usage.CompletionTokens, + }, nil } diff --git a/internal/ai/provider.go b/internal/ai/provider.go index 9ea393b..4844aec 100644 --- a/internal/ai/provider.go +++ b/internal/ai/provider.go @@ -29,24 +29,36 @@ type GenerateRequest struct { Model string } +// GenerateResponse holds the LLM response text and token usage. +type GenerateResponse struct { + Text string + InputTokens int64 + OutputTokens int64 +} + +// TotalTokens returns the sum of input and output tokens. +func (r GenerateResponse) TotalTokens() int64 { + return r.InputTokens + r.OutputTokens +} + // Provider defines the interface for LLM providers. type Provider interface { - Generate(ctx context.Context, req GenerateRequest) (string, error) + Generate(ctx context.Context, req GenerateRequest) (GenerateResponse, error) Name() string } // GenerateWithTimeout calls the provider with a timeout and validates the response. -func GenerateWithTimeout(ctx context.Context, p Provider, req GenerateRequest) (string, error) { +func GenerateWithTimeout(ctx context.Context, p Provider, req GenerateRequest) (GenerateResponse, error) { ctx, cancel := context.WithTimeout(ctx, DefaultTimeout) defer cancel() response, err := p.Generate(ctx, req) if err != nil { - return "", classifyError(err, p.Name()) + return GenerateResponse{}, classifyError(err, p.Name()) } - if strings.TrimSpace(response) == "" { - return "", errEmptyResponse + if strings.TrimSpace(response.Text) == "" { + return GenerateResponse{}, errEmptyResponse } return response, nil diff --git a/internal/command/command.go b/internal/command/command.go index d1ec289..1eee26a 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -273,7 +273,10 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { return err } - yamlContent := ai.StripFences(response) + _, _ = fmt.Fprintf(cmd.Writer, "Tokens used: %d (input: %d, output: %d)\n", + response.TotalTokens(), response.InputTokens, response.OutputTokens) + + yamlContent := ai.StripFences(response.Text) return cmd.unparseAndWrite(p, yamlContent, outputDir, dryRun) }, From b742cb2ad89436e83866d6489c28c0bfa8901b23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Mon, 16 Mar 2026 19:35:00 +0000 Subject: [PATCH 11/48] fix: unparse all YAML documents together for step reuse --- internal/ai/provider.go | 7 ++++-- internal/command/command.go | 48 +++++++++++++++++-------------------- 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/internal/ai/provider.go b/internal/ai/provider.go index 4844aec..1db4013 100644 --- a/internal/ai/provider.go +++ b/internal/ai/provider.go @@ -102,6 +102,8 @@ func StripFences(s string) string { func SystemPrompt(providerName string) string { return fmt.Sprintf(`You are a CI/CD workflow generator for %s. +Your output will be converted to HCL where steps are reusable blocks shared across workflows and jobs. When generating multiple workflows, use IDENTICAL step definitions for common operations (checkout, setup, install dependencies, build, test). Give shared steps consistent names and IDs across all workflows so they can be deduplicated. + Generate valid %s YAML based on the user's description. Rules: @@ -109,9 +111,10 @@ Rules: - Use current action versions (tags like @v4, not SHAs). - Set minimum required permissions. - Use environment variables for secrets (e.g. secrets.MY_SECRET), never hardcode values. -- Include descriptive step names and IDs. +- Include descriptive step names and IDs. Use consistent names: checkout, setup_go, install_deps, build, test, lint — not step_1, step_2. - Follow %s best practices and conventions. - When relevant, base your output on official starter workflows. - If the request implies multiple workflows, separate them with --- (YAML document separator). -- Each YAML document should be a complete, valid workflow.`, providerName, providerName, providerName) +- Each YAML document should be a complete, valid workflow. +- When multiple workflows share steps (e.g. checkout + setup), use the EXACT same step name and id in each workflow so they can be deduplicated into a single reusable definition.`, providerName, providerName, providerName) } diff --git a/internal/command/command.go b/internal/command/command.go index 1eee26a..621ffb1 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/urfave/cli/v3" "github.com/yldio/cinzel/internal/ai" @@ -330,6 +331,13 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { } func (cmd *Cli) unparseAndWrite(p provider.Provider, yamlContent, outputDir string, dryRun bool) error { + tmpDir, err := os.MkdirTemp("", "cinzel-assist-*") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + + defer os.RemoveAll(tmpDir) + docs := splitYAMLDocuments(yamlContent) for i, doc := range docs { @@ -338,36 +346,24 @@ func (cmd *Cli) unparseAndWrite(p provider.Provider, yamlContent, outputDir stri continue } - tmpFile, err := os.CreateTemp("", "cinzel-assist-*.yaml") - if err != nil { - return fmt.Errorf("failed to create temp file: %w", err) - } - - tmpPath := tmpFile.Name() - - if _, err := tmpFile.WriteString(doc); err != nil { - tmpFile.Close() - os.Remove(tmpPath) + timestamp := time.Now().Format("20060102-150405") + tmpPath := filepath.Join(tmpDir, fmt.Sprintf("assist-%s-%d.yaml", timestamp, i)) + if err := os.WriteFile(tmpPath, []byte(doc), 0600); err != nil { return fmt.Errorf("failed to write temp file: %w", err) } + } - tmpFile.Close() - - err = p.Unparse(provider.ProviderOps{ - File: tmpPath, - OutputDirectory: outputDir, - DryRun: dryRun, - }) - - os.Remove(tmpPath) - - if err != nil { - return fmt.Errorf( - "generated YAML could not be converted to HCL (document %d):\n%s\n\nRaw YAML:\n%s\n\nTry refining your prompt", - i+1, err, doc, - ) - } + err = p.Unparse(provider.ProviderOps{ + Directory: tmpDir, + OutputDirectory: outputDir, + DryRun: dryRun, + }) + if err != nil { + return fmt.Errorf( + "generated YAML could not be converted to HCL:\n%s\n\nRaw YAML:\n%s\n\nTry refining your prompt", + err, yamlContent, + ) } if !dryRun { From e774b3f7c20c57f765988e9810e623385d19b8ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Mon, 16 Mar 2026 19:44:52 +0000 Subject: [PATCH 12/48] fix: merge and deduplicate HCL blocks into single output file --- internal/command/command.go | 118 +++++++++++++++++++++++++++++++++--- 1 file changed, 108 insertions(+), 10 deletions(-) diff --git a/internal/command/command.go b/internal/command/command.go index 621ffb1..4badc3c 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -331,12 +331,19 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { } func (cmd *Cli) unparseAndWrite(p provider.Provider, yamlContent, outputDir string, dryRun bool) error { - tmpDir, err := os.MkdirTemp("", "cinzel-assist-*") + tmpYAMLDir, err := os.MkdirTemp("", "cinzel-assist-yaml-*") if err != nil { return fmt.Errorf("failed to create temp directory: %w", err) } - defer os.RemoveAll(tmpDir) + defer os.RemoveAll(tmpYAMLDir) + + tmpHCLDir, err := os.MkdirTemp("", "cinzel-assist-hcl-*") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + + defer os.RemoveAll(tmpHCLDir) docs := splitYAMLDocuments(yamlContent) @@ -346,8 +353,7 @@ func (cmd *Cli) unparseAndWrite(p provider.Provider, yamlContent, outputDir stri continue } - timestamp := time.Now().Format("20060102-150405") - tmpPath := filepath.Join(tmpDir, fmt.Sprintf("assist-%s-%d.yaml", timestamp, i)) + tmpPath := filepath.Join(tmpYAMLDir, fmt.Sprintf("workflow-%d.yaml", i)) if err := os.WriteFile(tmpPath, []byte(doc), 0600); err != nil { return fmt.Errorf("failed to write temp file: %w", err) @@ -355,9 +361,9 @@ func (cmd *Cli) unparseAndWrite(p provider.Provider, yamlContent, outputDir stri } err = p.Unparse(provider.ProviderOps{ - Directory: tmpDir, - OutputDirectory: outputDir, - DryRun: dryRun, + Directory: tmpYAMLDir, + OutputDirectory: tmpHCLDir, + DryRun: false, }) if err != nil { return fmt.Errorf( @@ -366,14 +372,106 @@ func (cmd *Cli) unparseAndWrite(p provider.Provider, yamlContent, outputDir stri ) } - if !dryRun { - absDir, _ := filepath.Abs(outputDir) - _, _ = fmt.Fprintf(cmd.Writer, "HCL files written to %s\n", absDir) + merged, err := mergeHCLFiles(tmpHCLDir) + if err != nil { + return fmt.Errorf("failed to merge HCL files: %w", err) + } + + if dryRun { + _, _ = fmt.Fprintln(cmd.Writer, merged) + + return nil + } + + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + timestamp := time.Now().Format("20060102-150405") + outPath := filepath.Join(outputDir, fmt.Sprintf("assist-%s.hcl", timestamp)) + + if err := os.WriteFile(outPath, []byte(merged), 0644); err != nil { + return fmt.Errorf("failed to write output file: %w", err) } + absPath, _ := filepath.Abs(outPath) + _, _ = fmt.Fprintf(cmd.Writer, "HCL written to %s\n", absPath) + return nil } +func mergeHCLFiles(dir string) (string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return "", err + } + + seen := make(map[string]bool) + + var parts []string + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".hcl") { + continue + } + + content, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + return "", err + } + + for _, block := range splitHCLBlocks(string(content)) { + block = strings.TrimSpace(block) + if block == "" { + continue + } + + if seen[block] { + continue + } + + seen[block] = true + parts = append(parts, block) + } + } + + return strings.Join(parts, "\n\n") + "\n", nil +} + +func splitHCLBlocks(content string) []string { + var blocks []string + var current strings.Builder + + depth := 0 + + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + + if trimmed == "" && depth == 0 { + if current.Len() > 0 { + blocks = append(blocks, current.String()) + current.Reset() + } + + continue + } + + if current.Len() > 0 { + current.WriteString("\n") + } + + current.WriteString(line) + + depth += strings.Count(trimmed, "{") - strings.Count(trimmed, "}") + } + + if current.Len() > 0 { + blocks = append(blocks, current.String()) + } + + return blocks +} + func splitYAMLDocuments(s string) []string { var docs []string var current strings.Builder From acf612bbc2dc7e299656dec0f645178e7ea58864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Tue, 17 Mar 2026 08:06:09 +0000 Subject: [PATCH 13/48] refactor: address code review findings for assist --- internal/ai/anthropic.go | 20 +- internal/ai/errors.go | 22 +++ internal/ai/openai.go | 20 +- internal/ai/provider.go | 23 ++- internal/ai/strip.go | 40 +++- internal/command/assist.go | 370 ++++++++++++++++++++++++++++++++++++ internal/command/command.go | 338 -------------------------------- internal/command/errors.go | 11 ++ 8 files changed, 461 insertions(+), 383 deletions(-) create mode 100644 internal/ai/errors.go create mode 100644 internal/command/assist.go create mode 100644 internal/command/errors.go diff --git a/internal/ai/anthropic.go b/internal/ai/anthropic.go index 4c51566..9b5d8a2 100644 --- a/internal/ai/anthropic.go +++ b/internal/ai/anthropic.go @@ -5,9 +5,7 @@ package ai import ( "context" - "errors" "fmt" - "os" "strings" "github.com/anthropics/anthropic-sdk-go" @@ -19,13 +17,6 @@ const ( anthropicAPIKeyEnvVar = "ANTHROPIC_API_KEY" ) -var errMissingAnthropicKey = errors.New( - "ANTHROPIC_API_KEY environment variable is not set.\n\n" + - "Set it with:\n" + - " export ANTHROPIC_API_KEY=sk-ant-...\n\n" + - "Get your key at https://console.anthropic.com/settings/keys", -) - // Anthropic implements the Provider interface using the Anthropic API. type Anthropic struct { apiKey string @@ -34,15 +25,12 @@ type Anthropic struct { // NewAnthropic creates an Anthropic provider, reading the API key from the // environment or the provided key string. func NewAnthropic(apiKey string) (*Anthropic, error) { - if apiKey == "" { - apiKey = os.Getenv(anthropicAPIKeyEnvVar) - } - - if apiKey == "" { - return nil, errMissingAnthropicKey + key, err := resolveAPIKey(apiKey, anthropicAPIKeyEnvVar, errMissingAnthropicKey) + if err != nil { + return nil, err } - return &Anthropic{apiKey: apiKey}, nil + return &Anthropic{apiKey: key}, nil } // Name returns the provider name. diff --git a/internal/ai/errors.go b/internal/ai/errors.go new file mode 100644 index 0000000..4af8778 --- /dev/null +++ b/internal/ai/errors.go @@ -0,0 +1,22 @@ +// Copyright 2026 YLD Limited +// SPDX-License-Identifier: Apache-2.0 + +package ai + +import "errors" + +var ( + errEmptyResponse = errors.New("LLM returned empty response. Try a more specific prompt") + errMissingAnthropicKey = errors.New( + "ANTHROPIC_API_KEY environment variable is not set.\n\n" + + "Set it with:\n" + + " export ANTHROPIC_API_KEY=sk-ant-...\n\n" + + "Get your key at https://console.anthropic.com/settings/keys", + ) + errMissingOpenAIKey = errors.New( + "OPENAI_API_KEY environment variable is not set.\n\n" + + "Set it with:\n" + + " export OPENAI_API_KEY=sk-...\n\n" + + "Get your key at https://platform.openai.com/api-keys", + ) +) diff --git a/internal/ai/openai.go b/internal/ai/openai.go index ed231f5..2d3fb73 100644 --- a/internal/ai/openai.go +++ b/internal/ai/openai.go @@ -5,9 +5,7 @@ package ai import ( "context" - "errors" "fmt" - "os" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" @@ -18,13 +16,6 @@ const ( openaiAPIKeyEnvVar = "OPENAI_API_KEY" ) -var errMissingOpenAIKey = errors.New( - "OPENAI_API_KEY environment variable is not set.\n\n" + - "Set it with:\n" + - " export OPENAI_API_KEY=sk-...\n\n" + - "Get your key at https://platform.openai.com/api-keys", -) - // OpenAI implements the Provider interface using the OpenAI API. type OpenAI struct { apiKey string @@ -33,15 +24,12 @@ type OpenAI struct { // NewOpenAI creates an OpenAI provider, reading the API key from the // environment or the provided key string. func NewOpenAI(apiKey string) (*OpenAI, error) { - if apiKey == "" { - apiKey = os.Getenv(openaiAPIKeyEnvVar) - } - - if apiKey == "" { - return nil, errMissingOpenAIKey + key, err := resolveAPIKey(apiKey, openaiAPIKeyEnvVar, errMissingOpenAIKey) + if err != nil { + return nil, err } - return &OpenAI{apiKey: apiKey}, nil + return &OpenAI{apiKey: key}, nil } // Name returns the provider name. diff --git a/internal/ai/provider.go b/internal/ai/provider.go index 1db4013..6c72b0d 100644 --- a/internal/ai/provider.go +++ b/internal/ai/provider.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "os" "regexp" "strings" "time" @@ -20,7 +21,7 @@ const ( DefaultMaxTokens = 4096 ) -var errEmptyResponse = errors.New("LLM returned empty response. Try a more specific prompt") +var fencePattern = regexp.MustCompile("(?s)```(?:ya?ml)?\\s*\n(.*?)```") // GenerateRequest holds the parameters for an LLM generation call. type GenerateRequest struct { @@ -31,8 +32,8 @@ type GenerateRequest struct { // GenerateResponse holds the LLM response text and token usage. type GenerateResponse struct { - Text string - InputTokens int64 + Text string + InputTokens int64 OutputTokens int64 } @@ -81,10 +82,22 @@ func classifyError(err error, providerName string) error { } } +// resolveAPIKey returns the provided key, or falls back to the environment +// variable. Returns missingErr if neither is set. +func resolveAPIKey(provided, envVar string, missingErr error) (string, error) { + if provided != "" { + return provided, nil + } + + if key := os.Getenv(envVar); key != "" { + return key, nil + } + + return "", missingErr +} + // StripFences removes markdown code fences from LLM output, returning clean YAML. func StripFences(s string) string { - fencePattern := regexp.MustCompile("(?s)```(?:ya?ml)?\\s*\n(.*?)```") - matches := fencePattern.FindAllStringSubmatch(s, -1) if len(matches) > 0 { var parts []string diff --git a/internal/ai/strip.go b/internal/ai/strip.go index 6667426..37c96c6 100644 --- a/internal/ai/strip.go +++ b/internal/ai/strip.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "github.com/hashicorp/hcl/v2" @@ -14,11 +15,10 @@ import ( ) const ( - maxContextTokens = 8000 - bytesPerToken = 4 - maxContextBytes = maxContextTokens * bytesPerToken - strippedLiteral = `"..."` - strippedHeredoc = "..." + maxContextTokens = 8000 + bytesPerToken = 4 + maxContextBytes = maxContextTokens * bytesPerToken + strippedLiteral = `"..."` ) // StripHCLContext reads HCL files from the given directory, strips all string @@ -59,13 +59,29 @@ func StripHCLContext(dir string) (string, bool) { truncated := false if len(result) > maxContextBytes { - result = result[:maxContextBytes] + result = truncateAtNewline(result, maxContextBytes) truncated = true } return result, truncated } +// truncateAtNewline truncates s to at most maxLen bytes, cutting at the last +// newline before the limit to avoid splitting mid-line or mid-rune. +func truncateAtNewline(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + + cut := s[:maxLen] + + if i := strings.LastIndex(cut, "\n"); i > 0 { + return cut[:i] + } + + return cut +} + func stripHCLFile(src []byte, filename string) string { file, diags := hclsyntax.ParseConfig(src, filename, hcl.Pos{Line: 1, Column: 1}) if diags.HasErrors() { @@ -87,8 +103,16 @@ func stripHCLFile(src []byte, filename string) string { func stripBody(b *strings.Builder, body *hclsyntax.Body, indent int) { prefix := strings.Repeat(" ", indent) - for _, attr := range body.Attributes { - fmt.Fprintf(b, "%s%s = %s\n", prefix, attr.Name, strippedLiteral) + // Sort attribute names for deterministic output. + attrNames := make([]string, 0, len(body.Attributes)) + for name := range body.Attributes { + attrNames = append(attrNames, name) + } + + sort.Strings(attrNames) + + for _, name := range attrNames { + fmt.Fprintf(b, "%s%s = %s\n", prefix, name, strippedLiteral) } for _, block := range body.Blocks { diff --git a/internal/command/assist.go b/internal/command/assist.go new file mode 100644 index 0000000..cfd327c --- /dev/null +++ b/internal/command/assist.go @@ -0,0 +1,370 @@ +// Copyright 2026 YLD Limited +// SPDX-License-Identifier: Apache-2.0 + +package command + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/urfave/cli/v3" + "github.com/yldio/cinzel/internal/ai" + "github.com/yldio/cinzel/provider" +) + +const ( + defaultAssistOutputDir = "cinzel/assist" + maxRawYAMLErrorLen = 500 +) + +func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { + return &cli.Command{ + Name: "assist", + Usage: "Generate HCL workflow definitions from a natural language prompt", + Action: func(ctx context.Context, c *cli.Command) error { + prompt := c.String("prompt") + refine := c.String("refine") + + if prompt == "" && refine == "" { + return errPromptRequired + } + + outputDir := c.String("output-directory") + if outputDir == "" { + outputDir = defaultAssistOutputDir + } + + dryRun := c.Bool("dry-run") + acknowledge := c.Bool("acknowledge") + + aiProviderName := c.String("provider") + model := c.String("model") + + aiProvider, err := resolveAIProvider(aiProviderName, "") + if err != nil { + return err + } + + if !acknowledge { + if err := confirmCost(cmd.Writer, os.Stdin, aiProvider.Name(), model); err != nil { + return err + } + } + + _, _ = fmt.Fprintf(cmd.Writer, "Generating workflow...\n") + + systemPrompt := ai.SystemPrompt(p.GetProviderName()) + + if !c.Bool("no-context") { + contextDir := c.String("context-dir") + if contextDir == "" { + contextDir = "cinzel" + } + + hclContext, truncated := ai.StripHCLContext(contextDir) + if hclContext != "" { + systemPrompt += "\n\nExisting HCL structure (values stripped for privacy):\n\n" + hclContext + } + + if truncated { + _, _ = fmt.Fprintf(cmd.Writer, "warning: HCL context truncated to fit token limit\n") + } + } + + userPrompt := prompt + + if refine != "" { + assistContext, _ := ai.StripHCLContext(outputDir) + if assistContext == "" { + return fmt.Errorf("nothing to refine — run assist --prompt first to generate initial output in %s", outputDir) + } + + systemPrompt += "\n\nPrevious assist output (to be refined):\n\n" + assistContext + + if prompt != "" { + userPrompt = refine + "\n\nOriginal request: " + prompt + } else { + userPrompt = refine + } + } + + response, err := ai.GenerateWithTimeout(ctx, aiProvider, ai.GenerateRequest{ + SystemPrompt: systemPrompt, + UserPrompt: userPrompt, + Model: model, + }) + if err != nil { + return err + } + + _, _ = fmt.Fprintf(cmd.Writer, "Tokens used: %d (input: %d, output: %d)\n", + response.TotalTokens(), response.InputTokens, response.OutputTokens) + + yamlContent := ai.StripFences(response.Text) + + return cmd.unparseAndWrite(p, yamlContent, outputDir, dryRun) + }, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "prompt", + Aliases: []string{"p"}, + Usage: "Natural language description of the workflow", + }, + &cli.StringFlag{ + Name: "refine", + Usage: "Refine previous assist output with additional instructions", + }, + &cli.StringFlag{ + Name: "output-directory", + Value: "", + Usage: "Generated HCL files are created in `DIRECTORY` (default: cinzel/assist)", + }, + &cli.BoolFlag{ + Name: "dry-run", + Value: false, + Usage: "Output to stdout instead of writing files", + }, + &cli.BoolFlag{ + Name: "acknowledge", + Value: false, + Usage: "Bypass the cost confirmation prompt", + }, + &cli.StringFlag{ + Name: "provider", + Value: "anthropic", + Usage: "AI provider: anthropic or openai", + }, + &cli.StringFlag{ + Name: "model", + Value: "", + Usage: "Model override (default: provider-specific)", + }, + &cli.BoolFlag{ + Name: "no-context", + Value: false, + Usage: "Skip injecting existing HCL as context", + }, + &cli.StringFlag{ + Name: "context-dir", + Value: "", + Usage: "Directory to read existing HCL from (default: cinzel)", + }, + }, + } +} + +func (cmd *Cli) unparseAndWrite(p provider.Provider, yamlContent, outputDir string, dryRun bool) error { + tmpYAMLDir, err := os.MkdirTemp("", "cinzel-assist-yaml-*") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + + defer os.RemoveAll(tmpYAMLDir) + + tmpHCLDir, err := os.MkdirTemp("", "cinzel-assist-hcl-*") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + + defer os.RemoveAll(tmpHCLDir) + + docs := splitYAMLDocuments(yamlContent) + + for i, doc := range docs { + doc = strings.TrimSpace(doc) + if doc == "" { + continue + } + + tmpPath := filepath.Join(tmpYAMLDir, fmt.Sprintf("workflow-%d.yaml", i)) + + if err := os.WriteFile(tmpPath, []byte(doc), 0600); err != nil { + return fmt.Errorf("failed to write temp file: %w", err) + } + } + + err = p.Unparse(provider.ProviderOps{ + Directory: tmpYAMLDir, + OutputDirectory: tmpHCLDir, + DryRun: false, + }) + if err != nil { + preview := yamlContent + if len(preview) > maxRawYAMLErrorLen { + preview = preview[:maxRawYAMLErrorLen] + "\n... (truncated)" + } + + return fmt.Errorf( + "generated YAML could not be converted to HCL:\n%s\n\nRaw YAML (preview):\n%s\n\nTry refining your prompt", + err, preview, + ) + } + + merged, err := mergeHCLFiles(tmpHCLDir) + if err != nil { + return fmt.Errorf("failed to merge HCL files: %w", err) + } + + if dryRun { + _, _ = fmt.Fprintln(cmd.Writer, merged) + + return nil + } + + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + timestamp := time.Now().Format("20060102-150405") + outPath := filepath.Join(outputDir, fmt.Sprintf("assist-%s.hcl", timestamp)) + + if err := os.WriteFile(outPath, []byte(merged), 0644); err != nil { + return fmt.Errorf("failed to write output file: %w", err) + } + + absPath, _ := filepath.Abs(outPath) + _, _ = fmt.Fprintf(cmd.Writer, "HCL written to %s\n", absPath) + + return nil +} + +// mergeHCLFiles reads all HCL files in dir, parses them with the HCL AST, +// and returns a single merged output with duplicate blocks removed. +func mergeHCLFiles(dir string) (string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return "", err + } + + seen := make(map[string]bool) + + var parts []string + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".hcl") { + continue + } + + content, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + return "", err + } + + for _, block := range splitHCLBlocksAST(content, entry.Name()) { + block = strings.TrimSpace(block) + if block == "" { + continue + } + + if seen[block] { + continue + } + + seen[block] = true + parts = append(parts, block) + } + } + + return strings.Join(parts, "\n\n") + "\n", nil +} + +// splitHCLBlocksAST uses the HCL write parser to split content into +// individual top-level blocks. This is robust against braces inside +// strings, comments, and heredocs. +func splitHCLBlocksAST(src []byte, filename string) []string { + file, diags := hclwrite.ParseConfig(src, filename, hcl.Pos{Line: 1, Column: 1}) + if diags.HasErrors() { + // Fall back to raw content as a single block if parse fails. + return []string{string(src)} + } + + var blocks []string + + for _, block := range file.Body().Blocks() { + blocks = append(blocks, strings.TrimSpace(string(block.BuildTokens(nil).Bytes()))) + } + + // Also capture top-level attributes (e.g., standalone assignments). + attrs := file.Body().Attributes() + + attrNames := make([]string, 0, len(attrs)) + for name := range attrs { + attrNames = append(attrNames, name) + } + + sort.Strings(attrNames) + + for _, name := range attrNames { + attr := attrs[name] + blocks = append(blocks, strings.TrimSpace(string(attr.BuildTokens(nil).Bytes()))) + } + + return blocks +} + +func splitYAMLDocuments(s string) []string { + var docs []string + var current strings.Builder + + for _, line := range strings.Split(s, "\n") { + if strings.TrimSpace(line) == "---" && current.Len() > 0 { + docs = append(docs, current.String()) + current.Reset() + + continue + } + + current.WriteString(line) + current.WriteString("\n") + } + + if strings.TrimSpace(current.String()) != "" { + docs = append(docs, current.String()) + } + + return docs +} + +func resolveAIProvider(name, apiKey string) (ai.Provider, error) { + switch strings.ToLower(name) { + case "anthropic", "": + return ai.NewAnthropic(apiKey) + case "openai": + return ai.NewOpenAI(apiKey) + default: + return nil, fmt.Errorf("unknown AI provider %q. Supported: anthropic, openai", name) + } +} + +func confirmCost(w io.Writer, r io.Reader, providerName, model string) error { + if model == "" { + model = "default" + } + + _, _ = fmt.Fprintf(w, "This will call %s (%s). API usage will incur costs.\nContinue? [y/N] ", providerName, model) + + scanner := bufio.NewScanner(r) + if scanner.Scan() { + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer == "y" || answer == "yes" { + return nil + } + } + + return errCancelled +} + +// splitHCLBlocks splits HCL content by top-level blocks using the HCL AST. +// Deprecated: use splitHCLBlocksAST instead. Kept only if hclwrite is unavailable. +func splitHCLBlocks(content string) []string { + return splitHCLBlocksAST([]byte(content), "input.hcl") +} diff --git a/internal/command/command.go b/internal/command/command.go index 4badc3c..bfc32e6 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -4,18 +4,12 @@ package command import ( - "bufio" "context" "fmt" "io" "net/mail" - "os" - "path/filepath" - "strings" - "time" "github.com/urfave/cli/v3" - "github.com/yldio/cinzel/internal/ai" "github.com/yldio/cinzel/internal/cinzelerror" "github.com/yldio/cinzel/provider" ) @@ -191,335 +185,3 @@ func (cmd *Cli) addProvider(p provider.Provider) *cli.Command { }, } } - -const defaultAssistOutputDir = "cinzel/assist" - -func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { - return &cli.Command{ - Name: "assist", - Usage: "Generate HCL workflow definitions from a natural language prompt", - Action: func(ctx context.Context, c *cli.Command) error { - prompt := c.String("prompt") - refine := c.String("refine") - - if prompt == "" && refine == "" { - return fmt.Errorf("--prompt is required (or use --refine to iterate on previous output)") - } - - outputDir := c.String("output-directory") - if outputDir == "" { - outputDir = defaultAssistOutputDir - } - - dryRun := c.Bool("dry-run") - acknowledge := c.Bool("acknowledge") - - aiProviderName := c.String("provider") - model := c.String("model") - - aiProvider, err := resolveAIProvider(aiProviderName, "") - if err != nil { - return err - } - - if !acknowledge { - if err := confirmCost(cmd.Writer, os.Stdin, aiProvider.Name(), model); err != nil { - return err - } - } - - _, _ = fmt.Fprintf(cmd.Writer, "Generating workflow...\n") - - systemPrompt := ai.SystemPrompt(p.GetProviderName()) - - if !c.Bool("no-context") { - contextDir := c.String("context-dir") - if contextDir == "" { - contextDir = "cinzel" - } - - hclContext, truncated := ai.StripHCLContext(contextDir) - if hclContext != "" { - systemPrompt += "\n\nExisting HCL structure (values stripped for privacy):\n\n" + hclContext - } - - if truncated { - _, _ = fmt.Fprintf(cmd.Writer, "warning: HCL context truncated to fit token limit\n") - } - } - - userPrompt := prompt - - if refine != "" { - assistContext, _ := ai.StripHCLContext(outputDir) - if assistContext == "" { - return fmt.Errorf("nothing to refine — run assist --prompt first to generate initial output in %s", outputDir) - } - - systemPrompt += "\n\nPrevious assist output (to be refined):\n\n" + assistContext - - if prompt != "" { - userPrompt = refine + "\n\nOriginal request: " + prompt - } else { - userPrompt = refine - } - } - - response, err := ai.GenerateWithTimeout(ctx, aiProvider, ai.GenerateRequest{ - SystemPrompt: systemPrompt, - UserPrompt: userPrompt, - Model: model, - }) - if err != nil { - return err - } - - _, _ = fmt.Fprintf(cmd.Writer, "Tokens used: %d (input: %d, output: %d)\n", - response.TotalTokens(), response.InputTokens, response.OutputTokens) - - yamlContent := ai.StripFences(response.Text) - - return cmd.unparseAndWrite(p, yamlContent, outputDir, dryRun) - }, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "prompt", - Aliases: []string{"p"}, - Usage: "Natural language description of the workflow", - }, - &cli.StringFlag{ - Name: "refine", - Usage: "Refine previous assist output with additional instructions", - }, - &cli.StringFlag{ - Name: "output-directory", - Value: "", - Usage: "Generated HCL files are created in `DIRECTORY` (default: cinzel/assist)", - }, - &cli.BoolFlag{ - Name: "dry-run", - Value: false, - Usage: "Output to stdout instead of writing files", - }, - &cli.BoolFlag{ - Name: "acknowledge", - Value: false, - Usage: "Bypass the cost confirmation prompt", - }, - &cli.StringFlag{ - Name: "provider", - Value: "anthropic", - Usage: "AI provider: anthropic or openai", - }, - &cli.StringFlag{ - Name: "model", - Value: "", - Usage: "Model override (default: provider-specific)", - }, - &cli.BoolFlag{ - Name: "no-context", - Value: false, - Usage: "Skip injecting existing HCL as context", - }, - &cli.StringFlag{ - Name: "context-dir", - Value: "", - Usage: "Directory to read existing HCL from (default: cinzel)", - }, - }, - } -} - -func (cmd *Cli) unparseAndWrite(p provider.Provider, yamlContent, outputDir string, dryRun bool) error { - tmpYAMLDir, err := os.MkdirTemp("", "cinzel-assist-yaml-*") - if err != nil { - return fmt.Errorf("failed to create temp directory: %w", err) - } - - defer os.RemoveAll(tmpYAMLDir) - - tmpHCLDir, err := os.MkdirTemp("", "cinzel-assist-hcl-*") - if err != nil { - return fmt.Errorf("failed to create temp directory: %w", err) - } - - defer os.RemoveAll(tmpHCLDir) - - docs := splitYAMLDocuments(yamlContent) - - for i, doc := range docs { - doc = strings.TrimSpace(doc) - if doc == "" { - continue - } - - tmpPath := filepath.Join(tmpYAMLDir, fmt.Sprintf("workflow-%d.yaml", i)) - - if err := os.WriteFile(tmpPath, []byte(doc), 0600); err != nil { - return fmt.Errorf("failed to write temp file: %w", err) - } - } - - err = p.Unparse(provider.ProviderOps{ - Directory: tmpYAMLDir, - OutputDirectory: tmpHCLDir, - DryRun: false, - }) - if err != nil { - return fmt.Errorf( - "generated YAML could not be converted to HCL:\n%s\n\nRaw YAML:\n%s\n\nTry refining your prompt", - err, yamlContent, - ) - } - - merged, err := mergeHCLFiles(tmpHCLDir) - if err != nil { - return fmt.Errorf("failed to merge HCL files: %w", err) - } - - if dryRun { - _, _ = fmt.Fprintln(cmd.Writer, merged) - - return nil - } - - if err := os.MkdirAll(outputDir, 0755); err != nil { - return fmt.Errorf("failed to create output directory: %w", err) - } - - timestamp := time.Now().Format("20060102-150405") - outPath := filepath.Join(outputDir, fmt.Sprintf("assist-%s.hcl", timestamp)) - - if err := os.WriteFile(outPath, []byte(merged), 0644); err != nil { - return fmt.Errorf("failed to write output file: %w", err) - } - - absPath, _ := filepath.Abs(outPath) - _, _ = fmt.Fprintf(cmd.Writer, "HCL written to %s\n", absPath) - - return nil -} - -func mergeHCLFiles(dir string) (string, error) { - entries, err := os.ReadDir(dir) - if err != nil { - return "", err - } - - seen := make(map[string]bool) - - var parts []string - - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".hcl") { - continue - } - - content, err := os.ReadFile(filepath.Join(dir, entry.Name())) - if err != nil { - return "", err - } - - for _, block := range splitHCLBlocks(string(content)) { - block = strings.TrimSpace(block) - if block == "" { - continue - } - - if seen[block] { - continue - } - - seen[block] = true - parts = append(parts, block) - } - } - - return strings.Join(parts, "\n\n") + "\n", nil -} - -func splitHCLBlocks(content string) []string { - var blocks []string - var current strings.Builder - - depth := 0 - - for _, line := range strings.Split(content, "\n") { - trimmed := strings.TrimSpace(line) - - if trimmed == "" && depth == 0 { - if current.Len() > 0 { - blocks = append(blocks, current.String()) - current.Reset() - } - - continue - } - - if current.Len() > 0 { - current.WriteString("\n") - } - - current.WriteString(line) - - depth += strings.Count(trimmed, "{") - strings.Count(trimmed, "}") - } - - if current.Len() > 0 { - blocks = append(blocks, current.String()) - } - - return blocks -} - -func splitYAMLDocuments(s string) []string { - var docs []string - var current strings.Builder - - for _, line := range strings.Split(s, "\n") { - if strings.TrimSpace(line) == "---" && current.Len() > 0 { - docs = append(docs, current.String()) - current.Reset() - - continue - } - - current.WriteString(line) - current.WriteString("\n") - } - - if strings.TrimSpace(current.String()) != "" { - docs = append(docs, current.String()) - } - - return docs -} - -func resolveAIProvider(name, apiKey string) (ai.Provider, error) { - switch strings.ToLower(name) { - case "anthropic", "": - return ai.NewAnthropic(apiKey) - case "openai": - return ai.NewOpenAI(apiKey) - default: - return nil, fmt.Errorf("unknown AI provider %q. Supported: anthropic, openai", name) - } -} - -func confirmCost(w io.Writer, r io.Reader, providerName, model string) error { - if model == "" { - model = "default" - } - - _, _ = fmt.Fprintf(w, "This will call %s (%s). API usage will incur costs.\nContinue? [y/N] ", providerName, model) - - scanner := bufio.NewScanner(r) - if scanner.Scan() { - answer := strings.TrimSpace(strings.ToLower(scanner.Text())) - if answer == "y" || answer == "yes" { - return nil - } - } - - return fmt.Errorf("cancelled") -} diff --git a/internal/command/errors.go b/internal/command/errors.go new file mode 100644 index 0000000..48cb3ef --- /dev/null +++ b/internal/command/errors.go @@ -0,0 +1,11 @@ +// Copyright 2026 YLD Limited +// SPDX-License-Identifier: Apache-2.0 + +package command + +import "errors" + +var ( + errCancelled = errors.New("cancelled") + errPromptRequired = errors.New("--prompt is required (or use --refine to iterate on previous output)") +) From c0570c6c6e9f1692c5f08db211d03d4fa92abad8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Tue, 17 Mar 2026 10:10:22 +0000 Subject: [PATCH 14/48] docs: update plan with implementation status and findings --- ...nzel-assist-ai-workflow-generation-plan.md | 85 ++++++++++++++----- 1 file changed, 63 insertions(+), 22 deletions(-) diff --git a/docs/plans/2026-03-16-feat-cinzel-assist-ai-workflow-generation-plan.md b/docs/plans/2026-03-16-feat-cinzel-assist-ai-workflow-generation-plan.md index 596203f..bc255a3 100644 --- a/docs/plans/2026-03-16-feat-cinzel-assist-ai-workflow-generation-plan.md +++ b/docs/plans/2026-03-16-feat-cinzel-assist-ai-workflow-generation-plan.md @@ -1,7 +1,7 @@ --- title: "feat: cinzel assist — AI-powered workflow generation" type: feat -status: active +status: implemented date: 2026-03-16 origin: docs/brainstorms/2026-03-16-feat-cinzel-assist-ai-workflow-generation.md --- @@ -20,16 +20,16 @@ Writing CI/CD workflows from scratch requires deep knowledge of provider-specifi ## Proposed Solution -**v1 pipeline (minimal):** +**Current pipeline (implemented):** ``` -prompt → LLM → YAML → strip fences → unparse (via temp file) → HCL → ./cinzel/assist/ +existing HCL → strip string literals → build prompt → LLM → YAML → strip fences → split YAML docs → unparse each → merge/dedup HCL blocks → single timestamped file → ./cinzel/assist/ ``` -**Future pipeline (v2+):** +**Future pipeline (deferred):** ``` -existing HCL → strip string literals → build prompt → LLM → YAML → strip fences → unparse → HCL → (optional) pin SHAs → ./cinzel/assist/ +... → (optional) pin SHAs → (optional) retry with error feedback → ./cinzel/assist/ ``` ## Technical Approach @@ -42,7 +42,7 @@ existing HCL → strip string literals → build prompt → LLM → YAML → str ### Implementation Phases -#### Phase 1: End-to-end assist (Anthropic only) +#### Phase 1: End-to-end assist (Anthropic only) — COMPLETE **Goal**: `cinzel github assist --prompt "..."` generates HCL files. Minimal viable pipeline. @@ -101,15 +101,24 @@ existing HCL → strip string literals → build prompt → LLM → YAML → str - Unparse failure → "Generated YAML could not be converted to HCL:\n{error}\n\nRaw YAML:\n{yaml}\n\nTry refining your prompt." - Raw YAML error output must NOT contain any user HCL content (no context injection in v1, so this is inherently safe) -**Files**: +**Files (actual)**: - `internal/ai/doc.go` (NEW) - `internal/ai/anthropic.go` (NEW) -- `internal/command/command.go` (MODIFY — add assist subcommand) -- `go.mod` (MODIFY — add `github.com/anthropics/anthropic-sdk-go`) +- `internal/ai/provider.go` (NEW — interface, GenerateResponse, StripFences, SystemPrompt, resolveAPIKey) +- `internal/ai/openai.go` (NEW) +- `internal/ai/errors.go` (NEW — consolidated sentinel errors) +- `internal/ai/strip.go` (NEW — HCL string stripping for context injection) +- `internal/ai/strip_test.go` (NEW — 12 tests including real fixture) +- `internal/ai/provider_test.go` (NEW — StripFences tests) +- `internal/command/assist.go` (NEW — extracted assist logic, ~280 lines) +- `internal/command/assist_test.go` (NEW — splitYAMLDocuments tests) +- `internal/command/errors.go` (NEW — errCancelled, errPromptRequired) +- `internal/command/command.go` (MODIFY — slimmed down, assist extracted) +- `go.mod` (MODIFY — anthropic-sdk-go + openai-go) -**File count**: 2 new + 2 modified. +**File count**: 11 new + 2 modified. -#### Phase 2: Context injection + OpenAI +#### Phase 2: Context injection + OpenAI — COMPLETE (config files deferred) **Goal**: Existing HCL context improves output quality. OpenAI as second provider. Config file support. @@ -190,7 +199,7 @@ existing HCL → strip string literals → build prompt → LLM → YAML → str - `internal/command/config.go` (MODIFY — accept ai: section) - `go.mod` (MODIFY — add `github.com/openai/openai-go`) -#### Phase 3: `cinzel pin` (standalone command) +#### Phase 3: `cinzel pin` (standalone command) — DEFERRED **Goal**: Resolve action tags to SHAs. Standalone command, independently useful. @@ -225,7 +234,7 @@ existing HCL → strip string literals → build prompt → LLM → YAML → str - `internal/pin/pin.go` (NEW) - `internal/command/command.go` (MODIFY — register pin command) -#### Phase 4: Retry loop + hardening +#### Phase 4: Retry loop + hardening — DEFERRED **Goal**: Improve success rate for complex prompts. Ship only if failure rates warrant it. @@ -250,29 +259,61 @@ existing HCL → strip string literals → build prompt → LLM → YAML → str ### Interaction Graph -v1: `assist` calls: Anthropic SDK → write temp file → `provider.Unparse()` → `fsutil.WriteFile()`. Isolated pipeline, no interface changes. +Current: `assist` calls: `ai.Provider.Generate()` → `StripFences()` → split YAML docs → write temp files → `provider.Unparse()` → `mergeHCLFiles()` → `splitHCLBlocksAST()` (dedup) → single output file. -v2+: adds strip string literals → `ai.Provider.Generate()` → optional `pin.Pin()`. +Future: adds optional `pin.Pin()` and retry loop. ### Error Propagation -- AI API errors (auth, rate limit, timeout) → user-facing message with actionable instructions -- Unparse errors → v1: show error + raw YAML. v2+: retry with sanitized error context -- Pin errors → non-fatal warning, output written with unpinned tags -- v1 sends no context — inherently safe against data leakage +- AI API errors classified by type: auth (401), quota exceeded (insufficient_quota), rate limit (429), timeout (DeadlineExceeded) +- Unparse errors → show truncated raw YAML (max 500 chars) + suggestion to refine prompt +- Empty LLM response → clear error message +- Token usage displayed after every successful generation ### State Lifecycle Risks -- `./cinzel/assist/` overwritten on each run — no orphaned state +- `./cinzel/assist/` receives timestamped files (`assist-20260316-193045.hcl`) — no overwrite conflicts - `--refine` reads from `assist/` — clear error if deleted between runs -- Temp files cleaned up via `defer os.Remove()` — no leak on error paths +- Two temp directories cleaned up via `defer os.RemoveAll()` — no leak on error paths - No database, no persistent state beyond file output ### API Surface Parity - `assist` registered alongside `parse`/`unparse` in `addProvider()` — consistent flag patterns - `--output-directory` supported (architecture review #6) -- `pin` registered as sibling command — same `--file` flag + +## Implementation Notes (post-implementation) + +Items implemented during development that were not in the original plan: + +### HCL block merging and deduplication + +Multi-workflow prompts generate separate YAML documents. Each is unparsed independently, producing separate HCL files. `mergeHCLFiles` reads all generated HCL, splits into blocks using `hclwrite.ParseConfig` (AST-based, not brace counting), deduplicates identical blocks, and writes a single output file. This ensures shared steps (checkout, setup) appear once. + +### Single timestamped output file + +Output is `assist-{timestamp}.hcl` (e.g., `assist-20260316-193045.hcl`) instead of one file per workflow. Avoids overwrite conflicts on repeated runs. + +### Token usage display + +After generation, prints: `Tokens used: 1247 (input: 892, output: 355)`. Uses `GenerateResponse` struct with `InputTokens`, `OutputTokens`, `TotalTokens()`. + +### System prompt for step reuse + +Instructs the LLM to use identical step names across workflows so dedup works: "Use consistent names: checkout, setup_go, install_deps — not step_1, step_2." + +### Code review findings addressed + +From 3 parallel reviews (pattern recognition, performance, security): +- `splitHCLBlocks` replaced with AST-based `splitHCLBlocksAST` using `hclwrite` +- Regex `fencePattern` hoisted to package-level `var` +- Sentinel errors consolidated into `errors.go` per package +- `resolveAPIKey` shared helper eliminates constructor duplication +- Attribute ordering made deterministic via `sort.Strings` +- `truncateAtNewline` for safe context truncation (no mid-rune cuts) +- Raw YAML in error output truncated to 500 chars +- `errCancelled` and `errPromptRequired` as sentinel errors +- Assist logic extracted to `internal/command/assist.go` (~280 lines) ## Acceptance Criteria From 50c3dd8f67beb4dbe5a04f5d5ccd440125776fda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Tue, 17 Mar 2026 11:49:46 +0000 Subject: [PATCH 15/48] test: add missing unit tests and remove dead code --- internal/ai/provider_test.go | 113 ++++++++++++++++- internal/command/assist.go | 6 - internal/command/assist_test.go | 209 ++++++++++++++++++++++++++++++++ 3 files changed, 321 insertions(+), 7 deletions(-) diff --git a/internal/ai/provider_test.go b/internal/ai/provider_test.go index 4b1e037..1409ff5 100644 --- a/internal/ai/provider_test.go +++ b/internal/ai/provider_test.go @@ -4,12 +4,15 @@ package ai import ( + "context" + "errors" + "strings" "testing" ) func TestStripFences(t *testing.T) { tests := []struct { - name string + name string input string want string }{ @@ -59,3 +62,111 @@ func TestStripFences(t *testing.T) { }) } } + +func TestClassifyError(t *testing.T) { + tests := []struct { + name string + err error + contains string + }{ + { + name: "authentication error", + err: errors.New("authentication failed: 401 Unauthorized"), + contains: "invalid API key", + }, + { + name: "quota exceeded", + err: errors.New("insufficient_quota: check billing"), + contains: "quota exceeded", + }, + { + name: "rate limit", + err: errors.New("rate_limit_exceeded: 429"), + contains: "rate limited", + }, + { + name: "timeout", + err: context.DeadlineExceeded, + contains: "timed out", + }, + { + name: "generic error", + err: errors.New("something unexpected"), + contains: "LLM API error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := classifyError(tt.err, "test-provider") + if !strings.Contains(got.Error(), tt.contains) { + t.Errorf("classifyError():\ngot: %q\nwant to contain: %q", got.Error(), tt.contains) + } + }) + } +} + +func TestResolveAPIKey(t *testing.T) { + sentinel := errors.New("key missing") + + t.Run("provided key used", func(t *testing.T) { + key, err := resolveAPIKey("my-key", "NONEXISTENT_VAR", sentinel) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if key != "my-key" { + t.Errorf("expected my-key, got %s", key) + } + }) + + t.Run("missing key returns sentinel", func(t *testing.T) { + _, err := resolveAPIKey("", "NONEXISTENT_VAR_12345", sentinel) + if !errors.Is(err, sentinel) { + t.Errorf("expected sentinel error, got %v", err) + } + }) +} + +func TestTruncateAtNewline(t *testing.T) { + tests := []struct { + name string + input string + maxLen int + want string + }{ + { + name: "no truncation needed", + input: "short", + maxLen: 100, + want: "short", + }, + { + name: "truncates at newline", + input: "line1\nline2\nline3", + maxLen: 10, + want: "line1", + }, + { + name: "no newline in range", + input: "abcdefghij", + maxLen: 5, + want: "abcde", + }, + { + name: "exact length", + input: "abc", + maxLen: 3, + want: "abc", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateAtNewline(tt.input, tt.maxLen) + if got != tt.want { + t.Errorf("truncateAtNewline():\ngot: %q\nwant: %q", got, tt.want) + } + }) + } +} diff --git a/internal/command/assist.go b/internal/command/assist.go index cfd327c..8114d49 100644 --- a/internal/command/assist.go +++ b/internal/command/assist.go @@ -362,9 +362,3 @@ func confirmCost(w io.Writer, r io.Reader, providerName, model string) error { return errCancelled } - -// splitHCLBlocks splits HCL content by top-level blocks using the HCL AST. -// Deprecated: use splitHCLBlocksAST instead. Kept only if hclwrite is unavailable. -func splitHCLBlocks(content string) []string { - return splitHCLBlocksAST([]byte(content), "input.hcl") -} diff --git a/internal/command/assist_test.go b/internal/command/assist_test.go index dad6455..a4e279d 100644 --- a/internal/command/assist_test.go +++ b/internal/command/assist_test.go @@ -4,6 +4,10 @@ package command import ( + "bytes" + "os" + "path/filepath" + "strings" "testing" ) @@ -76,3 +80,208 @@ func TestSplitYAMLDocumentsContent(t *testing.T) { t.Errorf("doc[1]:\ngot: %q\nwant: %q", got, "name: workflow2\non:\n pull_request:\n") } } + +func TestSplitHCLBlocksAST(t *testing.T) { + tests := []struct { + name string + input string + wantBlocks int + }{ + { + name: "two blocks", + input: `step "checkout" { + name = "Checkout" +} + +step "test" { + name = "Test" +}`, + wantBlocks: 2, + }, + { + name: "block with braces in string", + input: `step "deploy" { + run = "echo ${VAR}" +}`, + wantBlocks: 1, + }, + { + name: "nested blocks", + input: `workflow "pr" { + on "pull_request" {} + jobs = [job.test] +}`, + wantBlocks: 1, + }, + { + name: "invalid HCL falls back to single block", + input: "this is not valid HCL {{{", + wantBlocks: 1, + }, + { + name: "top-level attribute", + input: `variable "os" { + value = "ubuntu" +} + +name = "test"`, + wantBlocks: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := splitHCLBlocksAST([]byte(tt.input), "test.hcl") + if len(got) != tt.wantBlocks { + t.Errorf("splitHCLBlocksAST() returned %d blocks, want %d\nblocks: %v", len(got), tt.wantBlocks, got) + } + }) + } +} + +func TestMergeHCLFiles(t *testing.T) { + dir := t.TempDir() + + file1 := `step "checkout" { + name = "Checkout" +} + +step "test" { + name = "Test" +} +` + file2 := `step "checkout" { + name = "Checkout" +} + +step "build" { + name = "Build" +} +` + + if err := os.WriteFile(filepath.Join(dir, "a.hcl"), []byte(file1), 0644); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(dir, "b.hcl"), []byte(file2), 0644); err != nil { + t.Fatal(err) + } + + merged, err := mergeHCLFiles(dir) + if err != nil { + t.Fatal(err) + } + + // checkout should appear only once (deduped) + if count := strings.Count(merged, `step "checkout"`); count != 1 { + t.Errorf("expected 1 checkout block, got %d\nmerged:\n%s", count, merged) + } + + // test and build should each appear once + if !strings.Contains(merged, `step "test"`) { + t.Error("expected test block in merged output") + } + + if !strings.Contains(merged, `step "build"`) { + t.Error("expected build block in merged output") + } +} + +func TestMergeHCLFilesIgnoresNonHCL(t *testing.T) { + dir := t.TempDir() + + if err := os.WriteFile(filepath.Join(dir, "readme.md"), []byte("# Secret"), 0644); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(dir, "test.hcl"), []byte(`step "a" {}`), 0644); err != nil { + t.Fatal(err) + } + + merged, err := mergeHCLFiles(dir) + if err != nil { + t.Fatal(err) + } + + if strings.Contains(merged, "Secret") { + t.Error("non-HCL content should not appear in merged output") + } +} + +func TestConfirmCost(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + {name: "yes", input: "y\n", wantErr: false}, + {name: "YES", input: "YES\n", wantErr: false}, + {name: "no", input: "n\n", wantErr: true}, + {name: "empty", input: "\n", wantErr: true}, + {name: "eof", input: "", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + err := confirmCost(&buf, strings.NewReader(tt.input), "anthropic", "default") + + if tt.wantErr && err == nil { + t.Error("expected error, got nil") + } + + if !tt.wantErr && err != nil { + t.Errorf("expected no error, got %v", err) + } + }) + } +} + +func TestResolveAIProvider(t *testing.T) { + tests := []struct { + name string + provider string + wantErr bool + wantName string + }{ + {name: "anthropic explicit", provider: "anthropic", wantErr: true}, + {name: "openai explicit", provider: "openai", wantErr: true}, + {name: "empty defaults to anthropic", provider: "", wantErr: true}, + {name: "unknown", provider: "gemini", wantErr: true}, + } + + // All cases error because no API keys are set in test env. + // We verify provider resolution logic, not API connectivity. + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := resolveAIProvider(tt.provider, "") + if tt.provider == "gemini" { + if err == nil || !strings.Contains(err.Error(), "unknown AI provider") { + t.Errorf("expected unknown provider error, got %v", err) + } + } else if err == nil { + t.Error("expected missing API key error without env var") + } + }) + } +} + +func TestResolveAIProviderWithKey(t *testing.T) { + p, err := resolveAIProvider("anthropic", "test-key") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if p.Name() != "anthropic" { + t.Errorf("expected anthropic, got %s", p.Name()) + } + + p, err = resolveAIProvider("openai", "test-key") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if p.Name() != "openai" { + t.Errorf("expected openai, got %s", p.Name()) + } +} From 7e720534e8c0acc039e7e7e6bd7ad4f4825e168c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Tue, 17 Mar 2026 12:10:16 +0000 Subject: [PATCH 16/48] fix: prevent path traversal and sanitize error messages --- internal/ai/provider.go | 6 +++--- internal/command/assist.go | 23 ++++++++++++++++++++++ internal/command/assist_test.go | 34 +++++++++++++++++++++++++++++++++ internal/command/errors.go | 4 +++- 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/internal/ai/provider.go b/internal/ai/provider.go index 6c72b0d..cc2b849 100644 --- a/internal/ai/provider.go +++ b/internal/ai/provider.go @@ -70,11 +70,11 @@ func classifyError(err error, providerName string) error { switch { case strings.Contains(msg, "authentication") || strings.Contains(msg, "401"): - return fmt.Errorf("invalid API key for %s: %w", providerName, err) + return fmt.Errorf("invalid API key for %s. Check your API key is correct", providerName) case strings.Contains(msg, "insufficient_quota") || strings.Contains(msg, "billing"): - return fmt.Errorf("API quota exceeded for %s. Check your plan and billing at your provider's dashboard: %w", providerName, err) + return fmt.Errorf("API quota exceeded for %s. Check your plan and billing at your provider's dashboard", providerName) case strings.Contains(msg, "rate_limit") || strings.Contains(msg, "429"): - return fmt.Errorf("API rate limited. Try again in a moment: %w", err) + return fmt.Errorf("API rate limited. Try again in a moment") case errors.Is(err, context.DeadlineExceeded): return fmt.Errorf("LLM request timed out after %s. Try a simpler prompt", DefaultTimeout) default: diff --git a/internal/command/assist.go b/internal/command/assist.go index 8114d49..67355a8 100644 --- a/internal/command/assist.go +++ b/internal/command/assist.go @@ -43,6 +43,10 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { outputDir = defaultAssistOutputDir } + if err := validateRelativePath(outputDir); err != nil { + return fmt.Errorf("--output-directory: %w", err) + } + dryRun := c.Bool("dry-run") acknowledge := c.Bool("acknowledge") @@ -70,6 +74,10 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { contextDir = "cinzel" } + if err := validateRelativePath(contextDir); err != nil { + return fmt.Errorf("--context-dir: %w", err) + } + hclContext, truncated := ai.StripHCLContext(contextDir) if hclContext != "" { systemPrompt += "\n\nExisting HCL structure (values stripped for privacy):\n\n" + hclContext @@ -362,3 +370,18 @@ func confirmCost(w io.Writer, r io.Reader, providerName, model string) error { return errCancelled } + +// validateRelativePath ensures a path is relative and does not escape the +// current working directory via ".." traversal or absolute paths. +func validateRelativePath(p string) error { + if filepath.IsAbs(p) { + return errAbsolutePath + } + + cleaned := filepath.Clean(p) + if strings.HasPrefix(cleaned, "..") { + return errPathTraversal + } + + return nil +} diff --git a/internal/command/assist_test.go b/internal/command/assist_test.go index a4e279d..b8def95 100644 --- a/internal/command/assist_test.go +++ b/internal/command/assist_test.go @@ -5,6 +5,7 @@ package command import ( "bytes" + "errors" "os" "path/filepath" "strings" @@ -285,3 +286,36 @@ func TestResolveAIProviderWithKey(t *testing.T) { t.Errorf("expected openai, got %s", p.Name()) } } + +func TestValidateRelativePath(t *testing.T) { + tests := []struct { + name string + path string + wantErr error + }{ + {name: "valid relative", path: "cinzel/assist", wantErr: nil}, + {name: "valid simple", path: "output", wantErr: nil}, + {name: "valid nested", path: "a/b/c", wantErr: nil}, + {name: "absolute path", path: "/etc/secrets", wantErr: errAbsolutePath}, + {name: "parent traversal", path: "../../../etc", wantErr: errPathTraversal}, + {name: "hidden traversal", path: "foo/../../bar", wantErr: errPathTraversal}, + {name: "current dir", path: ".", wantErr: nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateRelativePath(tt.path) + if tt.wantErr == nil && err != nil { + t.Errorf("expected no error, got %v", err) + } + + if tt.wantErr != nil && err == nil { + t.Errorf("expected %v, got nil", tt.wantErr) + } + + if tt.wantErr != nil && err != nil && !errors.Is(err, tt.wantErr) { + t.Errorf("expected %v, got %v", tt.wantErr, err) + } + }) + } +} diff --git a/internal/command/errors.go b/internal/command/errors.go index 48cb3ef..1790135 100644 --- a/internal/command/errors.go +++ b/internal/command/errors.go @@ -6,6 +6,8 @@ package command import "errors" var ( - errCancelled = errors.New("cancelled") + errCancelled = errors.New("cancelled") errPromptRequired = errors.New("--prompt is required (or use --refine to iterate on previous output)") + errAbsolutePath = errors.New("path must be relative to the project directory") + errPathTraversal = errors.New("path must not escape the project directory (no .. traversal)") ) From 90a70527ca2863b7054397cb47ee93f8cb09c99b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Tue, 17 Mar 2026 12:34:41 +0000 Subject: [PATCH 17/48] refactor: move assist and pin to top-level commands --- internal/ai/provider.go | 2 +- internal/command/assist.go | 44 +++- internal/command/command.go | 35 +++- internal/command/pin.go | 88 ++++++++ internal/pin/doc.go | 5 + internal/pin/errors.go | 10 + internal/pin/pin.go | 400 ++++++++++++++++++++++++++++++++++++ internal/pin/pin_test.go | 379 ++++++++++++++++++++++++++++++++++ 8 files changed, 952 insertions(+), 11 deletions(-) create mode 100644 internal/command/pin.go create mode 100644 internal/pin/doc.go create mode 100644 internal/pin/errors.go create mode 100644 internal/pin/pin.go create mode 100644 internal/pin/pin_test.go diff --git a/internal/ai/provider.go b/internal/ai/provider.go index cc2b849..6ec66fa 100644 --- a/internal/ai/provider.go +++ b/internal/ai/provider.go @@ -121,7 +121,7 @@ Generate valid %s YAML based on the user's description. Rules: - Output ONLY valid YAML. No markdown code fences, no explanations, no commentary. -- Use current action versions (tags like @v4, not SHAs). +- For action versions, use the LATEST major version tag (e.g. actions/checkout@v6, actions/setup-go@v5). Versions will be automatically pinned to SHAs after generation. - Set minimum required permissions. - Use environment variables for secrets (e.g. secrets.MY_SECRET), never hardcode values. - Include descriptive step names and IDs. Use consistent names: checkout, setup_go, install_deps, build, test, lint — not step_1, step_2. diff --git a/internal/command/assist.go b/internal/command/assist.go index 67355a8..c58da15 100644 --- a/internal/command/assist.go +++ b/internal/command/assist.go @@ -18,6 +18,7 @@ import ( "github.com/hashicorp/hcl/v2/hclwrite" "github.com/urfave/cli/v3" "github.com/yldio/cinzel/internal/ai" + "github.com/yldio/cinzel/internal/pin" "github.com/yldio/cinzel/provider" ) @@ -26,7 +27,7 @@ const ( maxRawYAMLErrorLen = 500 ) -func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { +func (cmd *Cli) assistCommand() *cli.Command { return &cli.Command{ Name: "assist", Usage: "Generate HCL workflow definitions from a natural language prompt", @@ -38,6 +39,13 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { return errPromptRequired } + providerName := c.String("provider") + + p, err := cmd.resolveProvider(providerName) + if err != nil { + return err + } + outputDir := c.String("output-directory") if outputDir == "" { outputDir = defaultAssistOutputDir @@ -50,10 +58,10 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { dryRun := c.Bool("dry-run") acknowledge := c.Bool("acknowledge") - aiProviderName := c.String("provider") + aiName := c.String("ai") model := c.String("model") - aiProvider, err := resolveAIProvider(aiProviderName, "") + aiProvider, err := resolveAIProvider(aiName, "") if err != nil { return err } @@ -66,7 +74,7 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { _, _ = fmt.Fprintf(cmd.Writer, "Generating workflow...\n") - systemPrompt := ai.SystemPrompt(p.GetProviderName()) + systemPrompt := ai.SystemPrompt(providerName) if !c.Bool("no-context") { contextDir := c.String("context-dir") @@ -119,9 +127,31 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { yamlContent := ai.StripFences(response.Text) - return cmd.unparseAndWrite(p, yamlContent, outputDir, dryRun) + if err := cmd.unparseAndWrite(p, yamlContent, outputDir, dryRun); err != nil { + return err + } + + if providerName == "github" && !dryRun { + _, _ = fmt.Fprintf(cmd.Writer, "Pinning action versions...\n") + + resolver := pin.NewCachedResolver(pin.NewGitHubResolver("")) + + results, pinErr := pin.PinDirectory(ctx, outputDir, resolver, cmd.Writer, false) + if pinErr != nil { + _, _ = fmt.Fprintf(cmd.Writer, "warning: pin failed: %v\n", pinErr) + } else { + cmd.printPinSummary(results) + } + } + + return nil }, Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "provider", + Usage: "CI/CD provider: github or gitlab", + Required: true, + }, &cli.StringFlag{ Name: "prompt", Aliases: []string{"p"}, @@ -147,14 +177,14 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { Usage: "Bypass the cost confirmation prompt", }, &cli.StringFlag{ - Name: "provider", + Name: "ai", Value: "anthropic", Usage: "AI provider: anthropic or openai", }, &cli.StringFlag{ Name: "model", Value: "", - Usage: "Model override (default: provider-specific)", + Usage: "Model override (default: AI provider-specific)", }, &cli.BoolFlag{ Name: "no-context", diff --git a/internal/command/command.go b/internal/command/command.go index bfc32e6..edc3e22 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -21,17 +21,24 @@ const ( // Cli holds the CLI application state including the output writer and root command. type Cli struct { - Writer io.Writer - Cmd *cli.Command + Writer io.Writer + Cmd *cli.Command + providers map[string]provider.Provider } // Execute registers the given providers and runs the CLI with the supplied arguments. func (cmd *Cli) Execute(osArgs []string, providers []provider.Provider) error { + cmd.providers = make(map[string]provider.Provider, len(providers)) + for _, p := range providers { + cmd.providers[p.GetProviderName()] = p ap := cmd.addProvider(p) cmd.Cmd.Commands = append(cmd.Cmd.Commands, ap) } + cmd.Cmd.Commands = append(cmd.Cmd.Commands, cmd.assistCommand()) + cmd.Cmd.Commands = append(cmd.Cmd.Commands, cmd.pinCommand()) + if err := cmd.Cmd.Run(context.Background(), osArgs); err != nil { _, _ = fmt.Fprintf(cmd.Writer, "%s\n", cinzelerror.New(err).Err.Error()) @@ -41,6 +48,29 @@ func (cmd *Cli) Execute(osArgs []string, providers []provider.Provider) error { return nil } +// resolveProvider looks up a CI/CD provider by name. +func (cmd *Cli) resolveProvider(name string) (provider.Provider, error) { + if name == "" { + return nil, fmt.Errorf("--provider is required. Supported: %s", cmd.providerNames()) + } + + p, ok := cmd.providers[name] + if !ok { + return nil, fmt.Errorf("unknown provider %q. Supported: %s", name, cmd.providerNames()) + } + + return p, nil +} + +func (cmd *Cli) providerNames() string { + var names []string + for name := range cmd.providers { + names = append(names, name) + } + + return fmt.Sprintf("%v", names) +} + // New creates a Cli configured with the given writer and version string. func New(writer io.Writer, version string) *Cli { return &Cli{ @@ -181,7 +211,6 @@ func (cmd *Cli) addProvider(p provider.Provider) *cli.Command { }, }, }, - cmd.assistCommand(p), }, } } diff --git a/internal/command/pin.go b/internal/command/pin.go new file mode 100644 index 0000000..cc3a474 --- /dev/null +++ b/internal/command/pin.go @@ -0,0 +1,88 @@ +// Copyright 2026 YLD Limited +// SPDX-License-Identifier: Apache-2.0 + +package command + +import ( + "context" + "fmt" + + "github.com/urfave/cli/v3" + "github.com/yldio/cinzel/internal/pin" +) + +func (cmd *Cli) pinCommand() *cli.Command { + return &cli.Command{ + Name: "pin", + Usage: "Resolve GitHub Actions version tags to commit SHAs (no token required for public actions)", + Action: func(ctx context.Context, c *cli.Command) error { + filePath := c.String("file") + dirPath := c.String("directory") + dryRun := c.Bool("dry-run") + + if filePath == "" && dirPath == "" { + dirPath = "cinzel" + } + + resolver := pin.NewCachedResolver(pin.NewGitHubResolver("")) + + if filePath != "" { + results, err := pin.PinFile(ctx, filePath, resolver, cmd.Writer, dryRun) + if err != nil { + return err + } + + cmd.printPinSummary(results) + + return nil + } + + results, err := pin.PinDirectory(ctx, dirPath, resolver, cmd.Writer, dryRun) + if err != nil { + return err + } + + cmd.printPinSummary(results) + + return nil + }, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "file", + Aliases: []string{"f"}, + Value: "", + Usage: "Pin actions in a single HCL `FILE`", + }, + &cli.StringFlag{ + Name: "directory", + Aliases: []string{"d"}, + Value: "", + Usage: "Pin actions in all HCL files in `DIRECTORY` (default: cinzel)", + }, + &cli.BoolFlag{ + Name: "dry-run", + Value: false, + Usage: "Show what would be pinned without writing files", + }, + }, + } +} + +func (cmd *Cli) printPinSummary(results []pin.PinResult) { + pinned := 0 + skipped := 0 + failed := 0 + + for _, r := range results { + switch { + case r.WasAlready: + skipped++ + case r.Error != nil: + failed++ + default: + pinned++ + } + } + + _, _ = fmt.Fprintf(cmd.Writer, "\nPin summary: %d pinned, %d already pinned, %d failed\n", pinned, skipped, failed) +} diff --git a/internal/pin/doc.go b/internal/pin/doc.go new file mode 100644 index 0000000..823767d --- /dev/null +++ b/internal/pin/doc.go @@ -0,0 +1,5 @@ +// Copyright 2026 YLD Limited +// SPDX-License-Identifier: Apache-2.0 + +// Package pin resolves action version tags to commit SHAs via the GitHub API. +package pin diff --git a/internal/pin/errors.go b/internal/pin/errors.go new file mode 100644 index 0000000..04dd3d6 --- /dev/null +++ b/internal/pin/errors.go @@ -0,0 +1,10 @@ +// Copyright 2026 YLD Limited +// SPDX-License-Identifier: Apache-2.0 + +package pin + +import "errors" + +var ( + errNoHCLFiles = errors.New("no HCL files found in the specified path") +) diff --git a/internal/pin/pin.go b/internal/pin/pin.go new file mode 100644 index 0000000..0eecc35 --- /dev/null +++ b/internal/pin/pin.go @@ -0,0 +1,400 @@ +// Copyright 2026 YLD Limited +// SPDX-License-Identifier: Apache-2.0 + +package pin + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +const ( + cacheTTL = 24 * time.Hour + cacheSubdir = "cinzel/pins" + githubAPIBase = "https://api.github.com" + tokenEnvVar = "GITHUB_TOKEN" +) + +// tagPattern matches version strings that look like tags (v1, v1.2, v1.2.3) +// as opposed to SHAs (40+ hex chars). +var tagPattern = regexp.MustCompile(`^v?\d+(\.\d+)*$`) + +// Resolver resolves action version tags to commit SHAs. +type Resolver interface { + ResolveTag(ctx context.Context, owner, repo, tag string) (string, error) +} + +// GitHubResolver resolves tags via the GitHub API. +type GitHubResolver struct { + token string + client *http.Client +} + +// NewGitHubResolver creates a resolver that uses the GitHub API. +// If token is empty, it falls back to GITHUB_TOKEN env var. +// Unauthenticated requests are limited to 60/hr; authenticated to 5000/hr. +func NewGitHubResolver(token string) *GitHubResolver { + if token == "" { + token = os.Getenv(tokenEnvVar) + } + + return &GitHubResolver{ + token: token, + client: &http.Client{Timeout: 10 * time.Second}, + } +} + +// ResolveTag resolves a tag to a commit SHA via the GitHub API. +func (r *GitHubResolver) ResolveTag(ctx context.Context, owner, repo, tag string) (string, error) { + url := fmt.Sprintf("%s/repos/%s/%s/git/ref/tags/%s", githubAPIBase, owner, repo, tag) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/vnd.github.v3+json") + + if r.token != "" { + req.Header.Set("Authorization", "Bearer "+r.token) + } + + resp, err := r.client.Do(req) + if err != nil { + return "", fmt.Errorf("GitHub API request failed: %w", err) + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub API returned %d for %s/%s@%s", resp.StatusCode, owner, repo, tag) + } + + var ref struct { + Object struct { + SHA string `json:"sha"` + Type string `json:"type"` + } `json:"object"` + } + + if err := json.NewDecoder(resp.Body).Decode(&ref); err != nil { + return "", fmt.Errorf("failed to decode GitHub API response: %w", err) + } + + // If the ref points to a tag object (annotated tag), dereference to the commit. + if ref.Object.Type == "tag" { + return r.dereferenceTag(ctx, owner, repo, ref.Object.SHA) + } + + return ref.Object.SHA, nil +} + +func (r *GitHubResolver) dereferenceTag(ctx context.Context, owner, repo, tagSHA string) (string, error) { + url := fmt.Sprintf("%s/repos/%s/%s/git/tags/%s", githubAPIBase, owner, repo, tagSHA) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/vnd.github.v3+json") + + if r.token != "" { + req.Header.Set("Authorization", "Bearer "+r.token) + } + + resp, err := r.client.Do(req) + if err != nil { + return "", fmt.Errorf("GitHub API request failed: %w", err) + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub API returned %d dereferencing tag %s", resp.StatusCode, tagSHA) + } + + var tag struct { + Object struct { + SHA string `json:"sha"` + } `json:"object"` + } + + if err := json.NewDecoder(resp.Body).Decode(&tag); err != nil { + return "", fmt.Errorf("failed to decode tag response: %w", err) + } + + return tag.Object.SHA, nil +} + +// CachedResolver wraps a Resolver with a file-based cache. +type CachedResolver struct { + inner Resolver + cacheDir string +} + +// NewCachedResolver creates a resolver that caches results for 24 hours. +func NewCachedResolver(inner Resolver) *CachedResolver { + cacheDir, _ := os.UserCacheDir() + + return &CachedResolver{ + inner: inner, + cacheDir: filepath.Join(cacheDir, cacheSubdir), + } +} + +// ResolveTag checks the cache first, then falls back to the inner resolver. +func (r *CachedResolver) ResolveTag(ctx context.Context, owner, repo, tag string) (string, error) { + key := cacheKey(owner, repo, tag) + cachePath := filepath.Join(r.cacheDir, key) + + if sha, ok := r.readCache(cachePath); ok { + return sha, nil + } + + sha, err := r.inner.ResolveTag(ctx, owner, repo, tag) + if err != nil { + return "", err + } + + r.writeCache(cachePath, sha) + + return sha, nil +} + +func cacheKey(owner, repo, tag string) string { + h := sha256.Sum256([]byte(fmt.Sprintf("%s/%s@%s", owner, repo, tag))) + + return fmt.Sprintf("%x", h[:16]) +} + +func (r *CachedResolver) readCache(path string) (string, bool) { + info, err := os.Stat(path) + if err != nil { + return "", false + } + + if time.Since(info.ModTime()) > cacheTTL { + return "", false + } + + data, err := os.ReadFile(path) + if err != nil { + return "", false + } + + sha := strings.TrimSpace(string(data)) + if sha == "" { + return "", false + } + + return sha, true +} + +func (r *CachedResolver) writeCache(path, sha string) { + _ = os.MkdirAll(r.cacheDir, 0700) + _ = os.WriteFile(path, []byte(sha), 0600) +} + +// ActionRef represents an action reference found in an HCL file. +type ActionRef struct { + Action string // e.g., "actions/checkout" + Version string // e.g., "v4" or "abc123..." + IsTag bool // true if Version looks like a tag, not a SHA +} + +// IsTag returns true if the version looks like a tag rather than a SHA. +func isTag(version string) bool { + return tagPattern.MatchString(version) +} + +// PinResult holds the result of pinning a single action. +type PinResult struct { + Action string + Tag string + SHA string + Error error + WasAlready bool // true if version was already a SHA +} + +// PinFile reads an HCL file, resolves all tag-based action versions to SHAs, +// and writes the updated file. When dryRun is true, resolutions are reported +// but the file is not modified. +func PinFile(ctx context.Context, path string, resolver Resolver, w io.Writer, dryRun bool) ([]PinResult, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", path, err) + } + + refs := findActionRefs(string(content)) + if len(refs) == 0 { + return nil, nil + } + + var results []PinResult + + updated := string(content) + + for _, ref := range refs { + if !ref.IsTag { + results = append(results, PinResult{ + Action: ref.Action, + SHA: ref.Version, + WasAlready: true, + }) + + continue + } + + parts := strings.SplitN(ref.Action, "/", 2) + if len(parts) != 2 { + results = append(results, PinResult{ + Action: ref.Action, + Tag: ref.Version, + Error: fmt.Errorf("invalid action format: %s", ref.Action), + }) + + continue + } + + sha, err := resolver.ResolveTag(ctx, parts[0], parts[1], ref.Version) + if err != nil { + _, _ = fmt.Fprintf(w, "warning: could not pin %s@%s: %v\n", ref.Action, ref.Version, err) + + results = append(results, PinResult{ + Action: ref.Action, + Tag: ref.Version, + Error: err, + }) + + continue + } + + // Replace version value in the HCL content. + oldLine := fmt.Sprintf(`version = %q`, ref.Version) + newLine := fmt.Sprintf(`version = %q`, sha) + updated = strings.Replace(updated, oldLine, newLine, 1) + + // Add or update the comment above the uses block. + commentLine := fmt.Sprintf("// %s %s", ref.Action, ref.Version) + usesLine := fmt.Sprintf(`action = %q`, ref.Action) + + if idx := strings.Index(updated, usesLine); idx > 0 { + // Find the uses block opening before this action line. + beforeAction := updated[:idx] + usesIdx := strings.LastIndex(beforeAction, "uses {") + + if usesIdx >= 0 { + beforeUses := updated[:usesIdx] + afterUses := updated[usesIdx:] + + // Check if there's already a comment on the line before uses. + lines := strings.Split(beforeUses, "\n") + lastLine := strings.TrimSpace(lines[len(lines)-1]) + + if lastLine == "" && len(lines) >= 2 { + prevLine := strings.TrimSpace(lines[len(lines)-2]) + if strings.HasPrefix(prevLine, "//") { + // Replace existing comment. + indent := lines[len(lines)-2][:len(lines[len(lines)-2])-len(strings.TrimLeft(lines[len(lines)-2], " \t"))] + lines[len(lines)-2] = indent + commentLine + updated = strings.Join(lines, "\n") + afterUses + } + } + } + } + + _, _ = fmt.Fprintf(w, "pinned %s@%s → %s\n", ref.Action, ref.Version, sha[:12]) + + results = append(results, PinResult{ + Action: ref.Action, + Tag: ref.Version, + SHA: sha, + }) + } + + if !dryRun && updated != string(content) { + if err := os.WriteFile(path, []byte(updated), 0644); err != nil { + return results, fmt.Errorf("failed to write %s: %w", path, err) + } + } + + return results, nil +} + +// PinDirectory pins all HCL files in a directory. +func PinDirectory(ctx context.Context, dir string, resolver Resolver, w io.Writer, dryRun bool) ([]PinResult, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("failed to read directory %s: %w", dir, err) + } + + var allResults []PinResult + + found := false + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".hcl") { + continue + } + + found = true + path := filepath.Join(dir, entry.Name()) + + results, err := PinFile(ctx, path, resolver, w, dryRun) + if err != nil { + _, _ = fmt.Fprintf(w, "warning: %s: %v\n", entry.Name(), err) + + continue + } + + allResults = append(allResults, results...) + } + + if !found { + return nil, errNoHCLFiles + } + + return allResults, nil +} + +// findActionRefs extracts action references from HCL content by looking for +// uses blocks containing action and version attributes. +func findActionRefs(content string) []ActionRef { + // Match action = "owner/repo" followed by version = "tag-or-sha" + // within uses blocks. Uses a simple regex approach since the HCL + // structure is well-defined from cinzel's own output. + actionPattern := regexp.MustCompile(`action\s*=\s*"([^"]+)"`) + versionPattern := regexp.MustCompile(`version\s*=\s*"([^"]+)"`) + + actionMatches := actionPattern.FindAllStringSubmatchIndex(content, -1) + versionMatches := versionPattern.FindAllStringSubmatchIndex(content, -1) + + if len(actionMatches) != len(versionMatches) { + return nil + } + + var refs []ActionRef + + for i, am := range actionMatches { + action := content[am[2]:am[3]] + version := content[versionMatches[i][2]:versionMatches[i][3]] + + refs = append(refs, ActionRef{ + Action: action, + Version: version, + IsTag: isTag(version), + }) + } + + return refs +} diff --git a/internal/pin/pin_test.go b/internal/pin/pin_test.go new file mode 100644 index 0000000..090bd65 --- /dev/null +++ b/internal/pin/pin_test.go @@ -0,0 +1,379 @@ +// Copyright 2026 YLD Limited +// SPDX-License-Identifier: Apache-2.0 + +package pin + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +type mockResolver struct { + shas map[string]string +} + +func (m *mockResolver) ResolveTag(_ context.Context, owner, repo, tag string) (string, error) { + key := fmt.Sprintf("%s/%s@%s", owner, repo, tag) + + if sha, ok := m.shas[key]; ok { + return sha, nil + } + + return "", fmt.Errorf("tag not found: %s", key) +} + +func TestFindActionRefs(t *testing.T) { + content := `step "checkout" { + uses { + action = "actions/checkout" + version = "v4" + } +} + +step "setup" { + uses { + action = "actions/setup-go" + version = "v5" + } +} + +step "pinned" { + uses { + action = "actions/checkout" + version = "de0fac2e4500dabe0009e67214ff5f5447ce83dd" + } +}` + + refs := findActionRefs(content) + + if len(refs) != 3 { + t.Fatalf("expected 3 refs, got %d", len(refs)) + } + + if refs[0].Action != "actions/checkout" || refs[0].Version != "v4" || !refs[0].IsTag { + t.Errorf("ref[0]: got %+v", refs[0]) + } + + if refs[1].Action != "actions/setup-go" || refs[1].Version != "v5" || !refs[1].IsTag { + t.Errorf("ref[1]: got %+v", refs[1]) + } + + if refs[2].IsTag { + t.Error("ref[2] should not be a tag (it's a SHA)") + } +} + +func TestIsTag(t *testing.T) { + tests := []struct { + version string + want bool + }{ + {"v4", true}, + {"v1.2.3", true}, + {"v5.0", true}, + {"1.2.3", true}, + {"de0fac2e4500dabe0009e67214ff5f5447ce83dd", false}, + {"abc123", false}, + {"latest", false}, + {"main", false}, + } + + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + if got := isTag(tt.version); got != tt.want { + t.Errorf("isTag(%q) = %v, want %v", tt.version, got, tt.want) + } + }) + } +} + +func TestPinFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "steps.hcl") + + content := `step "checkout" { + // actions/checkout v3 + uses { + action = "actions/checkout" + version = "v4" + } +} + +step "setup" { + uses { + action = "actions/setup-go" + version = "v5" + } +} +` + + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + resolver := &mockResolver{ + shas: map[string]string{ + "actions/checkout@v4": "abc123def456abc123def456abc123def456abc1", + "actions/setup-go@v5": "def456abc123def456abc123def456abc123def4", + }, + } + + var buf bytes.Buffer + + results, err := PinFile(context.Background(), path, resolver, &buf, false) + if err != nil { + t.Fatal(err) + } + + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + + for _, r := range results { + if r.Error != nil { + t.Errorf("unexpected error for %s: %v", r.Action, r.Error) + } + + if r.SHA == "" { + t.Errorf("expected SHA for %s", r.Action) + } + } + + // Verify file was updated. + updated, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + if strings.Contains(string(updated), `"v4"`) { + t.Error("v4 tag should have been replaced") + } + + if !strings.Contains(string(updated), `"abc123def456abc123def456abc123def456abc1"`) { + t.Error("expected checkout SHA in output") + } + + if !strings.Contains(string(updated), `"def456abc123def456abc123def456abc123def4"`) { + t.Error("expected setup-go SHA in output") + } + + // Verify output messages. + output := buf.String() + + if !strings.Contains(output, "pinned actions/checkout@v4") { + t.Error("expected pin message for checkout") + } + + if !strings.Contains(output, "pinned actions/setup-go@v5") { + t.Error("expected pin message for setup-go") + } +} + +func TestPinFileAlreadyPinned(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "steps.hcl") + + content := `step "checkout" { + uses { + action = "actions/checkout" + version = "de0fac2e4500dabe0009e67214ff5f5447ce83dd" + } +} +` + + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + resolver := &mockResolver{shas: map[string]string{}} + + var buf bytes.Buffer + + results, err := PinFile(context.Background(), path, resolver, &buf, false) + if err != nil { + t.Fatal(err) + } + + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + + if !results[0].WasAlready { + t.Error("expected WasAlready to be true") + } +} + +func TestPinFileResolveFails(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "steps.hcl") + + content := `step "checkout" { + uses { + action = "private/action" + version = "v1" + } +} +` + + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + resolver := &mockResolver{shas: map[string]string{}} + + var buf bytes.Buffer + + results, err := PinFile(context.Background(), path, resolver, &buf, false) + if err != nil { + t.Fatal(err) + } + + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + + if results[0].Error == nil { + t.Error("expected error for unresolvable tag") + } + + // File should remain unchanged. + updated, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(string(updated), `"v1"`) { + t.Error("version should remain as tag when resolution fails") + } + + if !strings.Contains(buf.String(), "warning") { + t.Error("expected warning in output") + } +} + +func TestPinDirectory(t *testing.T) { + dir := t.TempDir() + + file1 := `step "checkout" { + uses { + action = "actions/checkout" + version = "v4" + } +}` + + file2 := `step "setup" { + uses { + action = "actions/setup-go" + version = "v5" + } +}` + + if err := os.WriteFile(filepath.Join(dir, "a.hcl"), []byte(file1), 0644); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(dir, "b.hcl"), []byte(file2), 0644); err != nil { + t.Fatal(err) + } + + // Non-HCL file should be ignored. + if err := os.WriteFile(filepath.Join(dir, "readme.md"), []byte("# Docs"), 0644); err != nil { + t.Fatal(err) + } + + resolver := &mockResolver{ + shas: map[string]string{ + "actions/checkout@v4": "sha1sha1sha1sha1sha1sha1sha1sha1sha1sha1", + "actions/setup-go@v5": "sha2sha2sha2sha2sha2sha2sha2sha2sha2sha2", + }, + } + + var buf bytes.Buffer + + results, err := PinDirectory(context.Background(), dir, resolver, &buf, false) + if err != nil { + t.Fatal(err) + } + + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } +} + +func TestPinFileDryRun(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "steps.hcl") + + content := `step "checkout" { + uses { + action = "actions/checkout" + version = "v4" + } +} +` + + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + resolver := &mockResolver{ + shas: map[string]string{ + "actions/checkout@v4": "abc123def456abc123def456abc123def456abc1", + }, + } + + var buf bytes.Buffer + + results, err := PinFile(context.Background(), path, resolver, &buf, true) + if err != nil { + t.Fatal(err) + } + + if len(results) != 1 || results[0].SHA == "" { + t.Fatal("expected 1 resolved result") + } + + // File should NOT be modified in dry-run mode. + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(string(after), `"v4"`) { + t.Error("dry-run should not modify the file") + } +} + +func TestPinDirectoryNoHCL(t *testing.T) { + dir := t.TempDir() + + if err := os.WriteFile(filepath.Join(dir, "readme.md"), []byte("# Docs"), 0644); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + + _, err := PinDirectory(context.Background(), dir, &mockResolver{}, &buf, false) + if err == nil { + t.Error("expected error for directory with no HCL files") + } +} + +func TestCacheKey(t *testing.T) { + key1 := cacheKey("actions", "checkout", "v4") + key2 := cacheKey("actions", "checkout", "v5") + key3 := cacheKey("actions", "checkout", "v4") + + if key1 == key2 { + t.Error("different tags should produce different keys") + } + + if key1 != key3 { + t.Error("same inputs should produce same key") + } +} From 4cf5af602166afce5370d9027ee2aa481d8a5008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Tue, 17 Mar 2026 12:39:50 +0000 Subject: [PATCH 18/48] docs: cancel Phase 4 retry loop in favor of --refine --- ...-03-16-feat-cinzel-assist-ai-workflow-generation-plan.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-03-16-feat-cinzel-assist-ai-workflow-generation-plan.md b/docs/plans/2026-03-16-feat-cinzel-assist-ai-workflow-generation-plan.md index bc255a3..5f167d7 100644 --- a/docs/plans/2026-03-16-feat-cinzel-assist-ai-workflow-generation-plan.md +++ b/docs/plans/2026-03-16-feat-cinzel-assist-ai-workflow-generation-plan.md @@ -234,11 +234,9 @@ existing HCL → strip string literals → build prompt → LLM → YAML → str - `internal/pin/pin.go` (NEW) - `internal/command/command.go` (MODIFY — register pin command) -#### Phase 4: Retry loop + hardening — DEFERRED +#### Phase 4: Retry loop + hardening — CANCELLED -**Goal**: Improve success rate for complex prompts. Ship only if failure rates warrant it. - -**Prerequisite**: Phase 1 shipped, real failure data collected. +**Cancelled**: `--refine` provides the same capability with user control. Automated retry is uncontrolled, costs 3x on failure, and the user can't steer the correction. With `--refine`, the user sees the error, decides what to change, and gives specific instructions — better outcome at lower cost. **Tasks**: - Implement retry loop in assist: From bad85f950a09595a6900bd312cb3ff64b65a33d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Tue, 17 Mar 2026 12:42:57 +0000 Subject: [PATCH 19/48] fix: add path validation to pin command and fix doc comment --- internal/command/pin.go | 12 ++++++++++++ internal/pin/pin.go | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/internal/command/pin.go b/internal/command/pin.go index cc3a474..e884b04 100644 --- a/internal/command/pin.go +++ b/internal/command/pin.go @@ -24,6 +24,18 @@ func (cmd *Cli) pinCommand() *cli.Command { dirPath = "cinzel" } + if filePath != "" { + if err := validateRelativePath(filePath); err != nil { + return fmt.Errorf("--file: %w", err) + } + } + + if dirPath != "" { + if err := validateRelativePath(dirPath); err != nil { + return fmt.Errorf("--directory: %w", err) + } + } + resolver := pin.NewCachedResolver(pin.NewGitHubResolver("")) if filePath != "" { diff --git a/internal/pin/pin.go b/internal/pin/pin.go index 0eecc35..9ed9e79 100644 --- a/internal/pin/pin.go +++ b/internal/pin/pin.go @@ -212,7 +212,7 @@ type ActionRef struct { IsTag bool // true if Version looks like a tag, not a SHA } -// IsTag returns true if the version looks like a tag rather than a SHA. +// isTag returns true if the version looks like a tag rather than a SHA. func isTag(version string) bool { return tagPattern.MatchString(version) } From b81be68bcb8ae0b2d6e4f48b587bd93f90bd5408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Tue, 17 Mar 2026 13:20:46 +0000 Subject: [PATCH 20/48] fix: correct comment indentation in pin output --- internal/pin/pin.go | 79 ++++++++++++++++++++++++------------ internal/pin/pin_test.go | 88 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 27 deletions(-) diff --git a/internal/pin/pin.go b/internal/pin/pin.go index 9ed9e79..0975568 100644 --- a/internal/pin/pin.go +++ b/internal/pin/pin.go @@ -285,33 +285,7 @@ func PinFile(ctx context.Context, path string, resolver Resolver, w io.Writer, d updated = strings.Replace(updated, oldLine, newLine, 1) // Add or update the comment above the uses block. - commentLine := fmt.Sprintf("// %s %s", ref.Action, ref.Version) - usesLine := fmt.Sprintf(`action = %q`, ref.Action) - - if idx := strings.Index(updated, usesLine); idx > 0 { - // Find the uses block opening before this action line. - beforeAction := updated[:idx] - usesIdx := strings.LastIndex(beforeAction, "uses {") - - if usesIdx >= 0 { - beforeUses := updated[:usesIdx] - afterUses := updated[usesIdx:] - - // Check if there's already a comment on the line before uses. - lines := strings.Split(beforeUses, "\n") - lastLine := strings.TrimSpace(lines[len(lines)-1]) - - if lastLine == "" && len(lines) >= 2 { - prevLine := strings.TrimSpace(lines[len(lines)-2]) - if strings.HasPrefix(prevLine, "//") { - // Replace existing comment. - indent := lines[len(lines)-2][:len(lines[len(lines)-2])-len(strings.TrimLeft(lines[len(lines)-2], " \t"))] - lines[len(lines)-2] = indent + commentLine - updated = strings.Join(lines, "\n") + afterUses - } - } - } - } + updated = upsertUsesComment(updated, ref.Action, ref.Version) _, _ = fmt.Fprintf(w, "pinned %s@%s → %s\n", ref.Action, ref.Version, sha[:12]) @@ -398,3 +372,54 @@ func findActionRefs(content string) []ActionRef { return refs } + +// upsertUsesComment adds or updates the comment line above a uses block +// to document the original action and tag. For example: +// +// // actions/checkout v4 +// uses { +// action = "actions/checkout" +// version = "abc123..." +// } +func upsertUsesComment(content, action, tag string) string { + comment := fmt.Sprintf("// %s %s", action, tag) + actionLine := fmt.Sprintf(`action = %q`, action) + + idx := strings.Index(content, actionLine) + if idx <= 0 { + return content + } + + beforeAction := content[:idx] + usesIdx := strings.LastIndex(beforeAction, "uses {") + + if usesIdx < 0 { + return content + } + + // Find the indent by looking at what's before "uses {" on its line. + lineStart := strings.LastIndex(content[:usesIdx], "\n") + 1 + indent := content[lineStart:usesIdx] + + beforeUses := content[:lineStart] + afterUses := content[lineStart:] + lines := strings.Split(beforeUses, "\n") + + // Check if the line before uses (skipping blank line) is already a comment. + lastIdx := len(lines) - 1 + + if lastIdx >= 0 && strings.TrimSpace(lines[lastIdx]) == "" { + lastIdx-- + } + + if lastIdx >= 0 && strings.HasPrefix(strings.TrimSpace(lines[lastIdx]), "//") { + // Update existing comment, preserving its indent. + existingIndent := lines[lastIdx][:len(lines[lastIdx])-len(strings.TrimLeft(lines[lastIdx], " \t"))] + lines[lastIdx] = existingIndent + comment + + return strings.Join(lines, "\n") + afterUses + } + + // No existing comment — insert one before uses, same indent. + return beforeUses + indent + comment + "\n" + afterUses +} diff --git a/internal/pin/pin_test.go b/internal/pin/pin_test.go index 090bd65..2cb7402 100644 --- a/internal/pin/pin_test.go +++ b/internal/pin/pin_test.go @@ -256,6 +256,94 @@ func TestPinFileResolveFails(t *testing.T) { } } +func TestPinFileAddsCommentWhenMissing(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "steps.hcl") + + content := `step "setup" { + uses { + action = "actions/setup-go" + version = "v5" + } +} +` + + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + resolver := &mockResolver{ + shas: map[string]string{ + "actions/setup-go@v5": "def456abc123def456abc123def456abc123def4", + }, + } + + var buf bytes.Buffer + + _, err := PinFile(context.Background(), path, resolver, &buf, false) + if err != nil { + t.Fatal(err) + } + + updated, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(string(updated), "// actions/setup-go v5") { + t.Errorf("expected comment to be added\ngot:\n%s", string(updated)) + } + + // Verify indent: comment should have same indent as "uses {" + if !strings.Contains(string(updated), " // actions/setup-go v5\n uses {") { + t.Errorf("comment should have same indent as uses block\ngot:\n%s", string(updated)) + } +} + +func TestPinFileUpdatesExistingComment(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "steps.hcl") + + content := `step "checkout" { + // actions/checkout v3 + uses { + action = "actions/checkout" + version = "v4" + } +} +` + + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + resolver := &mockResolver{ + shas: map[string]string{ + "actions/checkout@v4": "abc123def456abc123def456abc123def456abc1", + }, + } + + var buf bytes.Buffer + + _, err := PinFile(context.Background(), path, resolver, &buf, false) + if err != nil { + t.Fatal(err) + } + + updated, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(string(updated), "// actions/checkout v4") { + t.Errorf("expected comment to be updated to v4\ngot:\n%s", string(updated)) + } + + if strings.Contains(string(updated), "// actions/checkout v3") { + t.Error("old comment v3 should have been replaced") + } +} + func TestPinDirectory(t *testing.T) { dir := t.TempDir() From f3493d3ff812b82a0c3b9a6e20da4c2ced8478e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Tue, 17 Mar 2026 13:27:04 +0000 Subject: [PATCH 21/48] feat: add upgrade command to bump actions to latest versions --- internal/command/command.go | 1 + internal/command/upgrade.go | 100 ++++++++++++++++++++++ internal/pin/pin.go | 42 +++++++++ internal/pin/upgrade.go | 154 +++++++++++++++++++++++++++++++++ internal/pin/upgrade_test.go | 161 +++++++++++++++++++++++++++++++++++ 5 files changed, 458 insertions(+) create mode 100644 internal/command/upgrade.go create mode 100644 internal/pin/upgrade.go create mode 100644 internal/pin/upgrade_test.go diff --git a/internal/command/command.go b/internal/command/command.go index edc3e22..39d14ee 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -38,6 +38,7 @@ func (cmd *Cli) Execute(osArgs []string, providers []provider.Provider) error { cmd.Cmd.Commands = append(cmd.Cmd.Commands, cmd.assistCommand()) cmd.Cmd.Commands = append(cmd.Cmd.Commands, cmd.pinCommand()) + cmd.Cmd.Commands = append(cmd.Cmd.Commands, cmd.upgradeCommand()) if err := cmd.Cmd.Run(context.Background(), osArgs); err != nil { _, _ = fmt.Fprintf(cmd.Writer, "%s\n", cinzelerror.New(err).Err.Error()) diff --git a/internal/command/upgrade.go b/internal/command/upgrade.go new file mode 100644 index 0000000..2b600be --- /dev/null +++ b/internal/command/upgrade.go @@ -0,0 +1,100 @@ +// Copyright 2026 YLD Limited +// SPDX-License-Identifier: Apache-2.0 + +package command + +import ( + "context" + "fmt" + + "github.com/urfave/cli/v3" + "github.com/yldio/cinzel/internal/pin" +) + +func (cmd *Cli) upgradeCommand() *cli.Command { + return &cli.Command{ + Name: "upgrade", + Usage: "Upgrade GitHub Actions to their latest versions and pin to SHAs", + Action: func(ctx context.Context, c *cli.Command) error { + filePath := c.String("file") + dirPath := c.String("directory") + dryRun := c.Bool("dry-run") + + if filePath == "" && dirPath == "" { + dirPath = "cinzel" + } + + if filePath != "" { + if err := validateRelativePath(filePath); err != nil { + return fmt.Errorf("--file: %w", err) + } + } + + if dirPath != "" { + if err := validateRelativePath(dirPath); err != nil { + return fmt.Errorf("--directory: %w", err) + } + } + + resolver := pin.NewGitHubResolver("") + + if filePath != "" { + results, err := pin.UpgradeFile(ctx, filePath, resolver, cmd.Writer, dryRun) + if err != nil { + return err + } + + cmd.printUpgradeSummary(results) + + return nil + } + + results, err := pin.UpgradeDirectory(ctx, dirPath, resolver, cmd.Writer, dryRun) + if err != nil { + return err + } + + cmd.printUpgradeSummary(results) + + return nil + }, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "file", + Aliases: []string{"f"}, + Value: "", + Usage: "Upgrade actions in a single HCL `FILE`", + }, + &cli.StringFlag{ + Name: "directory", + Aliases: []string{"d"}, + Value: "", + Usage: "Upgrade actions in all HCL files in `DIRECTORY` (default: cinzel)", + }, + &cli.BoolFlag{ + Name: "dry-run", + Value: false, + Usage: "Show what would be upgraded without writing files", + }, + }, + } +} + +func (cmd *Cli) printUpgradeSummary(results []pin.UpgradeResult) { + upgraded := 0 + current := 0 + failed := 0 + + for _, r := range results { + switch { + case r.WasCurrent: + current++ + case r.Error != nil: + failed++ + default: + upgraded++ + } + } + + _, _ = fmt.Fprintf(cmd.Writer, "\nUpgrade summary: %d upgraded, %d already current, %d failed\n", upgraded, current, failed) +} diff --git a/internal/pin/pin.go b/internal/pin/pin.go index 0975568..712e72a 100644 --- a/internal/pin/pin.go +++ b/internal/pin/pin.go @@ -136,6 +136,48 @@ func (r *GitHubResolver) dereferenceTag(ctx context.Context, owner, repo, tagSHA return tag.Object.SHA, nil } +// LatestTag returns the latest semver tag for a repository by listing tags +// sorted by version descending. +func (r *GitHubResolver) LatestTag(ctx context.Context, owner, repo string) (string, error) { + url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", githubAPIBase, owner, repo) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/vnd.github.v3+json") + + if r.token != "" { + req.Header.Set("Authorization", "Bearer "+r.token) + } + + resp, err := r.client.Do(req) + if err != nil { + return "", fmt.Errorf("GitHub API request failed: %w", err) + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub API returned %d for %s/%s latest release", resp.StatusCode, owner, repo) + } + + var release struct { + TagName string `json:"tag_name"` + } + + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", fmt.Errorf("failed to decode release response: %w", err) + } + + if release.TagName == "" { + return "", fmt.Errorf("no releases found for %s/%s", owner, repo) + } + + return release.TagName, nil +} + // CachedResolver wraps a Resolver with a file-based cache. type CachedResolver struct { inner Resolver diff --git a/internal/pin/upgrade.go b/internal/pin/upgrade.go new file mode 100644 index 0000000..15a7523 --- /dev/null +++ b/internal/pin/upgrade.go @@ -0,0 +1,154 @@ +// Copyright 2026 YLD Limited +// SPDX-License-Identifier: Apache-2.0 + +package pin + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// UpgradeResult holds the result of upgrading a single action. +type UpgradeResult struct { + Action string + OldVersion string + NewTag string + NewSHA string + Error error + WasCurrent bool // true if already on the latest version +} + +// UpgradeFile reads an HCL file, checks each action for a newer release, +// and updates both the version and comment. When dryRun is true, changes +// are reported but the file is not modified. +func UpgradeFile(ctx context.Context, path string, resolver *GitHubResolver, w io.Writer, dryRun bool) ([]UpgradeResult, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", path, err) + } + + refs := findActionRefs(string(content)) + if len(refs) == 0 { + return nil, nil + } + + var results []UpgradeResult + + updated := string(content) + + for _, ref := range refs { + parts := strings.SplitN(ref.Action, "/", 2) + if len(parts) != 2 { + results = append(results, UpgradeResult{ + Action: ref.Action, + Error: fmt.Errorf("invalid action format: %s", ref.Action), + }) + + continue + } + + latestTag, err := resolver.LatestTag(ctx, parts[0], parts[1]) + if err != nil { + _, _ = fmt.Fprintf(w, "warning: could not find latest version for %s: %v\n", ref.Action, err) + + results = append(results, UpgradeResult{ + Action: ref.Action, + OldVersion: ref.Version, + Error: err, + }) + + continue + } + + // Check if already on the latest major version. + if ref.IsTag && ref.Version == latestTag { + results = append(results, UpgradeResult{ + Action: ref.Action, + OldVersion: ref.Version, + WasCurrent: true, + }) + + continue + } + + // Resolve the latest tag to a SHA. + sha, err := resolver.ResolveTag(ctx, parts[0], parts[1], latestTag) + if err != nil { + _, _ = fmt.Fprintf(w, "warning: could not pin %s@%s: %v\n", ref.Action, latestTag, err) + + results = append(results, UpgradeResult{ + Action: ref.Action, + OldVersion: ref.Version, + NewTag: latestTag, + Error: err, + }) + + continue + } + + // Replace version value. + oldLine := fmt.Sprintf(`version = %q`, ref.Version) + newLine := fmt.Sprintf(`version = %q`, sha) + updated = strings.Replace(updated, oldLine, newLine, 1) + + // Add or update comment. + updated = upsertUsesComment(updated, ref.Action, latestTag) + + _, _ = fmt.Fprintf(w, "upgraded %s: %s → %s (%s)\n", ref.Action, ref.Version, latestTag, sha[:12]) + + results = append(results, UpgradeResult{ + Action: ref.Action, + OldVersion: ref.Version, + NewTag: latestTag, + NewSHA: sha, + }) + } + + if !dryRun && updated != string(content) { + if err := os.WriteFile(path, []byte(updated), 0644); err != nil { + return results, fmt.Errorf("failed to write %s: %w", path, err) + } + } + + return results, nil +} + +// UpgradeDirectory upgrades all HCL files in a directory. +func UpgradeDirectory(ctx context.Context, dir string, resolver *GitHubResolver, w io.Writer, dryRun bool) ([]UpgradeResult, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("failed to read directory %s: %w", dir, err) + } + + var allResults []UpgradeResult + + found := false + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".hcl") { + continue + } + + found = true + path := filepath.Join(dir, entry.Name()) + + results, err := UpgradeFile(ctx, path, resolver, w, dryRun) + if err != nil { + _, _ = fmt.Fprintf(w, "warning: %s: %v\n", entry.Name(), err) + + continue + } + + allResults = append(allResults, results...) + } + + if !found { + return nil, errNoHCLFiles + } + + return allResults, nil +} diff --git a/internal/pin/upgrade_test.go b/internal/pin/upgrade_test.go new file mode 100644 index 0000000..d7e8177 --- /dev/null +++ b/internal/pin/upgrade_test.go @@ -0,0 +1,161 @@ +// Copyright 2026 YLD Limited +// SPDX-License-Identifier: Apache-2.0 + +package pin + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +type mockGitHubResolver struct { + latestTags map[string]string // "owner/repo" → latest tag + shas map[string]string // "owner/repo@tag" → sha +} + +func (m *mockGitHubResolver) resolveTag(owner, repo, tag string) (string, error) { + key := fmt.Sprintf("%s/%s@%s", owner, repo, tag) + + if sha, ok := m.shas[key]; ok { + return sha, nil + } + + return "", fmt.Errorf("tag not found: %s", key) +} + +func (m *mockGitHubResolver) latestTag(owner, repo string) (string, error) { + key := fmt.Sprintf("%s/%s", owner, repo) + + if tag, ok := m.latestTags[key]; ok { + return tag, nil + } + + return "", fmt.Errorf("no releases for %s", key) +} + +// We need to test with real GitHubResolver methods, so let's test +// the upgrade logic via UpgradeFile with a real resolver that we mock +// at the HTTP level. For unit tests, we'll test the helper functions directly. + +func TestUpgradeFileIntegration(t *testing.T) { + // This test uses the real UpgradeFile but with a mock resolver + // that would require HTTP mocking. For now, test the upgrade + // logic indirectly via the building blocks. + t.Skip("requires HTTP mock — covered by e2e tests") +} + +func TestFindActionRefsForUpgrade(t *testing.T) { + content := `step "checkout" { + // actions/checkout v4 + uses { + action = "actions/checkout" + version = "de0fac2e4500dabe0009e67214ff5f5447ce83dd" + } +} + +step "setup" { + uses { + action = "actions/setup-go" + version = "v4" + } +}` + + refs := findActionRefs(content) + + if len(refs) != 2 { + t.Fatalf("expected 2 refs, got %d", len(refs)) + } + + // First ref is SHA-pinned + if refs[0].IsTag { + t.Error("SHA version should not be detected as tag") + } + + // Second ref is a tag + if !refs[1].IsTag || refs[1].Version != "v4" { + t.Errorf("expected tag v4, got %+v", refs[1]) + } +} + +func TestUpgradeFileDryRun(t *testing.T) { + // Create a test file and verify dry-run doesn't modify it. + // Uses a custom GitHubResolver subclass would be needed for full test, + // but we can verify the file-level behavior with the real function + // by making LatestTag fail (no network). + dir := t.TempDir() + path := filepath.Join(dir, "steps.hcl") + + content := `step "checkout" { + uses { + action = "actions/checkout" + version = "v4" + } +} +` + + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + // This will fail because there's no HTTP mock, but it exercises the code path. + resolver := NewGitHubResolver("") + + var buf bytes.Buffer + + // This will produce warnings (API calls fail) but shouldn't panic. + _, _ = UpgradeFile(context.Background(), path, resolver, &buf, true) + + // Verify file unchanged in dry-run. + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + if string(after) != content { + t.Error("dry-run should not modify the file") + } +} + +func TestUpgradeDirectoryNoHCL(t *testing.T) { + dir := t.TempDir() + + if err := os.WriteFile(filepath.Join(dir, "readme.md"), []byte("# Docs"), 0644); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + + resolver := NewGitHubResolver("") + + _, err := UpgradeDirectory(context.Background(), dir, resolver, &buf, false) + if err == nil { + t.Error("expected error for directory with no HCL files") + } +} + +func TestUpsertUsesCommentForUpgrade(t *testing.T) { + // Verify that upsertUsesComment works when upgrading from v4 to v6. + content := `step "checkout" { + // actions/checkout v4 + uses { + action = "actions/checkout" + version = "old-sha" + } +} +` + + updated := upsertUsesComment(content, "actions/checkout", "v6") + + if !strings.Contains(updated, "// actions/checkout v6") { + t.Errorf("expected comment updated to v6\ngot:\n%s", updated) + } + + if strings.Contains(updated, "// actions/checkout v4") { + t.Error("old v4 comment should be replaced") + } +} From 4ee3a078e69e5ced25a7ce22505b9b325c045960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Tue, 17 Mar 2026 15:49:49 +0000 Subject: [PATCH 22/48] fix: remove redundant --provider from upgrade --- internal/command/upgrade.go | 69 ++++++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/internal/command/upgrade.go b/internal/command/upgrade.go index 2b600be..57860f1 100644 --- a/internal/command/upgrade.go +++ b/internal/command/upgrade.go @@ -6,9 +6,11 @@ package command import ( "context" "fmt" + "path/filepath" "github.com/urfave/cli/v3" "github.com/yldio/cinzel/internal/pin" + "github.com/yldio/cinzel/provider" ) func (cmd *Cli) upgradeCommand() *cli.Command { @@ -19,6 +21,7 @@ func (cmd *Cli) upgradeCommand() *cli.Command { filePath := c.String("file") dirPath := c.String("directory") dryRun := c.Bool("dry-run") + parse := c.Bool("parse") if filePath == "" && dirPath == "" { dirPath = "cinzel" @@ -38,25 +41,48 @@ func (cmd *Cli) upgradeCommand() *cli.Command { resolver := pin.NewGitHubResolver("") - if filePath != "" { - results, err := pin.UpgradeFile(ctx, filePath, resolver, cmd.Writer, dryRun) - if err != nil { - return err - } + var results []pin.UpgradeResult - cmd.printUpgradeSummary(results) + var err error - return nil + if filePath != "" { + results, err = pin.UpgradeFile(ctx, filePath, resolver, cmd.Writer, dryRun) + } else { + results, err = pin.UpgradeDirectory(ctx, dirPath, resolver, cmd.Writer, dryRun) } - results, err := pin.UpgradeDirectory(ctx, dirPath, resolver, cmd.Writer, dryRun) if err != nil { return err } cmd.printUpgradeSummary(results) - return nil + if !parse || dryRun { + return nil + } + + // Check if anything was actually upgraded. + upgraded := false + + for _, r := range results { + if r.Error == nil && !r.WasCurrent { + upgraded = true + + break + } + } + + if !upgraded { + return nil + } + + parseDir := dirPath + if filePath != "" { + // If a single file was upgraded, parse its parent directory. + parseDir = filepath.Dir(filePath) + } + + return cmd.runParseGitHub(parseDir, c.String("output-directory")) }, Flags: []cli.Flag{ &cli.StringFlag{ @@ -76,10 +102,35 @@ func (cmd *Cli) upgradeCommand() *cli.Command { Value: false, Usage: "Show what would be upgraded without writing files", }, + &cli.BoolFlag{ + Name: "parse", + Value: false, + Usage: "Regenerate GitHub Actions YAML files after upgrading", + }, + &cli.StringFlag{ + Name: "output-directory", + Value: "", + Usage: "Output directory for parsed YAML (default: .github/workflows)", + }, }, } } +func (cmd *Cli) runParseGitHub(inputDir, outputDir string) error { + p, err := cmd.resolveProvider("github") + if err != nil { + return err + } + + _, _ = fmt.Fprintf(cmd.Writer, "\nRegenerating YAML...\n") + + return p.Parse(provider.ProviderOps{ + Directory: inputDir, + OutputDirectory: outputDir, + DryRun: false, + }) +} + func (cmd *Cli) printUpgradeSummary(results []pin.UpgradeResult) { upgraded := 0 current := 0 From 148375cecbfc4693802d77e5441a77cb3a83f066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Tue, 17 Mar 2026 15:52:40 +0000 Subject: [PATCH 23/48] refactor: move pin and upgrade under github subcommand --- internal/command/command.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/internal/command/command.go b/internal/command/command.go index 39d14ee..3301e10 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -37,8 +37,6 @@ func (cmd *Cli) Execute(osArgs []string, providers []provider.Provider) error { } cmd.Cmd.Commands = append(cmd.Cmd.Commands, cmd.assistCommand()) - cmd.Cmd.Commands = append(cmd.Cmd.Commands, cmd.pinCommand()) - cmd.Cmd.Commands = append(cmd.Cmd.Commands, cmd.upgradeCommand()) if err := cmd.Cmd.Run(context.Background(), osArgs); err != nil { _, _ = fmt.Fprintf(cmd.Writer, "%s\n", cinzelerror.New(err).Err.Error()) @@ -108,7 +106,7 @@ func formattedAuthors(authors []mail.Address) []any { } func (cmd *Cli) addProvider(p provider.Provider) *cli.Command { - return &cli.Command{ + providerCmd := &cli.Command{ Name: p.GetProviderName(), Usage: p.GetDescription(), Commands: []*cli.Command{ @@ -214,4 +212,11 @@ func (cmd *Cli) addProvider(p provider.Provider) *cli.Command { }, }, } + + if p.GetProviderName() == "github" { + providerCmd.Commands = append(providerCmd.Commands, cmd.pinCommand()) + providerCmd.Commands = append(providerCmd.Commands, cmd.upgradeCommand()) + } + + return providerCmd } From 1455f734c75afc2a55e974db63933174ad1b6d11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Tue, 17 Mar 2026 16:00:15 +0000 Subject: [PATCH 24/48] fix: upgrade detects already-current SHA-pinned versions --- internal/command/upgrade.go | 11 ++++++++++- internal/pin/upgrade.go | 20 ++++++++++---------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/internal/command/upgrade.go b/internal/command/upgrade.go index 57860f1..2882d5b 100644 --- a/internal/command/upgrade.go +++ b/internal/command/upgrade.go @@ -39,6 +39,8 @@ func (cmd *Cli) upgradeCommand() *cli.Command { } } + // No cache wrapper — upgrade checks latest releases which should not + // be served from a 24h cache. resolver := pin.NewGitHubResolver("") var results []pin.UpgradeResult @@ -82,7 +84,14 @@ func (cmd *Cli) upgradeCommand() *cli.Command { parseDir = filepath.Dir(filePath) } - return cmd.runParseGitHub(parseDir, c.String("output-directory")) + outputDir := c.String("output-directory") + if outputDir != "" { + if err := validateRelativePath(outputDir); err != nil { + return fmt.Errorf("--output-directory: %w", err) + } + } + + return cmd.runParseGitHub(parseDir, outputDir) }, Flags: []cli.Flag{ &cli.StringFlag{ diff --git a/internal/pin/upgrade.go b/internal/pin/upgrade.go index 15a7523..1b5d6f8 100644 --- a/internal/pin/upgrade.go +++ b/internal/pin/upgrade.go @@ -64,27 +64,27 @@ func UpgradeFile(ctx context.Context, path string, resolver *GitHubResolver, w i continue } - // Check if already on the latest major version. - if ref.IsTag && ref.Version == latestTag { + // Resolve the latest tag to a SHA. + sha, err := resolver.ResolveTag(ctx, parts[0], parts[1], latestTag) + if err != nil { + _, _ = fmt.Fprintf(w, "warning: could not pin %s@%s: %v\n", ref.Action, latestTag, err) + results = append(results, UpgradeResult{ Action: ref.Action, OldVersion: ref.Version, - WasCurrent: true, + NewTag: latestTag, + Error: err, }) continue } - // Resolve the latest tag to a SHA. - sha, err := resolver.ResolveTag(ctx, parts[0], parts[1], latestTag) - if err != nil { - _, _ = fmt.Fprintf(w, "warning: could not pin %s@%s: %v\n", ref.Action, latestTag, err) - + // Already on the latest version — compare by tag or SHA. + if (ref.IsTag && ref.Version == latestTag) || (!ref.IsTag && ref.Version == sha) { results = append(results, UpgradeResult{ Action: ref.Action, OldVersion: ref.Version, - NewTag: latestTag, - Error: err, + WasCurrent: true, }) continue From 52301ba304cc11b30d41825307255714f693f124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Tue, 17 Mar 2026 16:07:08 +0000 Subject: [PATCH 25/48] fix: show GITHUB_TOKEN hint on rate limit errors --- internal/pin/errors.go | 35 +++++++++++++++++++++++++++++++---- internal/pin/pin.go | 6 +++--- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/internal/pin/errors.go b/internal/pin/errors.go index 04dd3d6..0e14b89 100644 --- a/internal/pin/errors.go +++ b/internal/pin/errors.go @@ -3,8 +3,35 @@ package pin -import "errors" - -var ( - errNoHCLFiles = errors.New("no HCL files found in the specified path") +import ( + "errors" + "fmt" + "net/http" ) + +var errNoHCLFiles = errors.New("no HCL files found in the specified path") + +const tokenHint = "Set GITHUB_TOKEN for authenticated requests (5000/hr vs 60/hr unauthenticated):\n export GITHUB_TOKEN=ghp_..." + +// classifyGitHubError returns a user-friendly error for GitHub API failures. +func classifyGitHubError(statusCode int, resource string, unauthenticated bool) error { + switch statusCode { + case http.StatusForbidden: + if unauthenticated { + return fmt.Errorf("GitHub API rate limit exceeded for %s.\n\n%s", resource, tokenHint) + } + + return fmt.Errorf("GitHub API rate limit exceeded for %s. Try again later", resource) + case http.StatusNotFound: + return fmt.Errorf("GitHub API returned 404 for %s (action may be private or not exist)", resource) + case http.StatusUnauthorized: + return fmt.Errorf("GitHub API authentication failed. Check your GITHUB_TOKEN is valid") + default: + msg := fmt.Sprintf("GitHub API returned %d for %s", statusCode, resource) + if unauthenticated && (statusCode == http.StatusTooManyRequests) { + msg += "\n\n" + tokenHint + } + + return fmt.Errorf("%s", msg) + } +} diff --git a/internal/pin/pin.go b/internal/pin/pin.go index 712e72a..f0ee103 100644 --- a/internal/pin/pin.go +++ b/internal/pin/pin.go @@ -76,7 +76,7 @@ func (r *GitHubResolver) ResolveTag(ctx context.Context, owner, repo, tag string defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("GitHub API returned %d for %s/%s@%s", resp.StatusCode, owner, repo, tag) + return "", classifyGitHubError(resp.StatusCode, fmt.Sprintf("%s/%s@%s", owner, repo, tag), r.token == "") } var ref struct { @@ -120,7 +120,7 @@ func (r *GitHubResolver) dereferenceTag(ctx context.Context, owner, repo, tagSHA defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("GitHub API returned %d dereferencing tag %s", resp.StatusCode, tagSHA) + return "", classifyGitHubError(resp.StatusCode, "tag/"+tagSHA, r.token == "") } var tag struct { @@ -160,7 +160,7 @@ func (r *GitHubResolver) LatestTag(ctx context.Context, owner, repo string) (str defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("GitHub API returned %d for %s/%s latest release", resp.StatusCode, owner, repo) + return "", classifyGitHubError(resp.StatusCode, fmt.Sprintf("%s/%s latest release", owner, repo), r.token == "") } var release struct { From 92a583c6482b336dc340a637fa04c023d6ef7eca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Tue, 17 Mar 2026 16:07:22 +0000 Subject: [PATCH 26/48] chore: upgrade versions --- .github/workflows/pull-request.yaml | 2 +- .github/workflows/release-published.yaml | 4 ++-- .github/workflows/release.yaml | 6 +++--- cinzel/steps.hcl | 12 ++++++------ 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index d00a7dd..550999d 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -18,7 +18,7 @@ jobs: persist-credentials: "false" - id: mise_setup name: Setup mise - uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db + uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 with: cache: "true" install: "true" diff --git a/.github/workflows/release-published.yaml b/.github/workflows/release-published.yaml index 49fafdf..445aa49 100644 --- a/.github/workflows/release-published.yaml +++ b/.github/workflows/release-published.yaml @@ -32,7 +32,7 @@ jobs: fi - id: release_app_token name: Create release app token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: "${{ secrets.RELEASE_APP_ID }}" private-key: "${{ secrets.RELEASE_PRIVATE_KEY }}" @@ -45,7 +45,7 @@ jobs: persist-credentials: "false" - id: mise_setup name: Setup mise - uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db + uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 with: cache: "true" install: "true" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index f8a9303..65b75f3 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -34,7 +34,7 @@ jobs: fi - id: release_app_token name: Create release app token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: "${{ secrets.RELEASE_APP_ID }}" private-key: "${{ secrets.RELEASE_PRIVATE_KEY }}" @@ -48,7 +48,7 @@ jobs: persist-credentials: "true" - id: mise_setup name: Setup mise - uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db + uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 with: cache: "true" install: "true" @@ -117,7 +117,7 @@ jobs: file_pattern: CHANGELOG.md - id: create_release name: Create a GitHub release - uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b + uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd with: body: "${{ steps.git_cliff.outputs.content }}" tag: "${{ steps.tag_version.outputs.new_tag }}" diff --git a/cinzel/steps.hcl b/cinzel/steps.hcl index 8d1da5f..eddf655 100644 --- a/cinzel/steps.hcl +++ b/cinzel/steps.hcl @@ -65,10 +65,10 @@ step "release_app_token" { id = "release_app_token" name = "Create release app token" - // actions/create-github-app-token v2.2.1 + // actions/create-github-app-token v3.0.0 uses { action = "actions/create-github-app-token" - version = "29824e69f54612133e76f7eaac726eef6c875baf" + version = "f8d387b68d61c58ab83c6c016672934102569859" } with { @@ -90,10 +90,10 @@ step "release_app_token" { step "mise_setup" { name = "Setup mise" - // jdx/mise-action v3.6.3 + // jdx/mise-action v4.0.0 uses { action = "jdx/mise-action" - version = "5228313ee0372e111a38da051671ca30fc5a96db" + version = "c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4" } with { @@ -179,10 +179,10 @@ step "create_release" { id = "create_release" name = "Create a GitHub release" - // ncipollo/release-action v1.20.0 + // ncipollo/release-action v1.21.0 uses { action = "ncipollo/release-action" - version = "b7eabc95ff50cbeeedec83973935c8f306dfcd0b" + version = "339a81892b84b4eeb0f6e744e4574d79d0d9b8dd" } with { From ba91693d9b48dbf525f41d712d09def64eb6d288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Tue, 17 Mar 2026 16:13:46 +0000 Subject: [PATCH 27/48] feat: add assist, pin, and upgrade commands --- ...sist-pin-upgrade-feature-implementation.md | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 docs/solutions/patterns/assist-pin-upgrade-feature-implementation.md diff --git a/docs/solutions/patterns/assist-pin-upgrade-feature-implementation.md b/docs/solutions/patterns/assist-pin-upgrade-feature-implementation.md new file mode 100644 index 0000000..b4a1ca4 --- /dev/null +++ b/docs/solutions/patterns/assist-pin-upgrade-feature-implementation.md @@ -0,0 +1,125 @@ +--- +title: "feat: cinzel assist, pin, and upgrade — AI-powered workflow generation" +date: 2026-03-17 +category: patterns +tags: [ai, llm, cli, hcl, yaml, github-actions, anthropic, openai, pin, upgrade, architecture] +components: + - internal/ai + - internal/command + - internal/pin +origin: docs/plans/2026-03-16-feat-cinzel-assist-ai-workflow-generation-plan.md +--- + +# cinzel assist, pin, and upgrade + +Three new commands added to cinzel for AI-powered workflow generation and GitHub Actions version management. + +## Commands + +```sh +cinzel assist --provider github --prompt "golang PR with tests" # AI generates HCL +cinzel github pin # lock tags to SHAs +cinzel github upgrade # bump to latest + pin +cinzel github upgrade --parse # bump + regenerate YAML +``` + +## Architecture: YAML-then-unparse pipeline + +The LLM generates standard CI/CD YAML (which it knows from training data), then cinzel's existing `Unparse` converts to HCL. This avoids teaching the LLM cinzel's custom HCL schema. + +``` +prompt → LLM → YAML → strip fences → split docs → temp files → Unparse → merge/dedup HCL → single output +``` + +### Key design decisions + +1. **Temp file approach** — no `Provider` interface changes. Write LLM YAML to temp files, call existing `Unparse(ProviderOps{Directory: tmpDir})`. ~5 lines of code vs adding `UnparseBytes` to every provider. + +2. **HCL block merging** — multi-workflow prompts produce separate YAML documents. Each is unparsed independently, then `mergeHCLFiles` uses `hclwrite.ParseConfig` (AST-based, not brace counting) to split and deduplicate identical blocks. Shared steps appear once. + +3. **System prompt for step reuse** — instructs the LLM to use identical step names/IDs across workflows so dedup works. + +4. **Top-level vs nested** — `assist` is top-level (provider-agnostic, takes `--provider` flag). `pin` and `upgrade` are under `cinzel github` (GitHub-specific operations). + +## Privacy: string literal stripping + +Instead of a redaction/restoration engine, `StripHCLContext` walks the HCL AST and replaces ALL string literal values with `"..."`. Comments are dropped by the parser. Block labels preserved (accepted residual risk — comparable to function names). + +The LLM sees structural skeleton only: +```hcl +step "checkout" { + name = "..." + uses { + action = "..." + version = "..." + } +} +``` + +Context capped at 8000 tokens with newline-boundary truncation. + +## Pin: tag-to-SHA resolution + +`Resolver` interface with `GitHubResolver` (GitHub API) and `CachedResolver` (file-based, 24h TTL in `os.UserCacheDir()/cinzel/pins/`). + +- Handles annotated tags (dereferences to commit SHA) +- Adds/updates `// actions/checkout v4` comment above `uses` blocks +- Fallback on failure: warning + keep unpinned tag +- `GITHUB_TOKEN` env var for authenticated requests (5000/hr vs 60/hr) + +## Upgrade: latest version lookup + +Uses GitHub releases API (`/repos/.../releases/latest`). Compares by tag OR SHA — detects already-current versions even when SHA-pinned. No cache (must check live). Optional `--parse` regenerates YAML after upgrading. + +## AI provider interface + +```go +type Provider interface { + Generate(ctx context.Context, req GenerateRequest) (GenerateResponse, error) + Name() string +} +``` + +Two implementations: `Anthropic` and `OpenAI`. Shared `resolveAPIKey` helper. Error classification distinguishes auth/quota/rate-limit/timeout with actionable messages. + +`GenerateResponse` includes `InputTokens`/`OutputTokens` — displayed after every generation. + +## Review findings addressed + +Three rounds of parallel reviews (architecture, security, simplicity, performance, pattern recognition) produced these fixes: + +| Finding | Fix | +|---------|-----| +| Brace-counting HCL splitter fragile | Replaced with `hclwrite.ParseConfig` AST | +| Regex compiled per call | Hoisted to package-level `var` | +| Sentinel errors scattered | Consolidated into `errors.go` per package | +| Constructor duplication | Shared `resolveAPIKey` helper | +| Attribute ordering non-deterministic | Sorted map keys | +| Byte truncation mid-rune | `truncateAtNewline` cuts at last newline | +| Raw YAML in errors could leak data | Truncated to 500 chars | +| Path traversal on `--context-dir` | `validateRelativePath` blocks absolute + `..` | +| Dead `splitHCLBlocks` wrapper | Removed | +| 6 untested functions | Tests added for all | + +## File inventory + +| Package | File | Purpose | +|---------|------|---------| +| `internal/ai` | `provider.go` | Interface, error classification, StripFences, SystemPrompt | +| `internal/ai` | `anthropic.go` | Anthropic SDK wrapper | +| `internal/ai` | `openai.go` | OpenAI SDK wrapper | +| `internal/ai` | `strip.go` | HCL string stripping for privacy | +| `internal/ai` | `errors.go` | Sentinel errors | +| `internal/command` | `assist.go` | Assist pipeline, mergeHCLFiles, YAML splitting | +| `internal/command` | `pin.go` | Pin CLI command | +| `internal/command` | `upgrade.go` | Upgrade CLI command with --parse | +| `internal/command` | `errors.go` | CLI sentinel errors | +| `internal/pin` | `pin.go` | Resolver, GitHubResolver, CachedResolver, PinFile | +| `internal/pin` | `upgrade.go` | UpgradeFile, UpgradeDirectory, LatestTag | +| `internal/pin` | `errors.go` | GitHub API error classification with token hints | + +## Related + +- [Brainstorm](../../brainstorms/2026-03-16-feat-cinzel-assist-ai-workflow-generation.md) +- [Plan](../../plans/2026-03-16-feat-cinzel-assist-ai-workflow-generation-plan.md) +- [git-cliff release notes fix](../integration-issues/git-cliff-release-notes-wrong-changelog.md) From ca7de4878f28f60c218dabb58936304530b865e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Tue, 17 Mar 2026 16:21:55 +0000 Subject: [PATCH 28/48] docs: update README, CLAUDE.md, and doc.go for assist/pin/upgrade --- CLAUDE.md | 33 +++++++++++++++++++++++++++++++ README.md | 44 +++++++++++++++++++++++++++++++++++++++++ internal/command/doc.go | 5 +++-- 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a84c63f..60826bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,21 @@ mise run license-check # verify license headers cinzel.go # CLI entrypoint, wires providers internal/ command/ # CLI framework (urfave/cli) + command.go # provider registration, parse/unparse commands + assist.go # assist command, LLM pipeline, HCL merge/dedup + pin.go # pin command (GitHub Actions) + upgrade.go # upgrade command (GitHub Actions) + errors.go # CLI sentinel errors + ai/ # LLM integration + provider.go # AI provider interface, error classification, fence stripping + anthropic.go # Anthropic SDK client + openai.go # OpenAI SDK client + strip.go # HCL string stripping for privacy + errors.go # AI sentinel errors + pin/ # GitHub Actions version management + pin.go # Resolver interface, SHA resolution, caching, comment upsert + upgrade.go # latest version lookup via GitHub releases API + errors.go # GitHub API error classification filereader/ # reads HCL/YAML from disk filewriter/ # writes output files hclparser/ # HCL expression evaluator @@ -91,6 +106,24 @@ provider/ - Decoder-native diagnostics are acceptable and preferred. Tests should assert stable substrings from strict decoder errors instead of custom-normalized wording. - When adding fields, update typed structs first, then conversion logic, then fixtures/tests. Never add schema keys in validation-only tables. +### AI-assisted generation (`cinzel assist`) + +- **Pipeline**: prompt → LLM → YAML → strip fences → split YAML docs → temp files → `Unparse` → merge/dedup HCL blocks → single timestamped output. +- **No Provider interface changes** — uses temp files and existing `Unparse(ProviderOps{Directory})`. +- **Privacy**: `StripHCLContext` walks HCL AST, replaces all string literal values with `"..."`, strips comments. Block labels preserved (accepted residual risk). +- **HCL dedup**: `mergeHCLFiles` uses `hclwrite.ParseConfig` (AST-based) to split blocks, dedup by exact match. Handles braces in strings/heredocs correctly. +- **Auto-pin**: GitHub assist output is automatically pinned to SHAs after generation. +- **AI providers**: Anthropic (default) and OpenAI via `--ai` flag. Keys from env vars only. +- **`--refine`**: reads previous `assist/` output as additional context for iterative generation. + +### Version management (`cinzel github pin/upgrade`) + +- **Pin**: resolves action tags to SHAs via GitHub API. `Resolver` interface with `GitHubResolver` + `CachedResolver` (24h TTL file cache in `os.UserCacheDir()/cinzel/pins/`). +- **Upgrade**: finds latest release via GitHub API, compares by tag or SHA, updates version + comment. +- **Comment management**: `upsertUsesComment` adds `// actions/checkout v4` above `uses` blocks, or updates existing comments. +- **No token required** for public actions. `GITHUB_TOKEN` env var for higher rate limits. +- **Path validation**: `validateRelativePath` blocks absolute paths and `..` traversal on all user-supplied paths. + ### YAML output - Uses `gopkg.in/yaml.v3` node-level marshalling for precise control. diff --git a/README.md b/README.md index 3678eb5..bedad1d 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,50 @@ cinzel gitlab unparse --file ./.gitlab-ci.yml --output-directory ./cinzel Use `--dry-run` to print generated content to stdout. +### AI-assisted generation + +Generate HCL workflow definitions from a natural language prompt: + +```sh +cinzel assist --provider github --prompt "golang PR with tests and linting" +``` + +This calls an LLM (Anthropic by default), generates valid YAML, converts it to HCL via the unparse pipeline, and writes a single deduplicated file to `./cinzel/assist/`. For GitHub providers, action versions are automatically pinned to SHAs. + +Requires an API key: + +```sh +export ANTHROPIC_API_KEY=sk-ant-... +# or +export OPENAI_API_KEY=sk-... +cinzel assist --provider github --ai openai --prompt "..." +``` + +Refine previous output: + +```sh +cinzel assist --provider github --refine "add slack notification on failure" --prompt "add to PR workflow" +``` + +### Version management (GitHub Actions) + +Pin action tags to commit SHAs: + +```sh +cinzel github pin # pin all actions in ./cinzel/ +cinzel github pin --dry-run # preview without writing +``` + +Upgrade actions to their latest versions: + +```sh +cinzel github upgrade # bump to latest + pin SHAs +cinzel github upgrade --dry-run # preview changes +cinzel github upgrade --parse # bump + regenerate YAML +``` + +No GitHub token is required for public actions. Set `GITHUB_TOKEN` for higher rate limits (5000/hr vs 60/hr). + For release operator details about Homebrew automation, see [`docs/release/homebrew.md`](docs/release/homebrew.md). ## Providers diff --git a/internal/command/doc.go b/internal/command/doc.go index a9cc30c..bcca5e4 100644 --- a/internal/command/doc.go +++ b/internal/command/doc.go @@ -1,6 +1,7 @@ // Copyright 2026 YLD Limited // SPDX-License-Identifier: Apache-2.0 // Package command implements the cinzel CLI using urfave/cli. It registers -// provider subcommands (parse/unparse) and translates CLI flags into provider -// option structs. +// provider subcommands (parse/unparse), the top-level assist command for +// AI-powered workflow generation, and GitHub-specific pin/upgrade commands +// for action version management. package command From 5bfa50d39fd6624a2bb6c70e153776d9b18a164f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Tue, 17 Mar 2026 19:04:04 +0000 Subject: [PATCH 29/48] fix: grant contents write for coverage git notes push --- .github/workflows/pull-request.yaml | 2 +- cinzel/workflows.hcl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index 550999d..5619c6d 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -4,7 +4,7 @@ name: Pull Request on: pull_request: permissions: - contents: read + contents: write pull-requests: write jobs: pull_request: diff --git a/cinzel/workflows.hcl b/cinzel/workflows.hcl index 3094c2e..cecf575 100644 --- a/cinzel/workflows.hcl +++ b/cinzel/workflows.hcl @@ -7,7 +7,7 @@ workflow "pull_request" { name = "Pull Request" permissions { - contents = "read" + contents = "write" pull_requests = "write" } From 9d7c1a9d5c370a2695ab3dbcddeddc513a0c9466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Tue, 17 Mar 2026 19:22:09 +0000 Subject: [PATCH 30/48] fix: persist checkout credentials for coverage notes push --- .github/workflows/pull-request.yaml | 2 -- cinzel/steps.hcl | 5 ----- 2 files changed, 7 deletions(-) diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index 5619c6d..a2bea7e 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -14,8 +14,6 @@ jobs: - id: checkout name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - persist-credentials: "false" - id: mise_setup name: Setup mise uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 diff --git a/cinzel/steps.hcl b/cinzel/steps.hcl index eddf655..b1924c9 100644 --- a/cinzel/steps.hcl +++ b/cinzel/steps.hcl @@ -9,11 +9,6 @@ step "checkout" { action = "actions/checkout" version = "de0fac2e4500dabe0009e67214ff5f5447ce83dd" } - - with { - name = "persist-credentials" - value = "false" - } } step "checkout_release" { From 65fd1ca6f8420a9ab34e7c37224a1690d082b074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Tue, 17 Mar 2026 20:02:23 +0000 Subject: [PATCH 31/48] fix: use filepath.Join in tests for Windows compatibility --- internal/command/config_test.go | 6 +++--- provider/gitlab/gitlab_test.go | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/command/config_test.go b/internal/command/config_test.go index acc270c..3b545ea 100644 --- a/internal/command/config_test.go +++ b/internal/command/config_test.go @@ -262,7 +262,7 @@ workflow "ci" { } }) - want := "# file: .github/workflows/ci.yaml" + want := "# file: " + filepath.Join(".github", "workflows", "ci.yaml") if !strings.Contains(stdout, want) { t.Fatalf("stdout = %q, want to contain %q", stdout, want) @@ -307,7 +307,7 @@ workflow "ci" { } }) - want := "# file: custom/ci.yaml" + want := "# file: " + filepath.Join("custom", "ci.yaml") if !strings.Contains(stdout, want) { t.Fatalf("stdout = %q, want to contain %q", stdout, want) @@ -357,7 +357,7 @@ workflow "ci" { } }) - want := "# file: .github/workflows/ci.yaml" + want := "# file: " + filepath.Join(".github", "workflows", "ci.yaml") if !strings.Contains(stdout, want) { t.Fatalf("stdout = %q, want to contain %q", stdout, want) diff --git a/provider/gitlab/gitlab_test.go b/provider/gitlab/gitlab_test.go index 4e34e4b..74c3ae8 100644 --- a/provider/gitlab/gitlab_test.go +++ b/provider/gitlab/gitlab_test.go @@ -362,8 +362,9 @@ build: } }) - if !strings.Contains(out, "# file: cinzel/pipeline.hcl") { - t.Fatalf("expected dry-run output path, got %q", out) + expectedPath := filepath.Join("cinzel", "pipeline.hcl") + if !strings.Contains(out, "# file: "+expectedPath) { + t.Fatalf("expected dry-run output path containing %q, got %q", expectedPath, out) } } From dfc5b549a8823d606daaa8dcee7a5d1b346b2521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Tue, 17 Mar 2026 20:21:27 +0000 Subject: [PATCH 32/48] fix: normalize line endings in snapshot tests for Windows --- provider/github/snapshot_format_test.go | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/provider/github/snapshot_format_test.go b/provider/github/snapshot_format_test.go index d59e925..318a8b1 100644 --- a/provider/github/snapshot_format_test.go +++ b/provider/github/snapshot_format_test.go @@ -6,6 +6,7 @@ package github import ( "os" "path/filepath" + "strings" "testing" "github.com/yldio/cinzel/provider" @@ -53,8 +54,11 @@ jobs: t.Fatal(err) } - if string(gotBytes) != string(expectedBytes) { - t.Fatalf("snapshot mismatch\n--- got ---\n%s\n--- expected ---\n%s", string(gotBytes), string(expectedBytes)) + got := normalizeLineEndings(string(gotBytes)) + expected := normalizeLineEndings(string(expectedBytes)) + + if got != expected { + t.Fatalf("snapshot mismatch\n--- got ---\n%s\n--- expected ---\n%s", got, expected) } } @@ -97,9 +101,16 @@ func TestParseFormattingSnapshots(t *testing.T) { t.Fatal(err) } - if string(gotBytes) != string(expectedBytes) { - t.Fatalf("snapshot mismatch\n--- got ---\n%s\n--- expected ---\n%s", string(gotBytes), string(expectedBytes)) + got := normalizeLineEndings(string(gotBytes)) + expected := normalizeLineEndings(string(expectedBytes)) + + if got != expected { + t.Fatalf("snapshot mismatch\n--- got ---\n%s\n--- expected ---\n%s", got, expected) } }) } } + +func normalizeLineEndings(s string) string { + return strings.ReplaceAll(s, "\r\n", "\n") +} From 5fae550a3835e78b13a8a1629ec4749b7c308fce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Tue, 17 Mar 2026 20:26:08 +0000 Subject: [PATCH 33/48] fix: cross-platform test fixes for Windows paths and error messages --- internal/command/assist_test.go | 8 +++++++- internal/filereader/filereader_test.go | 12 ++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/internal/command/assist_test.go b/internal/command/assist_test.go index b8def95..38bd095 100644 --- a/internal/command/assist_test.go +++ b/internal/command/assist_test.go @@ -8,6 +8,7 @@ import ( "errors" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -288,6 +289,11 @@ func TestResolveAIProviderWithKey(t *testing.T) { } func TestValidateRelativePath(t *testing.T) { + absPath := "/etc/secrets" + if runtime.GOOS == "windows" { + absPath = `C:\Windows\System32` + } + tests := []struct { name string path string @@ -296,7 +302,7 @@ func TestValidateRelativePath(t *testing.T) { {name: "valid relative", path: "cinzel/assist", wantErr: nil}, {name: "valid simple", path: "output", wantErr: nil}, {name: "valid nested", path: "a/b/c", wantErr: nil}, - {name: "absolute path", path: "/etc/secrets", wantErr: errAbsolutePath}, + {name: "absolute path", path: absPath, wantErr: errAbsolutePath}, {name: "parent traversal", path: "../../../etc", wantErr: errPathTraversal}, {name: "hidden traversal", path: "foo/../../bar", wantErr: errPathTraversal}, {name: "current dir", path: ".", wantErr: nil}, diff --git a/internal/filereader/filereader_test.go b/internal/filereader/filereader_test.go index 61ca2db..5e66929 100644 --- a/internal/filereader/filereader_test.go +++ b/internal/filereader/filereader_test.go @@ -78,14 +78,12 @@ func TestFilereader(t *testing.T) { t.Run("can't read non existing HCL file", func(t *testing.T) { filePath := "somewhere" - message := "stat somewhere: no such file or directory" - fileReader := New[test.HclBody]() hclBody, err := fileReader.FromHCL(filePath, false) - if err.Error() != message { - t.Fatal(err.Error()) + if err == nil { + t.Fatal("expected error for non-existing file") } if hclBody != nil { @@ -131,14 +129,12 @@ func TestFilereader(t *testing.T) { t.Run("can't read non existing YAML file", func(t *testing.T) { filePath := "somewhere" - message := "stat somewhere: no such file or directory" - fileReader := New[test.YamlBody]() yamlBody, err := fileReader.FromYaml(filePath, false) - if err.Error() != message { - t.Fatal(err.Error()) + if err == nil { + t.Fatal("expected error for non-existing file") } if yamlBody != nil { From 32a512fd7cbfaaf8f0524fe9531997e972713aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Tue, 17 Mar 2026 20:36:57 +0000 Subject: [PATCH 34/48] refactor: move assist under provider subcommands --- internal/command/assist.go | 18 +++--------------- internal/command/command.go | 4 ++-- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/internal/command/assist.go b/internal/command/assist.go index c58da15..98a5535 100644 --- a/internal/command/assist.go +++ b/internal/command/assist.go @@ -27,7 +27,7 @@ const ( maxRawYAMLErrorLen = 500 ) -func (cmd *Cli) assistCommand() *cli.Command { +func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { return &cli.Command{ Name: "assist", Usage: "Generate HCL workflow definitions from a natural language prompt", @@ -39,13 +39,6 @@ func (cmd *Cli) assistCommand() *cli.Command { return errPromptRequired } - providerName := c.String("provider") - - p, err := cmd.resolveProvider(providerName) - if err != nil { - return err - } - outputDir := c.String("output-directory") if outputDir == "" { outputDir = defaultAssistOutputDir @@ -74,7 +67,7 @@ func (cmd *Cli) assistCommand() *cli.Command { _, _ = fmt.Fprintf(cmd.Writer, "Generating workflow...\n") - systemPrompt := ai.SystemPrompt(providerName) + systemPrompt := ai.SystemPrompt(p.GetProviderName()) if !c.Bool("no-context") { contextDir := c.String("context-dir") @@ -131,7 +124,7 @@ func (cmd *Cli) assistCommand() *cli.Command { return err } - if providerName == "github" && !dryRun { + if p.GetProviderName() == "github" && !dryRun { _, _ = fmt.Fprintf(cmd.Writer, "Pinning action versions...\n") resolver := pin.NewCachedResolver(pin.NewGitHubResolver("")) @@ -147,11 +140,6 @@ func (cmd *Cli) assistCommand() *cli.Command { return nil }, Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "provider", - Usage: "CI/CD provider: github or gitlab", - Required: true, - }, &cli.StringFlag{ Name: "prompt", Aliases: []string{"p"}, diff --git a/internal/command/command.go b/internal/command/command.go index 3301e10..90e484b 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -36,8 +36,6 @@ func (cmd *Cli) Execute(osArgs []string, providers []provider.Provider) error { cmd.Cmd.Commands = append(cmd.Cmd.Commands, ap) } - cmd.Cmd.Commands = append(cmd.Cmd.Commands, cmd.assistCommand()) - if err := cmd.Cmd.Run(context.Background(), osArgs); err != nil { _, _ = fmt.Fprintf(cmd.Writer, "%s\n", cinzelerror.New(err).Err.Error()) @@ -213,6 +211,8 @@ func (cmd *Cli) addProvider(p provider.Provider) *cli.Command { }, } + providerCmd.Commands = append(providerCmd.Commands, cmd.assistCommand(p)) + if p.GetProviderName() == "github" { providerCmd.Commands = append(providerCmd.Commands, cmd.pinCommand()) providerCmd.Commands = append(providerCmd.Commands, cmd.upgradeCommand()) From fe75e85d441e7c1133c395b1e5e289f2206ab8e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Wed, 18 Mar 2026 00:44:32 +0000 Subject: [PATCH 35/48] feat: deduplicate assist output against existing HCL blocks --- internal/command/assist.go | 113 +++++++++++++++++++++++++++++++- internal/command/assist_test.go | 100 ++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 2 deletions(-) diff --git a/internal/command/assist.go b/internal/command/assist.go index 98a5535..b2a976a 100644 --- a/internal/command/assist.go +++ b/internal/command/assist.go @@ -120,7 +120,16 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { yamlContent := ai.StripFences(response.Text) - if err := cmd.unparseAndWrite(p, yamlContent, outputDir, dryRun); err != nil { + contextDir := "cinzel" + if cd := c.String("context-dir"); cd != "" { + contextDir = cd + } + + if c.Bool("no-context") { + contextDir = "" + } + + if err := cmd.unparseAndWrite(p, yamlContent, outputDir, contextDir, dryRun); err != nil { return err } @@ -188,7 +197,7 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { } } -func (cmd *Cli) unparseAndWrite(p provider.Provider, yamlContent, outputDir string, dryRun bool) error { +func (cmd *Cli) unparseAndWrite(p provider.Provider, yamlContent, outputDir, contextDir string, dryRun bool) error { tmpYAMLDir, err := os.MkdirTemp("", "cinzel-assist-yaml-*") if err != nil { return fmt.Errorf("failed to create temp directory: %w", err) @@ -240,6 +249,10 @@ func (cmd *Cli) unparseAndWrite(p provider.Provider, yamlContent, outputDir stri return fmt.Errorf("failed to merge HCL files: %w", err) } + if contextDir != "" { + merged = deduplicateWithExisting(merged, contextDir) + } + if dryRun { _, _ = fmt.Fprintln(cmd.Writer, merged) @@ -303,6 +316,102 @@ func mergeHCLFiles(dir string) (string, error) { return strings.Join(parts, "\n\n") + "\n", nil } +// existingBlock maps a block's content to its source file. +type existingBlock struct { + content string + filename string + sig string // e.g. `step "checkout"` +} + +// blockSignature extracts the type and labels from an HCL block string, +// e.g. `step "checkout" {` → `step "checkout"`. +func blockSignature(block string) string { + line := strings.SplitN(block, "\n", 2)[0] + line = strings.TrimSpace(line) + line = strings.TrimSuffix(line, "{") + + return strings.TrimSpace(line) +} + +// deduplicateWithExisting compares generated blocks against existing HCL files +// in contextDir. Identical blocks are replaced with a reference comment. +// Blocks with matching signatures but different content are kept with a note. +func deduplicateWithExisting(merged, contextDir string) string { + entries, err := os.ReadDir(contextDir) + if err != nil { + return merged + } + + // Build index of existing blocks: signature → existingBlock. + existing := make(map[string]existingBlock) + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".hcl") { + continue + } + + content, err := os.ReadFile(filepath.Join(contextDir, entry.Name())) + if err != nil { + continue + } + + for _, block := range splitHCLBlocksAST(content, entry.Name()) { + block = strings.TrimSpace(block) + if block == "" { + continue + } + + sig := blockSignature(block) + if sig == "" { + continue + } + + existing[sig] = existingBlock{ + content: block, + filename: entry.Name(), + sig: sig, + } + } + } + + if len(existing) == 0 { + return merged + } + + // Compare each generated block against existing ones. + generatedBlocks := splitHCLBlocksAST([]byte(merged), "assist.hcl") + + var result []string + + for _, block := range generatedBlocks { + block = strings.TrimSpace(block) + if block == "" { + continue + } + + sig := blockSignature(block) + + eb, found := existing[sig] + if !found { + result = append(result, block) + + continue + } + + if eb.content == block { + // Identical — replace with reference comment. + result = append(result, fmt.Sprintf("// reuses: %s from %s", sig, eb.filename)) + + continue + } + + // Same signature but different content — keep with note. + result = append(result, fmt.Sprintf("// note: %s also exists in %s (different content)\n%s", sig, eb.filename, block)) + } + + return strings.Join(result, "\n\n") + "\n" +} + // splitHCLBlocksAST uses the HCL write parser to split content into // individual top-level blocks. This is robust against braces inside // strings, comments, and heredocs. diff --git a/internal/command/assist_test.go b/internal/command/assist_test.go index 38bd095..8f7646f 100644 --- a/internal/command/assist_test.go +++ b/internal/command/assist_test.go @@ -325,3 +325,103 @@ func TestValidateRelativePath(t *testing.T) { }) } } + +func TestBlockSignature(t *testing.T) { + tests := []struct { + block string + want string + }{ + {`step "checkout" {` + "\n name = \"Checkout\"\n}", `step "checkout"`}, + {`workflow "pr" {` + "\n name = \"PR\"\n}", `workflow "pr"`}, + {`variable "os" {` + "\n value = []\n}", `variable "os"`}, + } + + for _, tt := range tests { + got := blockSignature(tt.block) + if got != tt.want { + t.Errorf("blockSignature(%q) = %q, want %q", tt.block[:20], got, tt.want) + } + } +} + +func TestDeduplicateWithExisting(t *testing.T) { + contextDir := t.TempDir() + + existingSteps := `step "checkout" { + name = "Checkout" + + uses { + action = "actions/checkout" + version = "abc123" + } +} + +step "tests" { + name = "Tests" + run = "go test ./..." +} +` + + if err := os.WriteFile(filepath.Join(contextDir, "steps.hcl"), []byte(existingSteps), 0644); err != nil { + t.Fatal(err) + } + + // Generated output has identical checkout, different tests, and a new step. + generated := `step "checkout" { + name = "Checkout" + + uses { + action = "actions/checkout" + version = "abc123" + } +} + +step "tests" { + name = "Tests" + run = "npm test" +} + +step "deploy" { + name = "Deploy" + run = "deploy.sh" +} +` + + result := deduplicateWithExisting(generated, contextDir) + + // Identical checkout should be replaced with reference. + if !strings.Contains(result, `// reuses: step "checkout" from steps.hcl`) { + t.Errorf("expected reuse comment for checkout\ngot:\n%s", result) + } + + // Checkout block content should NOT be in output. + if strings.Contains(result, `action = "actions/checkout"`) { + t.Errorf("identical checkout block should be replaced, not kept\ngot:\n%s", result) + } + + // Different tests should be kept with a note. + if !strings.Contains(result, `// note: step "tests" also exists in steps.hcl`) { + t.Errorf("expected note for different tests block\ngot:\n%s", result) + } + + if !strings.Contains(result, `run = "npm test"`) { + t.Errorf("different tests block should be kept\ngot:\n%s", result) + } + + // New step should be kept as-is. + if !strings.Contains(result, `step "deploy"`) { + t.Errorf("new deploy step should be kept\ngot:\n%s", result) + } +} + +func TestDeduplicateWithExistingNoContextDir(t *testing.T) { + input := `step "checkout" { + name = "Checkout" +} +` + result := deduplicateWithExisting(input, "/nonexistent/path") + + if result != input { + t.Errorf("should return input unchanged for nonexistent dir\ngot: %q", result) + } +} From eb93a26bf2a3c406492bf9b398cd1e7175780dd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Wed, 18 Mar 2026 00:49:41 +0000 Subject: [PATCH 36/48] feat: use timestamped session folders for assist output --- internal/command/assist.go | 92 ++++++++++++++++++++++++++++++-------- 1 file changed, 73 insertions(+), 19 deletions(-) diff --git a/internal/command/assist.go b/internal/command/assist.go index b2a976a..258319a 100644 --- a/internal/command/assist.go +++ b/internal/command/assist.go @@ -92,9 +92,20 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { userPrompt := prompt if refine != "" { - assistContext, _ := ai.StripHCLContext(outputDir) + refineDir := c.String("from") + if refineDir == "" { + refineDir = latestAssistDir(outputDir) + } else { + refineDir = filepath.Join(outputDir, refineDir) + } + + if refineDir == "" { + return fmt.Errorf("nothing to refine — run assist --prompt first to generate output in %s", outputDir) + } + + assistContext, _ := ai.StripHCLContext(refineDir) if assistContext == "" { - return fmt.Errorf("nothing to refine — run assist --prompt first to generate initial output in %s", outputDir) + return fmt.Errorf("nothing to refine in %s — no HCL files found", refineDir) } systemPrompt += "\n\nPrevious assist output (to be refined):\n\n" + assistContext @@ -129,16 +140,17 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { contextDir = "" } - if err := cmd.unparseAndWrite(p, yamlContent, outputDir, contextDir, dryRun); err != nil { + sessionDir, err := cmd.unparseAndWrite(p, yamlContent, outputDir, contextDir, dryRun) + if err != nil { return err } - if p.GetProviderName() == "github" && !dryRun { + if p.GetProviderName() == "github" && sessionDir != "" { _, _ = fmt.Fprintf(cmd.Writer, "Pinning action versions...\n") resolver := pin.NewCachedResolver(pin.NewGitHubResolver("")) - results, pinErr := pin.PinDirectory(ctx, outputDir, resolver, cmd.Writer, false) + results, pinErr := pin.PinDirectory(ctx, sessionDir, resolver, cmd.Writer, false) if pinErr != nil { _, _ = fmt.Fprintf(cmd.Writer, "warning: pin failed: %v\n", pinErr) } else { @@ -158,6 +170,11 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { Name: "refine", Usage: "Refine previous assist output with additional instructions", }, + &cli.StringFlag{ + Name: "from", + Value: "", + Usage: "Target a specific assist session folder for --refine (e.g. 20260317-150405)", + }, &cli.StringFlag{ Name: "output-directory", Value: "", @@ -197,17 +214,18 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { } } -func (cmd *Cli) unparseAndWrite(p provider.Provider, yamlContent, outputDir, contextDir string, dryRun bool) error { +// unparseAndWrite returns the session directory path where output was written (empty if dry-run). +func (cmd *Cli) unparseAndWrite(p provider.Provider, yamlContent, outputDir, contextDir string, dryRun bool) (string, error) { tmpYAMLDir, err := os.MkdirTemp("", "cinzel-assist-yaml-*") if err != nil { - return fmt.Errorf("failed to create temp directory: %w", err) + return "", fmt.Errorf("failed to create temp directory: %w", err) } defer os.RemoveAll(tmpYAMLDir) tmpHCLDir, err := os.MkdirTemp("", "cinzel-assist-hcl-*") if err != nil { - return fmt.Errorf("failed to create temp directory: %w", err) + return "", fmt.Errorf("failed to create temp directory: %w", err) } defer os.RemoveAll(tmpHCLDir) @@ -223,7 +241,7 @@ func (cmd *Cli) unparseAndWrite(p provider.Provider, yamlContent, outputDir, con tmpPath := filepath.Join(tmpYAMLDir, fmt.Sprintf("workflow-%d.yaml", i)) if err := os.WriteFile(tmpPath, []byte(doc), 0600); err != nil { - return fmt.Errorf("failed to write temp file: %w", err) + return "", fmt.Errorf("failed to write temp file: %w", err) } } @@ -238,7 +256,7 @@ func (cmd *Cli) unparseAndWrite(p provider.Provider, yamlContent, outputDir, con preview = preview[:maxRawYAMLErrorLen] + "\n... (truncated)" } - return fmt.Errorf( + return "", fmt.Errorf( "generated YAML could not be converted to HCL:\n%s\n\nRaw YAML (preview):\n%s\n\nTry refining your prompt", err, preview, ) @@ -246,7 +264,7 @@ func (cmd *Cli) unparseAndWrite(p provider.Provider, yamlContent, outputDir, con merged, err := mergeHCLFiles(tmpHCLDir) if err != nil { - return fmt.Errorf("failed to merge HCL files: %w", err) + return "", fmt.Errorf("failed to merge HCL files: %w", err) } if contextDir != "" { @@ -256,24 +274,26 @@ func (cmd *Cli) unparseAndWrite(p provider.Provider, yamlContent, outputDir, con if dryRun { _, _ = fmt.Fprintln(cmd.Writer, merged) - return nil + return "", nil } - if err := os.MkdirAll(outputDir, 0755); err != nil { - return fmt.Errorf("failed to create output directory: %w", err) + timestamp := time.Now().Format("20060102-150405") + sessionDir := filepath.Join(outputDir, timestamp) + + if err := os.MkdirAll(sessionDir, 0755); err != nil { + return "", fmt.Errorf("failed to create output directory: %w", err) } - timestamp := time.Now().Format("20060102-150405") - outPath := filepath.Join(outputDir, fmt.Sprintf("assist-%s.hcl", timestamp)) + outPath := filepath.Join(sessionDir, "assist.hcl") if err := os.WriteFile(outPath, []byte(merged), 0644); err != nil { - return fmt.Errorf("failed to write output file: %w", err) + return "", fmt.Errorf("failed to write output file: %w", err) } - absPath, _ := filepath.Abs(outPath) + absPath, _ := filepath.Abs(sessionDir) _, _ = fmt.Fprintf(cmd.Writer, "HCL written to %s\n", absPath) - return nil + return sessionDir, nil } // mergeHCLFiles reads all HCL files in dir, parses them with the HCL AST, @@ -498,6 +518,40 @@ func confirmCost(w io.Writer, r io.Reader, providerName, model string) error { return errCancelled } +// latestAssistDir returns the path to the most recent timestamped subfolder +// in the given directory, or empty string if none exist. +func latestAssistDir(baseDir string) string { + entries, err := os.ReadDir(baseDir) + if err != nil { + return "" + } + + var latest string + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + name := entry.Name() + + // Timestamped folders match YYYYMMDD-HHMMSS pattern. + if len(name) != 15 { + continue + } + + if name > latest { + latest = name + } + } + + if latest == "" { + return "" + } + + return filepath.Join(baseDir, latest) +} + // validateRelativePath ensures a path is relative and does not escape the // current working directory via ".." traversal or absolute paths. func validateRelativePath(p string) error { From 0851aa220c03acfc26a65012e442bda215af2b5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Wed, 18 Mar 2026 01:02:32 +0000 Subject: [PATCH 37/48] fix: address code review findings from final review --- README.md | 26 +++++++++++++++++++++----- internal/ai/provider.go | 6 +++--- internal/command/assist.go | 4 ++++ internal/pin/errors.go | 14 ++++++++++++++ internal/pin/pin.go | 23 +++++++++++++++++------ internal/pin/upgrade.go | 10 ++++++++-- 6 files changed, 67 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index bedad1d..7c22569 100644 --- a/README.md +++ b/README.md @@ -80,10 +80,20 @@ Use `--dry-run` to print generated content to stdout. Generate HCL workflow definitions from a natural language prompt: ```sh -cinzel assist --provider github --prompt "golang PR with tests and linting" +cinzel github assist --prompt "golang PR with tests and linting" ``` -This calls an LLM (Anthropic by default), generates valid YAML, converts it to HCL via the unparse pipeline, and writes a single deduplicated file to `./cinzel/assist/`. For GitHub providers, action versions are automatically pinned to SHAs. +This calls an LLM (Anthropic by default), generates valid YAML, converts it to HCL via the unparse pipeline, and writes to a timestamped session folder under `./cinzel/assist/`. For GitHub, action versions are automatically pinned to SHAs. Blocks that match your existing HCL are replaced with `// reuses:` comments. + +Each prompt creates its own session: + +``` +cinzel/assist/ + 20260317-150405/ # first prompt + assist.hcl + 20260317-151200/ # second prompt + assist.hcl +``` Requires an API key: @@ -91,13 +101,19 @@ Requires an API key: export ANTHROPIC_API_KEY=sk-ant-... # or export OPENAI_API_KEY=sk-... -cinzel assist --provider github --ai openai --prompt "..." +cinzel github assist --ai openai --prompt "..." +``` + +Refine previous output (targets the latest session by default): + +```sh +cinzel github assist --refine "add slack notification on failure" --prompt "add to PR workflow" ``` -Refine previous output: +Refine a specific session: ```sh -cinzel assist --provider github --refine "add slack notification on failure" --prompt "add to PR workflow" +cinzel github assist --refine "add caching" --from 20260317-150405 ``` ### Version management (GitHub Actions) diff --git a/internal/ai/provider.go b/internal/ai/provider.go index 6ec66fa..7a4a4b4 100644 --- a/internal/ai/provider.go +++ b/internal/ai/provider.go @@ -69,16 +69,16 @@ func classifyError(err error, providerName string) error { msg := err.Error() switch { + case errors.Is(err, context.DeadlineExceeded): + return fmt.Errorf("LLM request timed out after %s. Try a simpler prompt", DefaultTimeout) case strings.Contains(msg, "authentication") || strings.Contains(msg, "401"): return fmt.Errorf("invalid API key for %s. Check your API key is correct", providerName) case strings.Contains(msg, "insufficient_quota") || strings.Contains(msg, "billing"): return fmt.Errorf("API quota exceeded for %s. Check your plan and billing at your provider's dashboard", providerName) case strings.Contains(msg, "rate_limit") || strings.Contains(msg, "429"): return fmt.Errorf("API rate limited. Try again in a moment") - case errors.Is(err, context.DeadlineExceeded): - return fmt.Errorf("LLM request timed out after %s. Try a simpler prompt", DefaultTimeout) default: - return fmt.Errorf("LLM API error (%s): %w", providerName, err) + return fmt.Errorf("LLM API error (%s): %s", providerName, msg) } } diff --git a/internal/command/assist.go b/internal/command/assist.go index 258319a..efb40ef 100644 --- a/internal/command/assist.go +++ b/internal/command/assist.go @@ -96,6 +96,10 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { if refineDir == "" { refineDir = latestAssistDir(outputDir) } else { + if err := validateRelativePath(refineDir); err != nil { + return fmt.Errorf("--from: %w", err) + } + refineDir = filepath.Join(outputDir, refineDir) } diff --git a/internal/pin/errors.go b/internal/pin/errors.go index 0e14b89..c07a3c3 100644 --- a/internal/pin/errors.go +++ b/internal/pin/errors.go @@ -11,6 +11,20 @@ import ( var errNoHCLFiles = errors.New("no HCL files found in the specified path") +// validateGitHubNames checks that owner, repo, and tag contain only safe +// characters to prevent URL injection. +func validateGitHubNames(owner, repo, tag string) error { + for _, pair := range []struct{ name, val string }{ + {"owner", owner}, {"repo", repo}, {"tag", tag}, + } { + if !safeNamePattern.MatchString(pair.val) { + return fmt.Errorf("invalid GitHub %s name: %q", pair.name, pair.val) + } + } + + return nil +} + const tokenHint = "Set GITHUB_TOKEN for authenticated requests (5000/hr vs 60/hr unauthenticated):\n export GITHUB_TOKEN=ghp_..." // classifyGitHubError returns a user-friendly error for GitHub API failures. diff --git a/internal/pin/pin.go b/internal/pin/pin.go index f0ee103..ee502ab 100644 --- a/internal/pin/pin.go +++ b/internal/pin/pin.go @@ -28,6 +28,15 @@ const ( // as opposed to SHAs (40+ hex chars). var tagPattern = regexp.MustCompile(`^v?\d+(\.\d+)*$`) +// safeNamePattern validates GitHub owner, repo, and tag names to prevent +// URL injection. Allows alphanumeric, hyphens, dots, underscores. +// safeNamePattern validates GitHub owner, repo, and tag names to prevent +// URL injection. Allows alphanumeric, hyphens, dots, underscores. +var safeNamePattern = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) + +var actionPattern = regexp.MustCompile(`action\s*=\s*"([^"]+)"`) +var versionPattern = regexp.MustCompile(`version\s*=\s*"([^"]+)"`) + // Resolver resolves action version tags to commit SHAs. type Resolver interface { ResolveTag(ctx context.Context, owner, repo, tag string) (string, error) @@ -55,6 +64,10 @@ func NewGitHubResolver(token string) *GitHubResolver { // ResolveTag resolves a tag to a commit SHA via the GitHub API. func (r *GitHubResolver) ResolveTag(ctx context.Context, owner, repo, tag string) (string, error) { + if err := validateGitHubNames(owner, repo, tag); err != nil { + return "", err + } + url := fmt.Sprintf("%s/repos/%s/%s/git/ref/tags/%s", githubAPIBase, owner, repo, tag) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) @@ -139,6 +152,10 @@ func (r *GitHubResolver) dereferenceTag(ctx context.Context, owner, repo, tagSHA // LatestTag returns the latest semver tag for a repository by listing tags // sorted by version descending. func (r *GitHubResolver) LatestTag(ctx context.Context, owner, repo string) (string, error) { + if !safeNamePattern.MatchString(owner) || !safeNamePattern.MatchString(repo) { + return "", fmt.Errorf("invalid owner/repo name: %s/%s", owner, repo) + } + url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", githubAPIBase, owner, repo) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) @@ -386,12 +403,6 @@ func PinDirectory(ctx context.Context, dir string, resolver Resolver, w io.Write // findActionRefs extracts action references from HCL content by looking for // uses blocks containing action and version attributes. func findActionRefs(content string) []ActionRef { - // Match action = "owner/repo" followed by version = "tag-or-sha" - // within uses blocks. Uses a simple regex approach since the HCL - // structure is well-defined from cinzel's own output. - actionPattern := regexp.MustCompile(`action\s*=\s*"([^"]+)"`) - versionPattern := regexp.MustCompile(`version\s*=\s*"([^"]+)"`) - actionMatches := actionPattern.FindAllStringSubmatchIndex(content, -1) versionMatches := versionPattern.FindAllStringSubmatchIndex(content, -1) diff --git a/internal/pin/upgrade.go b/internal/pin/upgrade.go index 1b5d6f8..da78a8d 100644 --- a/internal/pin/upgrade.go +++ b/internal/pin/upgrade.go @@ -12,6 +12,12 @@ import ( "strings" ) +// Upgrader extends Resolver with the ability to find the latest release tag. +type Upgrader interface { + Resolver + LatestTag(ctx context.Context, owner, repo string) (string, error) +} + // UpgradeResult holds the result of upgrading a single action. type UpgradeResult struct { Action string @@ -25,7 +31,7 @@ type UpgradeResult struct { // UpgradeFile reads an HCL file, checks each action for a newer release, // and updates both the version and comment. When dryRun is true, changes // are reported but the file is not modified. -func UpgradeFile(ctx context.Context, path string, resolver *GitHubResolver, w io.Writer, dryRun bool) ([]UpgradeResult, error) { +func UpgradeFile(ctx context.Context, path string, resolver Upgrader, w io.Writer, dryRun bool) ([]UpgradeResult, error) { content, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read %s: %w", path, err) @@ -118,7 +124,7 @@ func UpgradeFile(ctx context.Context, path string, resolver *GitHubResolver, w i } // UpgradeDirectory upgrades all HCL files in a directory. -func UpgradeDirectory(ctx context.Context, dir string, resolver *GitHubResolver, w io.Writer, dryRun bool) ([]UpgradeResult, error) { +func UpgradeDirectory(ctx context.Context, dir string, resolver Upgrader, w io.Writer, dryRun bool) ([]UpgradeResult, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("failed to read directory %s: %w", dir, err) From 4ab8092f410d627b43e669d93a2b0df140398fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Wed, 18 Mar 2026 01:04:29 +0000 Subject: [PATCH 38/48] refactor: remove providers map and pass provider directly to upgrade --- internal/command/command.go | 33 +++------------------------------ internal/command/upgrade.go | 25 ++++++++----------------- 2 files changed, 11 insertions(+), 47 deletions(-) diff --git a/internal/command/command.go b/internal/command/command.go index 90e484b..973f020 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -21,17 +21,13 @@ const ( // Cli holds the CLI application state including the output writer and root command. type Cli struct { - Writer io.Writer - Cmd *cli.Command - providers map[string]provider.Provider + Writer io.Writer + Cmd *cli.Command } // Execute registers the given providers and runs the CLI with the supplied arguments. func (cmd *Cli) Execute(osArgs []string, providers []provider.Provider) error { - cmd.providers = make(map[string]provider.Provider, len(providers)) - for _, p := range providers { - cmd.providers[p.GetProviderName()] = p ap := cmd.addProvider(p) cmd.Cmd.Commands = append(cmd.Cmd.Commands, ap) } @@ -45,29 +41,6 @@ func (cmd *Cli) Execute(osArgs []string, providers []provider.Provider) error { return nil } -// resolveProvider looks up a CI/CD provider by name. -func (cmd *Cli) resolveProvider(name string) (provider.Provider, error) { - if name == "" { - return nil, fmt.Errorf("--provider is required. Supported: %s", cmd.providerNames()) - } - - p, ok := cmd.providers[name] - if !ok { - return nil, fmt.Errorf("unknown provider %q. Supported: %s", name, cmd.providerNames()) - } - - return p, nil -} - -func (cmd *Cli) providerNames() string { - var names []string - for name := range cmd.providers { - names = append(names, name) - } - - return fmt.Sprintf("%v", names) -} - // New creates a Cli configured with the given writer and version string. func New(writer io.Writer, version string) *Cli { return &Cli{ @@ -215,7 +188,7 @@ func (cmd *Cli) addProvider(p provider.Provider) *cli.Command { if p.GetProviderName() == "github" { providerCmd.Commands = append(providerCmd.Commands, cmd.pinCommand()) - providerCmd.Commands = append(providerCmd.Commands, cmd.upgradeCommand()) + providerCmd.Commands = append(providerCmd.Commands, cmd.upgradeCommand(p)) } return providerCmd diff --git a/internal/command/upgrade.go b/internal/command/upgrade.go index 2882d5b..3cf3784 100644 --- a/internal/command/upgrade.go +++ b/internal/command/upgrade.go @@ -13,7 +13,7 @@ import ( "github.com/yldio/cinzel/provider" ) -func (cmd *Cli) upgradeCommand() *cli.Command { +func (cmd *Cli) upgradeCommand(p provider.Provider) *cli.Command { return &cli.Command{ Name: "upgrade", Usage: "Upgrade GitHub Actions to their latest versions and pin to SHAs", @@ -91,7 +91,13 @@ func (cmd *Cli) upgradeCommand() *cli.Command { } } - return cmd.runParseGitHub(parseDir, outputDir) + _, _ = fmt.Fprintf(cmd.Writer, "\nRegenerating YAML...\n") + + return p.Parse(provider.ProviderOps{ + Directory: parseDir, + OutputDirectory: outputDir, + DryRun: false, + }) }, Flags: []cli.Flag{ &cli.StringFlag{ @@ -125,21 +131,6 @@ func (cmd *Cli) upgradeCommand() *cli.Command { } } -func (cmd *Cli) runParseGitHub(inputDir, outputDir string) error { - p, err := cmd.resolveProvider("github") - if err != nil { - return err - } - - _, _ = fmt.Fprintf(cmd.Writer, "\nRegenerating YAML...\n") - - return p.Parse(provider.ProviderOps{ - Directory: inputDir, - OutputDirectory: outputDir, - DryRun: false, - }) -} - func (cmd *Cli) printUpgradeSummary(results []pin.UpgradeResult) { upgraded := 0 current := 0 From f3e3fd8be569791d63c7e9e9957ea7f0a7b89891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Wed, 18 Mar 2026 01:09:31 +0000 Subject: [PATCH 39/48] fix: remove dead code, duplicate comment, and go vet warning --- internal/command/assist.go | 26 +++++++++++--------------- internal/pin/errors.go | 2 +- internal/pin/pin.go | 2 -- 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/internal/command/assist.go b/internal/command/assist.go index efb40ef..f6b6cda 100644 --- a/internal/command/assist.go +++ b/internal/command/assist.go @@ -69,12 +69,14 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { systemPrompt := ai.SystemPrompt(p.GetProviderName()) - if !c.Bool("no-context") { - contextDir := c.String("context-dir") - if contextDir == "" { - contextDir = "cinzel" - } + noContext := c.Bool("no-context") + contextDir := c.String("context-dir") + + if contextDir == "" { + contextDir = "cinzel" + } + if !noContext { if err := validateRelativePath(contextDir); err != nil { return fmt.Errorf("--context-dir: %w", err) } @@ -135,16 +137,12 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { yamlContent := ai.StripFences(response.Text) - contextDir := "cinzel" - if cd := c.String("context-dir"); cd != "" { - contextDir = cd - } - - if c.Bool("no-context") { - contextDir = "" + dedupDir := contextDir + if noContext { + dedupDir = "" } - sessionDir, err := cmd.unparseAndWrite(p, yamlContent, outputDir, contextDir, dryRun) + sessionDir, err := cmd.unparseAndWrite(p, yamlContent, outputDir, dedupDir, dryRun) if err != nil { return err } @@ -344,7 +342,6 @@ func mergeHCLFiles(dir string) (string, error) { type existingBlock struct { content string filename string - sig string // e.g. `step "checkout"` } // blockSignature extracts the type and labels from an HCL block string, @@ -393,7 +390,6 @@ func deduplicateWithExisting(merged, contextDir string) string { existing[sig] = existingBlock{ content: block, filename: entry.Name(), - sig: sig, } } } diff --git a/internal/pin/errors.go b/internal/pin/errors.go index c07a3c3..83c4c85 100644 --- a/internal/pin/errors.go +++ b/internal/pin/errors.go @@ -46,6 +46,6 @@ func classifyGitHubError(statusCode int, resource string, unauthenticated bool) msg += "\n\n" + tokenHint } - return fmt.Errorf("%s", msg) + return errors.New(msg) } } diff --git a/internal/pin/pin.go b/internal/pin/pin.go index ee502ab..d97b6cb 100644 --- a/internal/pin/pin.go +++ b/internal/pin/pin.go @@ -28,8 +28,6 @@ const ( // as opposed to SHAs (40+ hex chars). var tagPattern = regexp.MustCompile(`^v?\d+(\.\d+)*$`) -// safeNamePattern validates GitHub owner, repo, and tag names to prevent -// URL injection. Allows alphanumeric, hyphens, dots, underscores. // safeNamePattern validates GitHub owner, repo, and tag names to prevent // URL injection. Allows alphanumeric, hyphens, dots, underscores. var safeNamePattern = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) From 594f935cb6bab26a57b2272a3d5b51fab0c1652a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Wed, 18 Mar 2026 07:28:49 +0000 Subject: [PATCH 40/48] fix: address all code review findings --- internal/command/assist.go | 63 ++++++++++++++++++++++-------------- internal/pin/pin.go | 25 ++++++++++---- internal/pin/pin_test.go | 5 ++- internal/pin/upgrade.go | 6 +++- internal/pin/upgrade_test.go | 5 ++- 5 files changed, 69 insertions(+), 35 deletions(-) diff --git a/internal/command/assist.go b/internal/command/assist.go index f6b6cda..16d3644 100644 --- a/internal/command/assist.go +++ b/internal/command/assist.go @@ -94,33 +94,13 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { userPrompt := prompt if refine != "" { - refineDir := c.String("from") - if refineDir == "" { - refineDir = latestAssistDir(outputDir) - } else { - if err := validateRelativePath(refineDir); err != nil { - return fmt.Errorf("--from: %w", err) - } - - refineDir = filepath.Join(outputDir, refineDir) - } - - if refineDir == "" { - return fmt.Errorf("nothing to refine — run assist --prompt first to generate output in %s", outputDir) - } - - assistContext, _ := ai.StripHCLContext(refineDir) - if assistContext == "" { - return fmt.Errorf("nothing to refine in %s — no HCL files found", refineDir) + refinedSystem, refinedUser, err := buildRefinePrompt(refine, prompt, outputDir, c.String("from")) + if err != nil { + return err } - systemPrompt += "\n\nPrevious assist output (to be refined):\n\n" + assistContext - - if prompt != "" { - userPrompt = refine + "\n\nOriginal request: " + prompt - } else { - userPrompt = refine - } + systemPrompt += refinedSystem + userPrompt = refinedUser } response, err := ai.GenerateWithTimeout(ctx, aiProvider, ai.GenerateRequest{ @@ -216,6 +196,39 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { } } +// buildRefinePrompt resolves the refine directory, loads previous output as +// context, and returns the additional system prompt and user prompt. +func buildRefinePrompt(refine, prompt, outputDir, from string) (string, string, error) { + refineDir := from + if refineDir == "" { + refineDir = latestAssistDir(outputDir) + } else { + if err := validateRelativePath(refineDir); err != nil { + return "", "", fmt.Errorf("--from: %w", err) + } + + refineDir = filepath.Join(outputDir, refineDir) + } + + if refineDir == "" { + return "", "", fmt.Errorf("nothing to refine — run assist --prompt first to generate output in %s", outputDir) + } + + assistContext, _ := ai.StripHCLContext(refineDir) + if assistContext == "" { + return "", "", fmt.Errorf("nothing to refine in %s — no HCL files found", refineDir) + } + + systemAddition := "\n\nPrevious assist output (to be refined):\n\n" + assistContext + + userPrompt := refine + if prompt != "" { + userPrompt = refine + "\n\nOriginal request: " + prompt + } + + return systemAddition, userPrompt, nil +} + // unparseAndWrite returns the session directory path where output was written (empty if dry-run). func (cmd *Cli) unparseAndWrite(p provider.Provider, yamlContent, outputDir, contextDir string, dryRun bool) (string, error) { tmpYAMLDir, err := os.MkdirTemp("", "cinzel-assist-yaml-*") diff --git a/internal/pin/pin.go b/internal/pin/pin.go index d97b6cb..5271d7a 100644 --- a/internal/pin/pin.go +++ b/internal/pin/pin.go @@ -84,7 +84,7 @@ func (r *GitHubResolver) ResolveTag(ctx context.Context, owner, repo, tag string return "", fmt.Errorf("GitHub API request failed: %w", err) } - defer resp.Body.Close() + defer drainAndClose(resp.Body) if resp.StatusCode != http.StatusOK { return "", classifyGitHubError(resp.StatusCode, fmt.Sprintf("%s/%s@%s", owner, repo, tag), r.token == "") @@ -128,7 +128,7 @@ func (r *GitHubResolver) dereferenceTag(ctx context.Context, owner, repo, tagSHA return "", fmt.Errorf("GitHub API request failed: %w", err) } - defer resp.Body.Close() + defer drainAndClose(resp.Body) if resp.StatusCode != http.StatusOK { return "", classifyGitHubError(resp.StatusCode, "tag/"+tagSHA, r.token == "") @@ -172,7 +172,7 @@ func (r *GitHubResolver) LatestTag(ctx context.Context, owner, repo string) (str return "", fmt.Errorf("GitHub API request failed: %w", err) } - defer resp.Body.Close() + defer drainAndClose(resp.Body) if resp.StatusCode != http.StatusOK { return "", classifyGitHubError(resp.StatusCode, fmt.Sprintf("%s/%s latest release", owner, repo), r.token == "") @@ -292,7 +292,11 @@ func PinFile(ctx context.Context, path string, resolver Resolver, w io.Writer, d return nil, fmt.Errorf("failed to read %s: %w", path, err) } - refs := findActionRefs(string(content)) + refs, err := findActionRefs(string(content)) + if err != nil { + return nil, fmt.Errorf("failed to parse action refs in %s: %w", path, err) + } + if len(refs) == 0 { return nil, nil } @@ -400,12 +404,12 @@ func PinDirectory(ctx context.Context, dir string, resolver Resolver, w io.Write // findActionRefs extracts action references from HCL content by looking for // uses blocks containing action and version attributes. -func findActionRefs(content string) []ActionRef { +func findActionRefs(content string) ([]ActionRef, error) { actionMatches := actionPattern.FindAllStringSubmatchIndex(content, -1) versionMatches := versionPattern.FindAllStringSubmatchIndex(content, -1) if len(actionMatches) != len(versionMatches) { - return nil + return nil, fmt.Errorf("mismatched action/version count: %d actions, %d versions", len(actionMatches), len(versionMatches)) } var refs []ActionRef @@ -421,7 +425,7 @@ func findActionRefs(content string) []ActionRef { }) } - return refs + return refs, nil } // upsertUsesComment adds or updates the comment line above a uses block @@ -474,3 +478,10 @@ func upsertUsesComment(content, action, tag string) string { // No existing comment — insert one before uses, same indent. return beforeUses + indent + comment + "\n" + afterUses } + +// drainAndClose reads the remaining body to enable HTTP connection reuse, +// then closes it. +func drainAndClose(body io.ReadCloser) { + _, _ = io.Copy(io.Discard, body) + body.Close() +} diff --git a/internal/pin/pin_test.go b/internal/pin/pin_test.go index 2cb7402..ce97107 100644 --- a/internal/pin/pin_test.go +++ b/internal/pin/pin_test.go @@ -49,7 +49,10 @@ step "pinned" { } }` - refs := findActionRefs(content) + refs, err := findActionRefs(content) + if err != nil { + t.Fatal(err) + } if len(refs) != 3 { t.Fatalf("expected 3 refs, got %d", len(refs)) diff --git a/internal/pin/upgrade.go b/internal/pin/upgrade.go index da78a8d..6ae54e8 100644 --- a/internal/pin/upgrade.go +++ b/internal/pin/upgrade.go @@ -37,7 +37,11 @@ func UpgradeFile(ctx context.Context, path string, resolver Upgrader, w io.Write return nil, fmt.Errorf("failed to read %s: %w", path, err) } - refs := findActionRefs(string(content)) + refs, err := findActionRefs(string(content)) + if err != nil { + return nil, fmt.Errorf("failed to parse action refs in %s: %w", path, err) + } + if len(refs) == 0 { return nil, nil } diff --git a/internal/pin/upgrade_test.go b/internal/pin/upgrade_test.go index d7e8177..da41f66 100644 --- a/internal/pin/upgrade_test.go +++ b/internal/pin/upgrade_test.go @@ -65,7 +65,10 @@ step "setup" { } }` - refs := findActionRefs(content) + refs, err := findActionRefs(content) + if err != nil { + t.Fatal(err) + } if len(refs) != 2 { t.Fatalf("expected 2 refs, got %d", len(refs)) From c81d84091e15855285d634adfc8ba362394d49c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Wed, 18 Mar 2026 09:18:31 +0000 Subject: [PATCH 41/48] feat: add cinzel init command for config setup --- CLAUDE.md | 382 ++++++++++-------- README.md | 8 +- ...at-cinzel-assist-ai-workflow-generation.md | 6 +- ...sist-pin-upgrade-feature-implementation.md | 2 +- internal/command/command.go | 2 + internal/command/init.go | 113 ++++++ 6 files changed, 343 insertions(+), 170 deletions(-) create mode 100644 internal/command/init.go diff --git a/CLAUDE.md b/CLAUDE.md index 60826bc..62cb8be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,165 +1,217 @@ -# CLAUDE.md - -## Project overview - -`cinzel` is a bidirectional converter between HCL and CI/CD pipeline YAML. The first (and currently only) provider is GitHub Actions. The architecture is designed for multiple providers (GitLab Pipelines, etc.) via the `provider.Provider` interface. - -- **Module**: `github.com/yldio/cinzel` -- **Go version**: 1.26 -- **License**: Apache-2.0 -- **Task runner**: [mise](https://mise.jdx.dev/) - -## Quick reference - -```sh -mise run test # run tests with coverage -mise run test-ci # run tests (CI, no coverage file) -mise run fmt # format Go + HCL files -mise run build # build binary to ./bin/cinzel -mise run bench # run benchmarks -mise run license # apply license headers -mise run license-check # verify license headers -``` - -## Architecture - -``` -cinzel.go # CLI entrypoint, wires providers -internal/ - command/ # CLI framework (urfave/cli) - command.go # provider registration, parse/unparse commands - assist.go # assist command, LLM pipeline, HCL merge/dedup - pin.go # pin command (GitHub Actions) - upgrade.go # upgrade command (GitHub Actions) - errors.go # CLI sentinel errors - ai/ # LLM integration - provider.go # AI provider interface, error classification, fence stripping - anthropic.go # Anthropic SDK client - openai.go # OpenAI SDK client - strip.go # HCL string stripping for privacy - errors.go # AI sentinel errors - pin/ # GitHub Actions version management - pin.go # Resolver interface, SHA resolution, caching, comment upsert - upgrade.go # latest version lookup via GitHub releases API - errors.go # GitHub API error classification - filereader/ # reads HCL/YAML from disk - filewriter/ # writes output files - hclparser/ # HCL expression evaluator - yamlwriter/ # YAML marshalling with gopkg.in/yaml.v3 node control - maputil/ # sorted map iteration helpers - naming/ # HCL<->YAML identifier normalization - fsutil/ # filesystem utilities - cinzelerror/ # shared sentinel errors - test/ # test helpers -provider/ - provider.go # Provider interface (Parse, Unparse) - github/ # GitHub Actions provider - github.go # main Parse/Unparse entry points - parse_workflow.go # HCL -> workflow YAML - parse_action.go # HCL -> action YAML (composite, node, docker) - unparse_workflow.go # workflow YAML -> HCL - unparse_action.go # action YAML -> HCL - workflow_yaml.go # YAML node builder, quote style decisions - models.go # shared types - config.go # HCL block schema (parseConfig) - validate.go # cross-direction validation - expression.go # ${{ }} expression handling - errors.go # sentinel errors - job/ # job parsing/validation - step/ # step parsing/encoding - action/ # action validation (uses refs) - workflow/ # workflow triggers, permissions, cron -``` - -## Key conventions - -### Code style - -- Every `.go` file starts with the copyright header. Run `mise run license` to apply. -- Every package has a `doc.go` with a package-level doc comment. -- Every exported Go declaration (functions, methods, types, constants, variables) must have a Go doc comment that starts with the declaration name. -- Doc comments must be directly attached to the declaration they document (no blank line between comment and declaration). -- Keep comments directly attached to the code they describe (avoid empty lines between a comment block and the following statement/declaration). -- Sentinel errors live in `errors.go` within each package. Use `errCamelCase` naming. -- No `testify` or assertion libraries — tests use stdlib `testing` only. -- Prefer visual separation between logic blocks: - - Add an empty line before `return`, non-error-guard `if`, and `for` when they are not the first statement in their current block. - - Do not add a blank line before those statements when they are the first statement in the current block (`func`, `if`/`else`, `for`, `switch`/`case`, or nested block). - - Keep adjacent logical blocks separated by one blank line, except tightly coupled `switch`/`case` style flows. - - Add an empty line between setup assignments and the first decision branch when it improves scanability. - -### HCL <-> YAML conversion - -- **Parse** = HCL to YAML (the "forward" direction). -- **Unparse** = YAML to HCL (the "reverse" direction). -- `$${{ }}` in HCL string literals becomes `${{ }}` in YAML (double-dollar escaping). -- Document type is auto-detected during unparse: workflow (has `on`+`jobs`), action (has `name`+`runs`, no `on`/`jobs`), or step-only fallback. -- Actions write to `//action.yml`. Workflows write to `/.yaml`. -- All `runs.using` types (composite, node20, docker) work in both directions. Only composite needs special handling for `steps` reference resolution; all other attributes flow through generic parsing. - -### Schema contracts (critical) - -- Provider parse schema must be defined by typed HCL structs in `provider//config.go` (HCL tags are the source of truth). -- Avoid ad-hoc "allowed attribute/key" maps for schema enforcement. They drift from parser contracts and create duplicate maintenance. -- `hcl:",remain"` is allowed only for intentional pass-through islands (for example deeply nested free-form sections), not as the default parse strategy. -- For unparse YAML validation, prefer strict typed decode (`goccy/go-yaml` strict mode) over hand-maintained key allowlists. -- Decoder-native diagnostics are acceptable and preferred. Tests should assert stable substrings from strict decoder errors instead of custom-normalized wording. -- When adding fields, update typed structs first, then conversion logic, then fixtures/tests. Never add schema keys in validation-only tables. - -### AI-assisted generation (`cinzel assist`) - -- **Pipeline**: prompt → LLM → YAML → strip fences → split YAML docs → temp files → `Unparse` → merge/dedup HCL blocks → single timestamped output. -- **No Provider interface changes** — uses temp files and existing `Unparse(ProviderOps{Directory})`. -- **Privacy**: `StripHCLContext` walks HCL AST, replaces all string literal values with `"..."`, strips comments. Block labels preserved (accepted residual risk). -- **HCL dedup**: `mergeHCLFiles` uses `hclwrite.ParseConfig` (AST-based) to split blocks, dedup by exact match. Handles braces in strings/heredocs correctly. -- **Auto-pin**: GitHub assist output is automatically pinned to SHAs after generation. -- **AI providers**: Anthropic (default) and OpenAI via `--ai` flag. Keys from env vars only. -- **`--refine`**: reads previous `assist/` output as additional context for iterative generation. - -### Version management (`cinzel github pin/upgrade`) - -- **Pin**: resolves action tags to SHAs via GitHub API. `Resolver` interface with `GitHubResolver` + `CachedResolver` (24h TTL file cache in `os.UserCacheDir()/cinzel/pins/`). -- **Upgrade**: finds latest release via GitHub API, compares by tag or SHA, updates version + comment. -- **Comment management**: `upsertUsesComment` adds `// actions/checkout v4` above `uses` blocks, or updates existing comments. -- **No token required** for public actions. `GITHUB_TOKEN` env var for higher rate limits. -- **Path validation**: `validateRelativePath` blocks absolute paths and `..` traversal on all user-supplied paths. - -### YAML output - -- Uses `gopkg.in/yaml.v3` node-level marshalling for precise control. -- Strings that need quoting use `DoubleQuotedStyle` (not single quotes — Zed editor converts `'` to `"` on save, breaking golden tests). -- `stringNeedsQuoting()` determines when to quote: empty strings, booleans, null, numbers, YAML special characters. The `@` character does NOT trigger quoting (e.g., `actions/checkout@v4` stays unquoted). -- Top-level key order: `name`, `run-name`, `on`, `jobs`, then remaining keys alphabetically. -- Uses `goccy/go-yaml` for test assertions (semantic comparison), but `gopkg.in/yaml.v3` for production marshalling. - -### Testing - -- **Golden tests**: compare generated output against `.golden.yaml` files. Use `assertYAMLSemanticEqual` (not byte comparison). -- **Roundtrip tests**: HCL -> YAML -> HCL -> YAML, assert semantic equality. Proves bidirectional stability. -- **Fixture matrix**: structured under `testdata/fixtures/matrix/{parse,unparse}/{valid,invalid}/`. Valid cases have `.hcl`+`.golden.yaml`, invalid cases have `.hcl`+`.error.txt`. -- **Action fixtures**: under `testdata/fixtures/actions/` — composite, node, docker action types. -- **Benchmark tests**: `BenchmarkParseWorkflow`, `BenchmarkUnparseWorkflow`, `BenchmarkRoundtripWorkflow`. - -### Commits - -- Conventional commits enforced via `commitlint` (types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert). -- Header max 50 chars, body/footer max 72 chars per line. -- Changelog managed by `git-cliff`. - -### CI/CD - -- GitHub Actions workflows in `.github/workflows/`. -- `mise run test` runs the key test suites before release. - -## Adding a new provider - -1. Create `provider//` implementing `provider.Provider`. -2. Wire it into `cinzel.go` alongside `github.New()`. -3. Add `provider//README.md` with HCL schema reference. -4. Add testdata fixtures following the same golden/roundtrip pattern. - -## Common pitfalls - -- **`parseHCLToWorkflows` return values**: Returns 4 values `([]WorkflowYAMLFile, map[string]any, []ActionYAMLFile, error)`. All error paths must return all 4. -- **Single YAML unmarshal**: `parseYAMLDocument()` unmarshals once, then `classifyWorkflowDocument()` or `classifyActionDocument()` route the result. Never unmarshal the same content twice. -- **Root package `-v` flag**: `go test -v ./...` at the root passes `-v` to the CLI app which rejects it. Run subpackages directly or omit `-v`. +# Project + +`cinzel` converts between HCL and CI/CD YAML. + +- Parse = HCL → YAML +- Unparse = YAML → HCL + +--- + +# Workflow + +For non-trivial tasks: + +1. Search the repo. +2. Identify provider and direction. +3. Plan briefly. +4. Implement minimal changes. +5. Run tests. +6. Fix until stable. + +Keep changes minimal and localized. + +--- + +# Routing (critical) + +Do not rely on implicit workflow selection for important tasks. + +Use explicit skills when available: + +- planning → `$ce:plan` +- plan review → `$ce:review-plan` +- debugging → `$ce:debug` +- code review → `$ce:review` +- verification → `$ce:verify` + +If a matching skill exists, use it instead of recreating the workflow. + +--- + +# Sub-agents + +Use sub-agents for non-trivial tasks when a clear boundary exists. + +Good delegation: + +- repo research vs implementation +- plan review vs editing +- verification vs coding + +Do not use sub-agents for trivial work. + +--- + +# Tools + +Prefer MCP tools when available. + +Use: + +- Context7 → external docs +- `ctx_search` → repo search +- `ctx_batch_execute` → multiple commands +- `ctx_execute` → dependent commands + +Use native tools only if MCP tools are unavailable or failed. + +Summarize outputs. Do not return raw logs. + +--- + +# Schema contracts (critical) + +- HCL schema lives only in `provider//config.go` +- Do not use ad-hoc key maps +- Do not duplicate schema in validation + +- `hcl:",remain"` only for intentional pass-through + +YAML validation: + +- use strict typed decode (`goccy/go-yaml`) +- do not use allowlists + +When adding fields: + +1. update structs +2. update conversion +3. update tests + +--- + +# Change rules + +- Keep changes minimal and localized +- Do not refactor unrelated code +- Do not change public interfaces unless required +- Prefer existing patterns +- Do not introduce unrelated formatting + +--- + +# Conversion rules + +- `$${{ }}` → `${{ }}` +- Detect on unparse: + - workflow: `on` + `jobs` + - action: `name` + `runs` + - else: step-only + +- Output: + - actions → `//action.yml` + - workflows → `/.yaml` + +--- + +# YAML output + +- Use `yaml.v3` node API +- Use double quotes when required +- Do not rely on single quotes + +Quote when needed for: + +- empty +- bool/null +- numbers +- YAML special chars + +Do not quote `@` + +Key order: + +1. name +2. run-name +3. on +4. jobs +5. rest sorted + +--- + +# AI assist (`cinzel assist`) + +- **Pipeline**: prompt → LLM → YAML → strip fences → split docs → temp files → Unparse → merge/dedup HCL → session folder +- Output: `cinzel/assist/{timestamp}/assist.hcl` — each prompt gets its own session +- `--refine` targets latest session by default, or `--from {timestamp}` for a specific one +- Blocks identical to existing `cinzel/*.hcl` replaced with `// reuses:` comments +- Different blocks with same signature get `// note:` comments +- Auto-pins GitHub actions to SHAs after generation +- Privacy: `StripHCLContext` replaces all string values with `"..."` via HCL AST walk + +# Version management (`cinzel github pin/upgrade`) + +- `pin`: resolves action tags → SHAs via GitHub API. Cached 24h. Adds `// action tag` comments +- `upgrade`: finds latest release, compares by tag or SHA, updates version + comment +- No token required for public actions. `GITHUB_TOKEN` for higher rate limits + +--- + +# Testing + +- Golden: semantic YAML comparison +- Roundtrip must remain stable + +When changing code: + +- update tests +- ensure roundtrip passes +- ensure golden passes + +--- + +# Code style + +- Every package has `doc.go` +- Every exported symbol has doc comment (starts with name) +- Comments directly attached + +- Errors in `errors.go`, `errCamelCase` +- Use stdlib `testing` only + +Formatting: + +- one blank line between logical blocks +- blank line before `return`, `if`, `for` (if not first) +- keep `switch/case` compact + +- match surrounding style +- no unrelated formatting + +--- + +# Commits + +- one intent per commit +- reviewable in ≤5 minutes + +Split if: + +- multiple intents +- refactor mixed with behavior change +- unrelated areas touched + +Preferred order: + +1. refactor +2. change +3. tests +4. cleanup + +--- + +# Pitfalls + +- `parseHCLToWorkflows` returns 4 values — always return all +- do not unmarshal YAML twice +- avoid `go test -v ./...` at root diff --git a/README.md b/README.md index 7c22569..2235103 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,13 @@ cinzel/assist/ assist.hcl ``` -Requires an API key: +Requires an API key. Set up with: + +```sh +cinzel init +``` + +Or set environment variables directly: ```sh export ANTHROPIC_API_KEY=sk-ant-... diff --git a/docs/brainstorms/2026-03-16-feat-cinzel-assist-ai-workflow-generation.md b/docs/brainstorms/2026-03-16-feat-cinzel-assist-ai-workflow-generation.md index 44384db..57ec24a 100644 --- a/docs/brainstorms/2026-03-16-feat-cinzel-assist-ai-workflow-generation.md +++ b/docs/brainstorms/2026-03-16-feat-cinzel-assist-ai-workflow-generation.md @@ -89,9 +89,9 @@ export OPENAI_API_KEY=sk-... **CLI flag** (highest precedence, per-invocation): ```sh -cinzel github assist --prompt "..." # uses ai.default -cinzel github assist --prompt "..." --provider openai # override for this call -cinzel github assist --prompt "..." --provider anthropic --model claude-opus-4-20250514 # override both +cinzel github assist --prompt "..." # uses default AI provider +cinzel github assist --prompt "..." --ai openai # override AI provider +cinzel github assist --prompt "..." --ai anthropic --model claude-opus-4-20250514 # override both ``` **Resolution order** (highest wins): diff --git a/docs/solutions/patterns/assist-pin-upgrade-feature-implementation.md b/docs/solutions/patterns/assist-pin-upgrade-feature-implementation.md index b4a1ca4..246a38b 100644 --- a/docs/solutions/patterns/assist-pin-upgrade-feature-implementation.md +++ b/docs/solutions/patterns/assist-pin-upgrade-feature-implementation.md @@ -17,7 +17,7 @@ Three new commands added to cinzel for AI-powered workflow generation and GitHub ## Commands ```sh -cinzel assist --provider github --prompt "golang PR with tests" # AI generates HCL +cinzel github assist --prompt "golang PR with tests" # AI generates HCL cinzel github pin # lock tags to SHAs cinzel github upgrade # bump to latest + pin cinzel github upgrade --parse # bump + regenerate YAML diff --git a/internal/command/command.go b/internal/command/command.go index 973f020..17a2a8a 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -32,6 +32,8 @@ func (cmd *Cli) Execute(osArgs []string, providers []provider.Provider) error { cmd.Cmd.Commands = append(cmd.Cmd.Commands, ap) } + cmd.Cmd.Commands = append(cmd.Cmd.Commands, cmd.initCommand()) + if err := cmd.Cmd.Run(context.Background(), osArgs); err != nil { _, _ = fmt.Fprintf(cmd.Writer, "%s\n", cinzelerror.New(err).Err.Error()) diff --git a/internal/command/init.go b/internal/command/init.go new file mode 100644 index 0000000..5f08408 --- /dev/null +++ b/internal/command/init.go @@ -0,0 +1,113 @@ +// Copyright 2026 YLD Limited +// SPDX-License-Identifier: Apache-2.0 + +package command + +import ( + "bufio" + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/urfave/cli/v3" +) + +const configTemplate = `# cinzel AI configuration +# API keys are stored here (never commit this file) + +ai: + default: %s + providers: + anthropic: + model: claude-sonnet-4-5-20250514 + api_key: "%s" + openai: + model: gpt-4o + api_key: "%s" +` + +func (cmd *Cli) initCommand() *cli.Command { + return &cli.Command{ + Name: "init", + Usage: "Create or update the cinzel configuration file", + Action: func(ctx context.Context, c *cli.Command) error { + configDir, err := configPath() + if err != nil { + return err + } + + configFile := filepath.Join(configDir, "config.yaml") + + if _, err := os.Stat(configFile); err == nil { + _, _ = fmt.Fprintf(cmd.Writer, "Config already exists at %s\n", configFile) + _, _ = fmt.Fprintf(cmd.Writer, "Overwrite? [y/N] ") + + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer != "y" && answer != "yes" { + return nil + } + } + } + + scanner := bufio.NewScanner(os.Stdin) + + _, _ = fmt.Fprintf(cmd.Writer, "Default AI provider (anthropic/openai) [anthropic]: ") + + defaultProvider := "anthropic" + + if scanner.Scan() { + if input := strings.TrimSpace(scanner.Text()); input != "" { + defaultProvider = input + } + } + + _, _ = fmt.Fprintf(cmd.Writer, "Anthropic API key (leave empty to skip): ") + + var anthropicKey string + + if scanner.Scan() { + anthropicKey = strings.TrimSpace(scanner.Text()) + } + + _, _ = fmt.Fprintf(cmd.Writer, "OpenAI API key (leave empty to skip): ") + + var openaiKey string + + if scanner.Scan() { + openaiKey = strings.TrimSpace(scanner.Text()) + } + + if err := os.MkdirAll(configDir, 0700); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + content := fmt.Sprintf(configTemplate, defaultProvider, anthropicKey, openaiKey) + + if err := os.WriteFile(configFile, []byte(content), 0600); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + absPath, _ := filepath.Abs(configFile) + _, _ = fmt.Fprintf(cmd.Writer, "\nConfig written to %s (permissions: 0600)\n", absPath) + _, _ = fmt.Fprintf(cmd.Writer, "You can also set keys via environment variables:\n") + _, _ = fmt.Fprintf(cmd.Writer, " export ANTHROPIC_API_KEY=sk-ant-...\n") + _, _ = fmt.Fprintf(cmd.Writer, " export OPENAI_API_KEY=sk-...\n") + + return nil + }, + } +} + +// configPath returns the OS-agnostic cinzel config directory. +func configPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("could not determine config directory: %w", err) + } + + return filepath.Join(dir, "cinzel"), nil +} From 839db840d06b194ea25be011140fcebf533749bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Wed, 18 Mar 2026 09:23:34 +0000 Subject: [PATCH 42/48] feat: wire config file into AI provider resolution --- internal/ai/config.go | 90 ++++++++++++++++++++++++++++++++++++++ internal/command/assist.go | 8 ++-- 2 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 internal/ai/config.go diff --git a/internal/ai/config.go b/internal/ai/config.go new file mode 100644 index 0000000..7a38154 --- /dev/null +++ b/internal/ai/config.go @@ -0,0 +1,90 @@ +// Copyright 2026 YLD Limited +// SPDX-License-Identifier: Apache-2.0 + +package ai + +import ( + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// Config represents the AI section of the cinzel config file. +type Config struct { + Default string `yaml:"default"` + Providers map[string]ProviderConfig `yaml:"providers"` +} + +// ProviderConfig holds per-provider settings from the config file. +type ProviderConfig struct { + Model string `yaml:"model"` + APIKey string `yaml:"api_key"` +} + +type configFile struct { + AI Config `yaml:"ai"` +} + +// LoadConfig reads the cinzel config file from os.UserConfigDir()/cinzel/config.yaml. +// Returns an empty Config (not an error) if the file doesn't exist. +func LoadConfig() Config { + dir, err := os.UserConfigDir() + if err != nil { + return Config{} + } + + path := filepath.Join(dir, "cinzel", "config.yaml") + + data, err := os.ReadFile(path) + if err != nil { + return Config{} + } + + var cfg configFile + if err := yaml.Unmarshal(data, &cfg); err != nil { + return Config{} + } + + return cfg.AI +} + +// ResolveProviderName returns the provider name to use, applying the +// resolution order: CLI flag > config default > "anthropic". +func (c Config) ResolveProviderName(cliFlag string) string { + if cliFlag != "" { + return cliFlag + } + + if c.Default != "" { + return c.Default + } + + return "anthropic" +} + +// ResolveAPIKey returns the API key for the given provider, applying the +// resolution order: env var > config file. +func (c Config) ResolveAPIKey(providerName string) string { + pc, ok := c.Providers[providerName] + if ok && pc.APIKey != "" { + return pc.APIKey + } + + return "" +} + +// ResolveModel returns the model for the given provider, applying the +// resolution order: CLI flag > config file > provider default. +func (c Config) ResolveModel(providerName, cliFlag string) string { + if cliFlag != "" { + return cliFlag + } + + pc, ok := c.Providers[providerName] + if ok && pc.Model != "" { + return pc.Model + } + + return "" +} diff --git a/internal/command/assist.go b/internal/command/assist.go index 16d3644..5b13f8f 100644 --- a/internal/command/assist.go +++ b/internal/command/assist.go @@ -51,10 +51,12 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { dryRun := c.Bool("dry-run") acknowledge := c.Bool("acknowledge") - aiName := c.String("ai") - model := c.String("model") + cfg := ai.LoadConfig() + aiName := cfg.ResolveProviderName(c.String("ai")) + model := cfg.ResolveModel(aiName, c.String("model")) + apiKey := cfg.ResolveAPIKey(aiName) - aiProvider, err := resolveAIProvider(aiName, "") + aiProvider, err := resolveAIProvider(aiName, apiKey) if err != nil { return err } From b8d34555ce520e9fbe73eefdabce6d8ffff67264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Wed, 18 Mar 2026 09:28:56 +0000 Subject: [PATCH 43/48] fix: let config file default override hardcoded --ai flag --- internal/command/assist.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/command/assist.go b/internal/command/assist.go index 5b13f8f..8ac183d 100644 --- a/internal/command/assist.go +++ b/internal/command/assist.go @@ -176,8 +176,8 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { }, &cli.StringFlag{ Name: "ai", - Value: "anthropic", - Usage: "AI provider: anthropic or openai", + Value: "", + Usage: "AI provider: anthropic or openai (default: from config or anthropic)", }, &cli.StringFlag{ Name: "model", From 51a2993c0b0827724b78135ba37fd411ed25bb0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Wed, 18 Mar 2026 09:36:54 +0000 Subject: [PATCH 44/48] docs: document config file support in CLAUDE.md and solution doc --- CLAUDE.md | 2 ++ ...sist-pin-upgrade-feature-implementation.md | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 62cb8be..0ce9a98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -148,6 +148,8 @@ Key order: - Different blocks with same signature get `// note:` comments - Auto-pins GitHub actions to SHAs after generation - Privacy: `StripHCLContext` replaces all string values with `"..."` via HCL AST walk +- Config: `cinzel init` creates `os.UserConfigDir()/cinzel/config.yaml` with AI provider defaults + API keys +- Resolution order: CLI flags > env vars > config file > hardcoded defaults # Version management (`cinzel github pin/upgrade`) diff --git a/docs/solutions/patterns/assist-pin-upgrade-feature-implementation.md b/docs/solutions/patterns/assist-pin-upgrade-feature-implementation.md index 246a38b..48f55e9 100644 --- a/docs/solutions/patterns/assist-pin-upgrade-feature-implementation.md +++ b/docs/solutions/patterns/assist-pin-upgrade-feature-implementation.md @@ -84,6 +84,26 @@ Two implementations: `Anthropic` and `OpenAI`. Shared `resolveAPIKey` helper. Er `GenerateResponse` includes `InputTokens`/`OutputTokens` — displayed after every generation. +## Configuration (`cinzel init`) + +`cinzel init` creates an interactive config file at `os.UserConfigDir()/cinzel/config.yaml` with `0600` permissions: + +```yaml +ai: + default: anthropic + providers: + anthropic: + model: claude-sonnet-4-5-20250514 + api_key: "sk-ant-..." + openai: + model: gpt-4o + api_key: "sk-..." +``` + +Resolution order (highest wins): CLI flags (`--ai`, `--model`) > env vars (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) > config file > hardcoded defaults. + +`LoadConfig()` in `internal/ai/config.go` reads the file silently — missing file returns empty config, no error. + ## Review findings addressed Three rounds of parallel reviews (architecture, security, simplicity, performance, pattern recognition) produced these fixes: @@ -109,8 +129,10 @@ Three rounds of parallel reviews (architecture, security, simplicity, performanc | `internal/ai` | `anthropic.go` | Anthropic SDK wrapper | | `internal/ai` | `openai.go` | OpenAI SDK wrapper | | `internal/ai` | `strip.go` | HCL string stripping for privacy | +| `internal/ai` | `config.go` | Config file loading and resolution | | `internal/ai` | `errors.go` | Sentinel errors | | `internal/command` | `assist.go` | Assist pipeline, mergeHCLFiles, YAML splitting | +| `internal/command` | `init.go` | `cinzel init` interactive config setup | | `internal/command` | `pin.go` | Pin CLI command | | `internal/command` | `upgrade.go` | Upgrade CLI command with --parse | | `internal/command` | `errors.go` | CLI sentinel errors | From 4e35d33a398f0b2a794fb698f85eb36279d4722f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Wed, 18 Mar 2026 09:39:28 +0000 Subject: [PATCH 45/48] chore: upgrade openai-go to v3.29.0 --- go.sum | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go.sum b/go.sum index 8096d07..4549dec 100644 --- a/go.sum +++ b/go.sum @@ -295,8 +295,8 @@ github.com/onsi/ginkgo/v2 v2.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0 github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM= github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU= github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= -github.com/openai/openai-go/v3 v3.28.0 h1:2+FfrCVMdGXSQrBv1tLWtokm+BU7+3hJ/8rAHPQ63KM= -github.com/openai/openai-go/v3 v3.28.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/openai/openai-go/v3 v3.29.0 h1:dZNJ0w7DxwpgppzKQjSKfLebW27KrtGqgSy4ipJS0U8= +github.com/openai/openai-go/v3 v3.29.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= From b1bb25c30204d82c921c429fbe9b65766fd8095b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Wed, 18 Mar 2026 09:41:50 +0000 Subject: [PATCH 46/48] chore: upgrade openai-go to v3.29.0 --- go.sum | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go.sum b/go.sum index 4549dec..8096d07 100644 --- a/go.sum +++ b/go.sum @@ -295,8 +295,8 @@ github.com/onsi/ginkgo/v2 v2.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0 github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM= github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU= github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= -github.com/openai/openai-go/v3 v3.29.0 h1:dZNJ0w7DxwpgppzKQjSKfLebW27KrtGqgSy4ipJS0U8= -github.com/openai/openai-go/v3 v3.29.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/openai/openai-go/v3 v3.28.0 h1:2+FfrCVMdGXSQrBv1tLWtokm+BU7+3hJ/8rAHPQ63KM= +github.com/openai/openai-go/v3 v3.28.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= From 7306357e430c137e1b3b959f1c473b2db087320c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Wed, 18 Mar 2026 09:44:11 +0000 Subject: [PATCH 47/48] chore: upgrade openai-go to v3.29.0 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 85b5214..8dfccb9 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/anthropics/anthropic-sdk-go v1.27.0 github.com/goccy/go-yaml v1.19.2 github.com/hashicorp/hcl/v2 v2.24.0 - github.com/openai/openai-go/v3 v3.28.0 + github.com/openai/openai-go/v3 v3.29.0 github.com/urfave/cli/v3 v3.7.0 github.com/zclconf/go-cty v1.18.0 github.com/zclconf/go-cty-yaml v1.2.0 diff --git a/go.sum b/go.sum index 8096d07..4549dec 100644 --- a/go.sum +++ b/go.sum @@ -295,8 +295,8 @@ github.com/onsi/ginkgo/v2 v2.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0 github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM= github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU= github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= -github.com/openai/openai-go/v3 v3.28.0 h1:2+FfrCVMdGXSQrBv1tLWtokm+BU7+3hJ/8rAHPQ63KM= -github.com/openai/openai-go/v3 v3.28.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/openai/openai-go/v3 v3.29.0 h1:dZNJ0w7DxwpgppzKQjSKfLebW27KrtGqgSy4ipJS0U8= +github.com/openai/openai-go/v3 v3.29.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= From 8e910b18742473c71beb53c2f6eedf3aae701381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Wed, 18 Mar 2026 10:01:43 +0000 Subject: [PATCH 48/48] fix: show privacy notice before cost confirmation --- internal/command/assist.go | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/internal/command/assist.go b/internal/command/assist.go index 8ac183d..3002a2e 100644 --- a/internal/command/assist.go +++ b/internal/command/assist.go @@ -61,14 +61,6 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { return err } - if !acknowledge { - if err := confirmCost(cmd.Writer, os.Stdin, aiProvider.Name(), model); err != nil { - return err - } - } - - _, _ = fmt.Fprintf(cmd.Writer, "Generating workflow...\n") - systemPrompt := ai.SystemPrompt(p.GetProviderName()) noContext := c.Bool("no-context") @@ -85,6 +77,7 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { hclContext, truncated := ai.StripHCLContext(contextDir) if hclContext != "" { + _, _ = fmt.Fprintf(cmd.Writer, "Including existing HCL structure as context (string values stripped for privacy). Use --no-context to skip sending existing HCL to the AI provider.\n") systemPrompt += "\n\nExisting HCL structure (values stripped for privacy):\n\n" + hclContext } @@ -93,9 +86,19 @@ func (cmd *Cli) assistCommand(p provider.Provider) *cli.Command { } } + if !acknowledge { + if err := confirmCost(cmd.Writer, os.Stdin, aiProvider.Name(), model); err != nil { + return err + } + } + + _, _ = fmt.Fprintf(cmd.Writer, "Generating workflow...\n") + userPrompt := prompt if refine != "" { + _, _ = fmt.Fprintf(cmd.Writer, "Including previous assist output as context (string values stripped for privacy).\n") + refinedSystem, refinedUser, err := buildRefinePrompt(refine, prompt, outputDir, c.String("from")) if err != nil { return err