Skip to content

feat: skillctl MVP — CLI with lifecycle management#1

Merged
pavelanni merged 21 commits intomainfrom
feature/skillctl-mvp
Apr 17, 2026
Merged

feat: skillctl MVP — CLI with lifecycle management#1
pavelanni merged 21 commits intomainfrom
feature/skillctl-mvp

Conversation

@pavelanni
Copy link
Copy Markdown
Collaborator

@pavelanni pavelanni commented Apr 16, 2026

Summary

  • Implements skillctl CLI with 10 commands: validate, pack, push, pull, inspect, list/ls, promote, install, prune
  • Skills are packaged as standard OCI images (not ORAS artifacts) — compatible with podman, skopeo, crane, and K8s ImageVolumes
  • Lifecycle state machine: draft → testing → published → deprecated → archived
  • Status tracked via OCI manifest annotations (image content stays immutable across promotions)
  • Library-first architecture: core logic in pkg/ (skillcard, oci, lifecycle), CLI is a thin consumer
  • install command deploys skills to agent-specific directories (Claude, Cursor, Windsurf, OpenCode, OpenClaw)

Key design decisions

  • OCI images, not ORAS artifacts: ensures compatibility with standard container tooling and K8s ImageVolumes
  • Status in annotations: promotion changes the manifest digest but not the layer digest — content is immutable
  • apiVersion skillimage.io/v1alpha1: project-owned domain, vendor-neutral
  • -testing tag suffix instead of -rc: clearer for our audience, valid semver pre-release label

Test plan

  • make test — all unit tests pass (skillcard, lifecycle, oci packages)
  • make lint — golangci-lint clean
  • Full workflow: validatepacklistinspectpromote (draft→testing→published) → prune
  • install --target claude deploys skill to ~/.claude/skills/
  • pull with local-looking ref shows helpful error pointing to install

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Introduced skillctl command-line tool for creating, validating, and managing AI agent skills as OCI images.
    • Added skill lifecycle management with state transitions: draft → testing → published → deprecated → archived.
    • Implemented commands for pack, push, pull, inspect, promote, install, list, validate, and prune operations.
  • Documentation

    • Added comprehensive README documenting the OCI Skill Registry framework and usage workflows.
  • Examples

    • Added hello-world example skill demonstrating skill creation format and structure.

- Add SkillCard v1 JSON Schema with validation rules
- Create schemas/embed.go for embedding schema at build time
- Add hello-world example skill for testing and documentation
- Initialize go.sum (dependencies will be added in subsequent tasks)

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Pavel Anni <panni@redhat.com>
Implements SkillCard Go types matching the YAML schema from Task 1.
Provides Parse() to read YAML from io.Reader and Serialize() to write
YAML to io.Writer. Uses gopkg.in/yaml.v3 with strict field checking
(KnownFields=true) to catch typos in SkillCard YAML.

Test coverage includes valid YAML parsing, invalid YAML error handling,
and roundtrip serialization.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Pavel Anni <panni@redhat.com>
Implements validation against the embedded JSON Schema and additional
semantic version validation using Masterminds/semver. Returns structured
validation errors with field paths and messages for better UX.

Signed-off-by: Pavel Anni <panni@redhat.com>
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Pavel Anni <panni@redhat.com>
Add cobra-based CLI framework with skillctl validate command.
The validate command checks SkillCard YAML files against the
JSON Schema and semantic rules (semver, name patterns).

Supports both directory and file paths as input. When given
a directory, automatically looks for skill.yaml within it.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Pavel Anni <panni@redhat.com>
Signed-off-by: Pavel Anni <panni@redhat.com>
Implements the lifecycle state machine for skill versioning:
- Five states: draft, testing, published, deprecated, archived
- Valid transitions: draft->testing->published->deprecated->archived
- OCI tag rules per state (draft: v-draft, testing: v-rc, published: v)
- State parsing and validation

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Pavel Anni <panni@redhat.com>
Implement the core OCI package using oras-go v2 to build standard OCI
images from skill directories. Pack reads a skill directory, validates
the SkillCard, creates a tar.gz layer, builds an OCI image manifest
with proper config and annotations, and stores in a local OCI layout.
ListLocal reads tags from the store and returns image metadata.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Pavel Anni <panni@redhat.com>
Add thin CLI wrappers around pkg/oci.Client for local pack and list
operations. The pack command creates OCI images from skill directories
and stores them in ~/.local/share/skillctl/store. The images command
displays all locally packed skills.

Also fix missing crypto/sha256 import in pkg/oci/pack.go to register
the hash algorithm for go-digest.

Signed-off-by: Pavel Anni <panni@redhat.com>

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Pavel Anni <panni@redhat.com>
Signed-off-by: Pavel Anni <panni@redhat.com>
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Pavel Anni <panni@redhat.com>
Implement push and pull commands for skillctl:
- push: push a skill image from local store to remote registry
- pull: pull a skill image from remote registry to local store
- pull supports -o flag to unpack skill files to a directory

These are thin wrappers around pkg/oci.Client.Push and Pull.

Signed-off-by: Pavel Anni <panni@redhat.com>
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Pavel Anni <panni@redhat.com>
Implements OCI inspect operation and skillctl inspect command to show
detailed SkillCard metadata and OCI image details for locally stored
skill images. Extracts annotations from manifest and computes layer
statistics.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Pavel Anni <panni@redhat.com>
Implement OCI promote operation that transitions a skill image
through lifecycle states (draft -> testing -> published ->
deprecated -> archived) by updating manifest annotations and
tags while keeping image layers immutable.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Pavel Anni <panni@redhat.com>
Signed-off-by: Pavel Anni <panni@redhat.com>
The -testing suffix is more descriptive than -rc for our audience.
Semver allows any alphanumeric pre-release label. Also added the
TAG column to skillctl images so users see the actual ref to use.

Updated design docs and README to match.

Signed-off-by: Pavel Anni <panni@redhat.com>
- Renamed 'skillctl images' to 'skillctl list' with 'ls' alias
- Added 'skillctl prune' to remove images superseded by promotion
- Extracted imageFromManifest helper for shared manifest reading

Signed-off-by: Pavel Anni <panni@redhat.com>
Install unpacks skills from local store to agent-specific
directories. Supports --target (claude, cursor, windsurf,
opencode, openclaw) and -o for custom paths. Pull now detects
local-looking refs and suggests install instead.

Signed-off-by: Pavel Anni <panni@redhat.com>
- Go module: github.com/redhat-et/oci-skill-registry -> github.com/redhat-et/skillimage
- apiVersion: skills.redhat.io/v1alpha1 -> skillimage.io/v1alpha1
- JSON Schema $id updated to skillimage.io
- CLI binary stays as skillctl
- Domains skillimage.io and skillimage.dev purchased

Signed-off-by: Pavel Anni <panni@redhat.com>
@github-advanced-security
Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

Comment thread pkg/oci/pull.go Fixed
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 16, 2026

Warning

Rate limit exceeded

@pavelanni has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 38 minutes and 16 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 38 minutes and 16 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a2284c8a-f657-45e8-bd9e-b68413504a6b

📥 Commits

Reviewing files that changed from the base of the PR and between 4729fcf and 0f8441a.

📒 Files selected for processing (7)
  • README.md
  • internal/cli/pull.go
  • pkg/oci/annotations.go
  • pkg/oci/pack.go
  • pkg/oci/promote.go
  • pkg/oci/prune.go
  • pkg/oci/pull.go
📝 Walkthrough

Walkthrough

Introduces a complete OCI skill registry framework called "skillimage" with a CLI tool (skillctl), core packages for lifecycle management and OCI operations, and skill validation. The module path changed from oci-skill-registry to skillimage. Lifecycle tag patterns updated from -rc to -testing, and status annotation key changed from io.skillregistry.status to io.skillimage.status.

Changes

Cohort / File(s) Summary
Project Configuration
.gitignore, go.mod, README.md
Updated module path to skillimage, fixed gitignore pattern, added comprehensive project documentation covering framework design, CLI usage, skill packaging format, and lifecycle workflows.
CLI Entrypoint & Root Command
cmd/skillctl/main.go, internal/cli/root.go, internal/cli/helpers.go
Implemented CLI executable with version handling and root command factory. Added client initialization helpers for OCI store directory management using XDG standards.
CLI Command Implementations
internal/cli/validate.go, internal/cli/pack.go, internal/cli/push.go, internal/cli/pull.go, internal/cli/list.go, internal/cli/inspect.go, internal/cli/promote.go, internal/cli/install.go, internal/cli/prune.go
Added nine subcommands for skill validation, packing to OCI images, registry push/pull operations, local image listing/inspection, lifecycle promotion, installation to agent directories, and local store pruning.
OCI Client Core
pkg/oci/client.go, pkg/oci/annotations.go, pkg/oci/pack.go
Implemented OCI client abstraction wrapping ORAS store, annotation builder deriving metadata from SkillCards, and pack operation converting skill directories to OCI layer blobs with manifest creation and local storage.
OCI Registry Operations
pkg/oci/push.go, pkg/oci/pull.go, pkg/oci/inspect.go
Implemented push/pull operations with remote repository creation and ORAS copying, pull with optional tar extraction, and inspect for retrieving image metadata with layer enumeration.
OCI Lifecycle Management
pkg/oci/promote.go, pkg/oci/prune.go
Implemented atomic promotion across draft→testing→published→deprecated→archived states with manifest annotation and tag updates, and local storage pruning by keeping highest-ranked state per name+version group.
SkillCard & Validation
pkg/skillcard/skillcard.go, pkg/skillcard/validate.go, schemas/skillcard-v1.json, schemas/embed.go
Defined SkillCard data model with YAML parse/serialize, JSON Schema validation with semantic-version checking, and embedded JSON Schema definition enforcing required metadata and structure.
Lifecycle State Management
pkg/lifecycle/lifecycle.go
Implemented lifecycle state constants (draft/testing/published/deprecated/archived), transition validation, state-to-tag mapping with version suffixes, and state parsing utilities.
Test Suites
pkg/oci/oci_test.go, pkg/skillcard/skillcard_test.go, pkg/lifecycle/lifecycle_test.go
Added comprehensive table-driven tests for pack/list/copy/unpack/inspect/promote operations, SkillCard parsing/serialization/validation, and lifecycle transitions/tag formatting.
Example Skill & Design Docs
examples/hello-world/skill.yaml, examples/hello-world/SKILL.md, docs/design/*, docs/superpowers/plans/*
Added hello-world example skill demonstrating SkillCard metadata and prompt format. Updated design and implementation specs to reflect -testing tag pattern and skillimage.io/v1alpha1 API version.

Sequence Diagrams

sequenceDiagram
    participant CLI as skillctl pack
    participant Client as OCI Client
    participant Validator as SkillCard Validator
    participant Store as Local OCI Store
    
    CLI->>Client: Pack(skillDir, opts)
    Client->>Validator: Read & Parse skill.yaml
    Validator->>Validator: Validate SkillCard
    Validator-->>Client: SkillCard + SKILL.md path
    
    Client->>Client: Create tar.gz layer from dir
    Client->>Store: Push layer blob
    Store-->>Client: Layer digest
    
    Client->>Client: Build image config
    Client->>Store: Push config blob
    Store-->>Client: Config digest
    
    Client->>Client: Build OCI manifest with annotations
    Client->>Store: Push manifest blob
    Store-->>Client: Manifest digest
    
    Client->>Store: Tag manifest as namespace/name:version-draft
    Store-->>Client: Success
    
    Client-->>CLI: Manifest descriptor + digest
Loading
sequenceDiagram
    participant CLI as skillctl push/pull
    participant Client as OCI Client
    participant Store as Local OCI Store
    participant Registry as Remote Registry
    
    alt Push Flow
        CLI->>Client: Push(ref, opts)
        Client->>Registry: Create remote repository
        Registry-->>Client: Repository connected
        Client->>Store: Resolve image in local store
        Store-->>Client: Manifest descriptor
        Client->>Registry: Copy image via ORAS
        Registry-->>Client: Copy complete
        Client->>Store: Tag as ref
        Store-->>Client: Success
        Client-->>CLI: Success
    else Pull Flow
        CLI->>Client: Pull(ref, opts)
        Client->>Registry: Create remote repository
        Registry-->>Client: Repository connected
        Client->>Registry: Fetch image via ORAS
        Registry-->>Client: Manifest + layers
        Client->>Store: Push to local store
        Store-->>Client: Image stored
        Client->>Client: Extract to OutputDir if set
        Client-->>CLI: Descriptor + unpack status
    end
Loading
sequenceDiagram
    participant CLI as skillctl promote
    participant Client as OCI Client
    participant Store as Local OCI Store
    participant Registry as Remote Registry
    
    alt Local Promotion
        CLI->>Client: PromoteLocal(ref, testing)
        Client->>Store: Resolve ref to manifest
        Store-->>Client: Manifest descriptor
        Client->>Store: Fetch manifest JSON
        Store-->>Client: Manifest content
        Client->>Client: Parse lifecycle status from annotation
        Client->>Client: Validate draft→testing transition
        Client->>Client: Update status annotation
        Client->>Store: Push updated manifest
        Store-->>Client: New manifest digest
        Client->>Store: Tag as version-testing
        Store-->>Client: Tagged
        Client-->>CLI: Promotion complete
    else Remote Promotion
        CLI->>Client: Promote(ref, testing)
        Client->>Registry: Create remote repository
        Client->>Registry: Resolve ref
        Client->>Registry: Fetch manifest
        Client->>Client: Update annotations
        Client->>Registry: Push updated manifest
        Client->>Registry: Tag with new state tag
        Client-->>CLI: Promotion complete
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.31% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: skillctl MVP — CLI with lifecycle management' clearly summarizes the main change: adding a CLI tool with lifecycle management capabilities for the skillctl project.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/skillctl-mvp

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Signed-off-by: Pavel Anni <panni@redhat.com>
Fixes:
- #2: Tag stripping now handles registry ports correctly
  (splitRefTag parses after last / to avoid port:tag confusion)
- #4: File permissions from tar clamped to 0755/0644 max
- #6: JSON Schema $id updated to skillimage.io
- #7: Annotation key changed from io.skillregistry to io.skillimage
- #10: validate no longer calls os.Exit(1) inside RunE
- #11: Removed unused flagFormat/flagVerbose declarations
- #12: tabwriter now uses cmd.OutOrStdout() instead of os.Stdout
- #15: Moved defaultClient/defaultStoreDir to helpers.go
- #16: Added --version flag via Cobra's Version field

Declined:
- #1: Platform stays amd64/linux — K8s ImageVolumes require valid
  platform; skill/skill would break mounting
- #8: init() panic is standard Go for embedded resources
- #13: Configurable timestamps deferred to reproducible builds phase

Signed-off-by: Pavel Anni <panni@redhat.com>
@pavelanni pavelanni marked this pull request as ready for review April 17, 2026 00:31
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/superpowers/plans/2026-04-16-skillctl-mvp.md (1)

954-975: ⚠️ Potential issue | 🟡 Minor

Plan doc is inconsistent with the new -testing tag convention.

This plan file still documents the Testing state tag as <version>-rc in multiple places, but the implementation in pkg/lifecycle/lifecycle.go (TagForState returns version + "-testing") and the updated design spec use -testing. Affected locations in this file include:

  • Line 961: {"1.2.0", lifecycle.Testing, "1.2.0-rc"} in TestTagForState
  • Line 1055: the inline TagForState implementation returns version + "-rc" for Testing
  • Lines 1171–1172: ListLocal test expects tag 1.0.0-rc... actually that one checks 1.0.0-draft, but promote tests at lines 2523, 2532, 2537, 2798, 2833–2834 all reference 1.0.0-rc
  • Lines 1859, 1865 in the Unpack test reference test/test-skill:1.0.0-rc

Anyone following this plan verbatim will produce tests that fail against the shipped lifecycle package. Please update all -rc occurrences to -testing for consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/superpowers/plans/2026-04-16-skillctl-mvp.md` around lines 954 - 975,
Update the plan doc tests and inline implementations to match the shipped
lifecycle convention that uses "-testing" (not "-rc"): replace the "1.2.0-rc"
expectation in TestTagForState, change the inline TagForState implementation
that returns version + "-rc" to return version + "-testing", and update all
other test expectations referenced (ListLocal, promote tests, Unpack tests,
etc.) that currently expect "1.0.0-rc" or similar to "1.0.0-testing" so the plan
matches the actual pkg/lifecycle.TagForState behavior.
🧹 Nitpick comments (10)
internal/cli/helpers.go (1)

18-32: Use filepath.Join instead of string concatenation for path construction.

home + "/.local/share" and dataDir + "/skillctl/store" hardcode forward slashes. While this happens to work on Linux/macOS and skillctl is primarily a Unix tool, using filepath.Join is idiomatic Go and avoids any surprises if XDG_DATA_HOME ever contains a trailing slash (which would produce a double-slash path).

Proposed fix
 import (
 	"fmt"
 	"os"
+	"path/filepath"

 	"github.com/redhat-et/skillimage/pkg/oci"
 )
@@
 func defaultStoreDir() (string, error) {
 	dataDir := os.Getenv("XDG_DATA_HOME")
 	if dataDir == "" {
 		home, err := os.UserHomeDir()
 		if err != nil {
 			return "", fmt.Errorf("finding home directory: %w", err)
 		}
-		dataDir = home + "/.local/share"
+		dataDir = filepath.Join(home, ".local", "share")
 	}
-	dir := dataDir + "/skillctl/store"
+	dir := filepath.Join(dataDir, "skillctl", "store")
 	if err := os.MkdirAll(dir, 0o755); err != nil {
 		return "", fmt.Errorf("creating store directory: %w", err)
 	}
 	return dir, nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cli/helpers.go` around lines 18 - 32, The defaultStoreDir function
builds filesystem paths by concatenating strings; replace those concatenations
with filepath.Join to construct paths portably and correctly (handle
XDG_DATA_HOME and home dir cases). Specifically, in defaultStoreDir use
filepath.Join(home, ".local", "share") instead of home + "/.local/share" and
filepath.Join(dataDir, "skillctl", "store") instead of dataDir +
"/skillctl/store"; ensure you import "path/filepath" and keep the same error
handling and permissions when calling os.MkdirAll.
pkg/oci/client.go (1)

15-24: Wrap oci.New error with context.

Every other error in this package is wrapped with fmt.Errorf(... %w ...); this one propagates the raw error, so callers see a bare message without knowing which store path failed. Minor, but a small improvement for debuggability.

Proposed fix
 func NewClient(storePath string) (*Client, error) {
 	store, err := oci.New(storePath)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("opening OCI store at %s: %w", storePath, err)
 	}
 	return &Client{
 		store:     store,
 		storePath: storePath,
 	}, nil
 }

(Requires adding "fmt" to imports.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/oci/client.go` around lines 15 - 24, The NewClient function returns the
raw error from oci.New; wrap that error with context including the failing
storePath so callers can see which path failed. Update NewClient (function
NewClient and the call to oci.New) to return fmt.Errorf("creating OCI store at
%s: %w", storePath, err) instead of returning err directly and add the "fmt"
import.
internal/cli/pack.go (1)

25-37: Use cmd.Context() instead of context.Background().

Cobra propagates a context (signal-cancellable when set up) via cmd.Context(). Using context.Background() here (and in push.go/promote.go) prevents future cancellation/signal handling from working. Low priority but worth fixing consistently across the new CLI commands.

Proposed fix
-	desc, err := client.Pack(context.Background(), dir, oci.PackOptions{Tag: tag})
+	desc, err := client.Pack(cmd.Context(), dir, oci.PackOptions{Tag: tag})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cli/pack.go` around lines 25 - 37, The runPack function (and
analogous functions in push.go/promote.go) currently calls client.Pack with
context.Background(), which bypasses Cobra's cancellable command context;
replace context.Background() with cmd.Context() in the client.Pack call inside
runPack (and similarly update client.Push/client.Promote calls in
runPush/runPromote) so the command respects cancellation/signals propagated by
Cobra's command context.
internal/cli/promote.go (1)

40-65: Consider validating the transition before calling out to the registry.

lifecycle.ParseState only validates that to is a known state, not that the transition from the current state is valid. For the remote (non---local) path, an invalid transition will only be detected after a network round-trip. If pkg/lifecycle exposes a transition validator, consider using it earlier; otherwise, this is fine for the MVP.

Also: pass cmd.Context() instead of context.Background() so signal cancellation works.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cli/promote.go` around lines 40 - 65, The runPromote function
currently only validates that toState is a known state via lifecycle.ParseState
and uses context.Background(); update it to validate the proposed transition
before making remote calls (use any transition validator exposed by
pkg/lifecycle — e.g., lifecycle.IsValidTransition or
lifecycle.ValidateTransition if available) and return an error early for invalid
transitions, and replace context.Background() with cmd.Context() so
cancellation/signals propagate; ensure both client.PromoteLocal(ctx, ref, to)
and client.Promote(ctx, ref, to, ...) use the new ctx and that the transition
check runs before either call.
pkg/oci/inspect.go (1)

23-37: Minor: unchecked rc.Close (matches golangci-lint errcheck hint).

Since the body has already been read successfully, a Close error is informational only, but wiring it through keeps the linter happy and mirrors typical Go idiom for read-then-close of small payloads.

🔧 Suggested tweak
-	rc, err := c.store.Fetch(ctx, desc)
-	if err != nil {
-		return nil, fmt.Errorf("fetching manifest: %w", err)
-	}
-	defer rc.Close()
-
-	manifestBytes, err := io.ReadAll(rc)
+	rc, err := c.store.Fetch(ctx, desc)
+	if err != nil {
+		return nil, fmt.Errorf("fetching manifest: %w", err)
+	}
+	manifestBytes, err := io.ReadAll(rc)
+	_ = rc.Close()
 	if err != nil {
 		return nil, fmt.Errorf("reading manifest: %w", err)
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/oci/inspect.go` around lines 23 - 37, The code currently defers
rc.Close() and ignores its return value; replace the blind defer with an
explicit close after reading the body so any Close error is captured and
returned (i.e., remove defer rc.Close(), call err = io.ReadAll(rc) as now, then
after that call do if cerr := rc.Close(); cerr != nil { return nil,
fmt.Errorf("closing manifest reader: %w", cerr) } before unmarshalling). Refer
to the Fetch call that yields rc, the rc variable, io.ReadAll(manifestBytes),
and the json.Unmarshal(manifestBytes, &manifest) sequence when making this
change.
pkg/skillcard/skillcard_test.go (1)

162-162: Don't discard Validate's error return in tests.

At lines 162, 185, and 211, the error from skillcard.Validate(sc) is dropped with _. If Validate ever fails internally (e.g., YAML marshal error on a refactor), these tests would silently still inspect errs and give misleading results. A t.Fatalf on non-nil error would make failures actionable.

Also applies to: 185-185, 211-211

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/skillcard/skillcard_test.go` at line 162, The tests call
skillcard.Validate(sc) but ignore its error return; update each test invocation
(the ones calling skillcard.Validate(sc) that assign errs, _ := ...) to capture
the error and fail the test on non-nil (e.g., errs, err :=
skillcard.Validate(sc); if err != nil { t.Fatalf("Validate returned error: %v",
err) }) before asserting on errs so internal failures (like YAML marshal errors)
surface immediately.
internal/cli/install.go (1)

89-96: Duplicates skillNameFromRef logic in pkg/oci/pull.go.

The exact same tag/path stripping lives in skillNameFromRef (pkg/oci/pull.go:93-104). Export it (e.g., oci.SkillNameFromRef) and reuse it here so the two implementations can't drift — especially important since Unpack uses its version to pick the destination directory (filepath.Join(outputDir, skillName) at line 98 must match what Unpack actually created).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cli/install.go` around lines 89 - 96, The skill name extraction
logic in internal/cli/install.go duplicates skillNameFromRef from
pkg/oci/pull.go; export the shared implementation (e.g., rename to
SkillNameFromRef in the oci package) and call oci.SkillNameFromRef(ref) from
install.go instead of reimplementing the parsing there so the value used for
filepath.Join(outputDir, skillName) matches Unpack's behavior; update any
imports/usages to reference the exported function and remove the duplicated code
in install.go.
pkg/oci/prune.go (1)

39-69: Parse status once per image.

Each image's Status is parsed twice (first loop for computing maxRank, second for filtering). A single pass that records (img, rank) pairs would avoid the duplication and make the skip-on-parse-error behavior explicit/consistent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/oci/prune.go` around lines 39 - 69, The code currently calls
lifecycle.ParseState(img.Status) twice per image (once to compute maxRank and
again to decide removal), causing duplicate work and inconsistent skip behavior;
refactor the loops in the prune logic to a single pass that records each image's
parse result and its rank (e.g., build a slice or map of (LocalImage -> rank)
while skipping images that fail ParseState), compute maxRank per group from
those recorded ranks, then iterate the recorded pairs to call c.store.Untag(ctx,
fullRef) for images whose recorded rank < maxRank; update references to removed,
groups, stateRank, lifecycle.ParseState, and c.store.Untag so parsing is done
exactly once per img and skip-on-parse-error is explicit.
pkg/oci/pull.go (2)

149-149: Unchecked Close() returns (flagged by golangci-lint/errcheck).

  • Line 149: defer gz.Close() — for a gzip Reader, Close can report trailing-data/checksum issues; at minimum, assign to _ explicitly or wrap to satisfy the linter.
  • Line 180: f.Close() on the error path is unchecked, which is acceptable here since we're already returning an error, but the linter will still flag it. Use _ = f.Close() to make the intent explicit.
🛠 Minimal fix
-	defer gz.Close()
+	defer func() { _ = gz.Close() }()
...
-			if _, err := io.Copy(f, tr); err != nil {
-				f.Close()
-				return fmt.Errorf("writing file %s: %w", target, err)
-			}
-			f.Close()
+			if _, err := io.Copy(f, tr); err != nil {
+				_ = f.Close()
+				return fmt.Errorf("writing file %s: %w", target, err)
+			}
+			if err := f.Close(); err != nil {
+				return fmt.Errorf("closing file %s: %w", target, err)
+			}

Also applies to: 180-180

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/oci/pull.go` at line 149, The linter flags unchecked Close() calls for
the gzip reader and file; update the defer and explicit close sites to make
intent explicit: change the deferred gz.Close() to capture or discard the
returned error (e.g., use _ = gz.Close() or wrap it in a closure that assigns
the error) and replace the unchecked f.Close() on the error path with an
explicit discard assignment (_ = f.Close()); locate the calls to gz.Close() and
f.Close() in this file (symbols gz and f in the pull handling function) and
apply those changes so errcheck/golangci-lint is satisfied.

166-184: Unsupported tar entry types are silently skipped.

The switch only handles TypeDir and TypeReg. Symlinks, hardlinks, and other types are dropped without warning. For skill packages this is probably fine (and safer than supporting symlinks through sanitizeTarPath), but consider either (a) returning an explicit error on unexpected types so malformed/malicious layers fail loudly, or (b) at least documenting the intent in the comment above.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/oci/pull.go` around lines 166 - 184, The extraction switch handling
header.Typeflag in pkg/oci/pull.go currently only handles tar.TypeDir and
tar.TypeReg and silently skips other entry types; add a default case to the
switch that returns an explicit error (e.g., fmt.Errorf("unsupported tar entry
type %v for %s", header.Typeflag, header.Name)) so unexpected types cause the
pull to fail loudly, and ensure the error bubbles up from the enclosing function
that performs the tar extraction (the block that uses header, tr, io.Copy,
os.OpenFile, clampDirMode, clampFileMode, sanitizeTarPath).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/cli/install.go`:
- Around line 47-48: The output path flag (outputDir set via
cmd.Flags().StringVarP) can contain an unexpanded leading tilde so when users
pass "~" or "~/..." it becomes a literal folder; update the command handler that
reads outputDir (the Run/PreRun where outputDir is used) to detect a leading "~"
or "~/" and replace it with the current user home from os.UserHomeDir(),
preserving the rest of the path and using filepath.Clean; ensure this
normalization happens before any mkdir/create or path joins that use outputDir
so quoted or programmatic flag values behave like shell-expanded ones.

In `@internal/cli/pull.go`:
- Around line 35-43: The looksLocal heuristic is too strict and misclassifies
short-hostname registries; change looksLocal(ref string) to treat any ref
containing a '/' as remote by returning len(strings.SplitN(ref, "/", 2)) < 2 and
remove the dot/colon checks on parts[0]; this keeps rejecting single-segment
refs like "myskill:1.0" while allowing "localhost/foo:tag" or
"registry/foo:tag". Update the looksLocal function (referenced by runPull)
accordingly and adjust/add tests if present to cover hostname-without-dot cases.

In `@pkg/oci/annotations.go`:
- Around line 26-31: The current byte-slice truncation of the description
(variable desc) before assigning to ann[ocispec.AnnotationDescription] can cut a
UTF-8 rune and produce invalid UTF-8; update the truncation logic in
pkg/oci/annotations.go (where desc is set from sc.Metadata.Description) to
truncate on a rune boundary instead of raw bytes — either convert to []rune and
limit the rune count to 256 runes, or trim the 256-byte substring back to the
previous valid UTF-8 boundary using utf8.ValidString (or
utf8.DecodeLastRuneInString) so the final desc is always valid UTF-8 before
assigning to the annotation.
- Around line 14-16: Update the function comment for buildAnnotations to reflect
the renamed annotation key: change the phrase "skillregistry status annotation"
to "skillimage status annotation" so it matches lifecycle.StatusAnnotation and
the current io.skillimage.status name; ensure the comment mentions
buildAnnotations and lifecycle.StatusAnnotation to make the connection clear.

In `@pkg/oci/pack.go`:
- Around line 220-254: The walk callback that uses tar.FileInfoHeader in the
anonymous func passed to filepath.Walk currently produces empty Linkname for
symlinks and may try to read non-regular files; update that callback to (1)
detect symlinks via info.Mode()&os.ModeSymlink and, for symlinks, call
os.Readlink(path) and pass the link target to tar.FileInfoHeader (set
header.Linkname) before tw.WriteHeader, and (2) explicitly skip non-regular file
types (sockets, devices, pipes) by checking ModeType bits (e.g.,
!info.Mode().IsRegular() && !(symlink case handled)) and returning nil (or
filepath.SkipDir for directories as already done) so you don't call
os.Open/io.Copy on unsupported entries. Ensure header.Name still uses
filepath.ToSlash(rel).
- Around line 31-35: Replace the plain deferred Close calls with explicit
error-suppression closures to satisfy errcheck: for the file handle opened as f
when reading skillPath (defer f.Close()), the reader rc closed at the later read
(defer rc.Close()), and the other deferred f.Close() in the loop, change each to
a deferred anonymous func that assigns the Close error to the blank identifier
(e.g., defer func() { _ = <var>.Close() }()) so the code keeps the read-only
close but silences errcheck; locate these variables (f and rc) in
pkg/oci/pack.go and apply the same pattern to all three defer calls.
- Around line 256-265: The callback in pkg/oci/pack.go opens files during
filepath.Walk and uses defer f.Close(), which keeps all FDs open until the walk
finishes; change this so each file is closed immediately after copying by
scoping the open/copy/close in an inline closure or by explicitly calling
f.Close() before returning from the callback. Locate the block that opens f
(using os.Open(path)), performs io.Copy(tw, f) and currently defers f.Close();
replace the defer with a per-file closure (e.g., an anonymous func that defers
f.Close() inside it and is invoked immediately) or call f.Close() right after
io.Copy and handle any close error, ensuring errors from os.Open, io.Copy, and
f.Close() are propagated (use the same fmt.Errorf wrapping style as the existing
returns).

In `@pkg/oci/promote.go`:
- Around line 58-62: The reader returned as rc from the Fetch call is being
closed without checking errors; change the code in promote.go around the
manifest reading (where manifestBytes and rc are used) to ensure rc.Close() is
deferred immediately after obtaining rc (e.g., defer rc.Close()) or capture and
handle the Close error (assign err = rc.Close() and wrap/log it) so the close is
not ignored; update the block where manifestBytes, err := io.ReadAll(rc) is used
to rely on the deferred/handled Close to satisfy errcheck.
- Around line 106-116: The manifest's version annotation
(manifest.Annotations[ocispec.AnnotationVersion]) can be empty and produce an
invalid tag from lifecycle.TagForState; add a guard to check that version is
non-empty before calling lifecycle.TagForState (or adjust TagForState to return
"" for empty input) and only build tagRef and call store.Tag when newTag is
non-empty and derived from a non-empty version; locate the logic around
parseNameFromTag(ref), lifecycle.TagForState(version, to), newTag, and store.Tag
to implement this validation.
- Line 107: The current parseNameFromTag call (used to set namespaceName)
mishandles digest-form references like registry.io/ns/name@sha256:abcd by
splitting on the last ':' and producing malformed tag refs; update the logic so
parseNameFromTag (or the caller) first detects an '@' digest and either (a)
splits on '@' and uses the portion before '@' as the repository name when
creating new tag references, or (b) explicitly rejects digest-style inputs with
a clear error; modify parseNameFromTag or add validation in the promote flow
(where namespaceName is computed and later used to build the new tag) to
implement one of these behaviors and ensure tag construction never combines a
digest fragment with a new :tag.

In `@pkg/oci/prune.go`:
- Around line 76-99: listLocalWithTags currently ignores the caller's context by
creating context.Background(), which prevents Prune(ctx) cancellations from
propagating; change func (c *Client) listLocalWithTags() to accept ctx
context.Context and use that ctx when calling c.store.Tags, c.store.Resolve and
c.imageFromManifest, and update the Prune caller to pass its ctx into
listLocalWithTags so tag enumeration and per-tag operations respect
cancellation/timeouts.

In `@pkg/oci/pull.go`:
- Around line 26-35: The pull implementation currently calls splitRefTag and
passes only the bare tag to oras.Copy (creating a bare, un-namespaced tag) and
then calls c.store.Tag to add the full ref, causing tag collisions; change the
call to oras.Copy to use the full ref string (pass ref instead of tag) so the
stored entry is already namespaced, and remove the subsequent c.store.Tag call
that tags with the full ref; this will eliminate the duplicate bare-tag entry
that ListLocal/parseNameFromTag mis-parses.

In `@pkg/oci/push.go`:
- Around line 47-66: splitRefTag currently treats digest refs like
"quay.io/org/name@sha256:..." as part of the repo; change splitRefTag to first
detect and strip a digest by looking for '@' (e.g., idx := strings.Index(ref,
"@")); if found, set ref = ref[:idx] before the existing colon/slash logic so
the "@sha256:..." is not included in the repo passed to remote.NewRepository;
this preserves tag handling and avoids misbehavior downstream (alternatively, if
digests are out of scope, explicitly detect '@' and return an error from
splitRefTag by changing its signature to (repo, tag string, err error) and
propagate that error).

In `@pkg/skillcard/validate.go`:
- Around line 102-107: The current fragile cleanup of ve.Error() by slicing on
the first ": " should be replaced by using the structured error kind; instead of
calling ve.Error() and performing string surgery on the result (the code around
the message variable and the strings.Index slice), obtain the clean text from
ve.ErrorKind (e.g., call ve.ErrorKind.LocalizedString() from the
jsonschema/v6/kind package) and use that as the message; add the kind package
import if missing and remove the idx-based trimming so the message comes
directly from ve.ErrorKind.LocalizedString().

In `@README.md`:
- Around line 58-59: Update the stale CLI example in the README: replace the old
command invocation "bin/skillctl images" with the current CLI name "bin/skillctl
list" (or "bin/skillctl ls") so the "List local images" section and example
reflect the actual command surface; ensure the heading and the example command
string under it are both updated to match the renamed subcommand.

---

Outside diff comments:
In `@docs/superpowers/plans/2026-04-16-skillctl-mvp.md`:
- Around line 954-975: Update the plan doc tests and inline implementations to
match the shipped lifecycle convention that uses "-testing" (not "-rc"): replace
the "1.2.0-rc" expectation in TestTagForState, change the inline TagForState
implementation that returns version + "-rc" to return version + "-testing", and
update all other test expectations referenced (ListLocal, promote tests, Unpack
tests, etc.) that currently expect "1.0.0-rc" or similar to "1.0.0-testing" so
the plan matches the actual pkg/lifecycle.TagForState behavior.

---

Nitpick comments:
In `@internal/cli/helpers.go`:
- Around line 18-32: The defaultStoreDir function builds filesystem paths by
concatenating strings; replace those concatenations with filepath.Join to
construct paths portably and correctly (handle XDG_DATA_HOME and home dir
cases). Specifically, in defaultStoreDir use filepath.Join(home, ".local",
"share") instead of home + "/.local/share" and filepath.Join(dataDir,
"skillctl", "store") instead of dataDir + "/skillctl/store"; ensure you import
"path/filepath" and keep the same error handling and permissions when calling
os.MkdirAll.

In `@internal/cli/install.go`:
- Around line 89-96: The skill name extraction logic in internal/cli/install.go
duplicates skillNameFromRef from pkg/oci/pull.go; export the shared
implementation (e.g., rename to SkillNameFromRef in the oci package) and call
oci.SkillNameFromRef(ref) from install.go instead of reimplementing the parsing
there so the value used for filepath.Join(outputDir, skillName) matches Unpack's
behavior; update any imports/usages to reference the exported function and
remove the duplicated code in install.go.

In `@internal/cli/pack.go`:
- Around line 25-37: The runPack function (and analogous functions in
push.go/promote.go) currently calls client.Pack with context.Background(), which
bypasses Cobra's cancellable command context; replace context.Background() with
cmd.Context() in the client.Pack call inside runPack (and similarly update
client.Push/client.Promote calls in runPush/runPromote) so the command respects
cancellation/signals propagated by Cobra's command context.

In `@internal/cli/promote.go`:
- Around line 40-65: The runPromote function currently only validates that
toState is a known state via lifecycle.ParseState and uses context.Background();
update it to validate the proposed transition before making remote calls (use
any transition validator exposed by pkg/lifecycle — e.g.,
lifecycle.IsValidTransition or lifecycle.ValidateTransition if available) and
return an error early for invalid transitions, and replace context.Background()
with cmd.Context() so cancellation/signals propagate; ensure both
client.PromoteLocal(ctx, ref, to) and client.Promote(ctx, ref, to, ...) use the
new ctx and that the transition check runs before either call.

In `@pkg/oci/client.go`:
- Around line 15-24: The NewClient function returns the raw error from oci.New;
wrap that error with context including the failing storePath so callers can see
which path failed. Update NewClient (function NewClient and the call to oci.New)
to return fmt.Errorf("creating OCI store at %s: %w", storePath, err) instead of
returning err directly and add the "fmt" import.

In `@pkg/oci/inspect.go`:
- Around line 23-37: The code currently defers rc.Close() and ignores its return
value; replace the blind defer with an explicit close after reading the body so
any Close error is captured and returned (i.e., remove defer rc.Close(), call
err = io.ReadAll(rc) as now, then after that call do if cerr := rc.Close(); cerr
!= nil { return nil, fmt.Errorf("closing manifest reader: %w", cerr) } before
unmarshalling). Refer to the Fetch call that yields rc, the rc variable,
io.ReadAll(manifestBytes), and the json.Unmarshal(manifestBytes, &manifest)
sequence when making this change.

In `@pkg/oci/prune.go`:
- Around line 39-69: The code currently calls lifecycle.ParseState(img.Status)
twice per image (once to compute maxRank and again to decide removal), causing
duplicate work and inconsistent skip behavior; refactor the loops in the prune
logic to a single pass that records each image's parse result and its rank
(e.g., build a slice or map of (LocalImage -> rank) while skipping images that
fail ParseState), compute maxRank per group from those recorded ranks, then
iterate the recorded pairs to call c.store.Untag(ctx, fullRef) for images whose
recorded rank < maxRank; update references to removed, groups, stateRank,
lifecycle.ParseState, and c.store.Untag so parsing is done exactly once per img
and skip-on-parse-error is explicit.

In `@pkg/oci/pull.go`:
- Line 149: The linter flags unchecked Close() calls for the gzip reader and
file; update the defer and explicit close sites to make intent explicit: change
the deferred gz.Close() to capture or discard the returned error (e.g., use _ =
gz.Close() or wrap it in a closure that assigns the error) and replace the
unchecked f.Close() on the error path with an explicit discard assignment (_ =
f.Close()); locate the calls to gz.Close() and f.Close() in this file (symbols
gz and f in the pull handling function) and apply those changes so
errcheck/golangci-lint is satisfied.
- Around line 166-184: The extraction switch handling header.Typeflag in
pkg/oci/pull.go currently only handles tar.TypeDir and tar.TypeReg and silently
skips other entry types; add a default case to the switch that returns an
explicit error (e.g., fmt.Errorf("unsupported tar entry type %v for %s",
header.Typeflag, header.Name)) so unexpected types cause the pull to fail
loudly, and ensure the error bubbles up from the enclosing function that
performs the tar extraction (the block that uses header, tr, io.Copy,
os.OpenFile, clampDirMode, clampFileMode, sanitizeTarPath).

In `@pkg/skillcard/skillcard_test.go`:
- Line 162: The tests call skillcard.Validate(sc) but ignore its error return;
update each test invocation (the ones calling skillcard.Validate(sc) that assign
errs, _ := ...) to capture the error and fail the test on non-nil (e.g., errs,
err := skillcard.Validate(sc); if err != nil { t.Fatalf("Validate returned
error: %v", err) }) before asserting on errs so internal failures (like YAML
marshal errors) surface immediately.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f06c823f-23eb-4ee4-9189-ad95001ec6ab

📥 Commits

Reviewing files that changed from the base of the PR and between d2479f4 and 4729fcf.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (36)
  • .gitignore
  • README.md
  • cmd/skillctl/main.go
  • docs/design/2026-04-15-oci-skill-registry-design.md
  • docs/design/2026-04-16-implementation-spec.md
  • docs/superpowers/plans/2026-04-16-skillctl-mvp.md
  • examples/hello-world/SKILL.md
  • examples/hello-world/skill.yaml
  • go.mod
  • internal/cli/helpers.go
  • internal/cli/inspect.go
  • internal/cli/install.go
  • internal/cli/list.go
  • internal/cli/pack.go
  • internal/cli/promote.go
  • internal/cli/prune.go
  • internal/cli/pull.go
  • internal/cli/push.go
  • internal/cli/root.go
  • internal/cli/validate.go
  • pkg/lifecycle/lifecycle.go
  • pkg/lifecycle/lifecycle_test.go
  • pkg/oci/annotations.go
  • pkg/oci/client.go
  • pkg/oci/inspect.go
  • pkg/oci/oci_test.go
  • pkg/oci/pack.go
  • pkg/oci/promote.go
  • pkg/oci/prune.go
  • pkg/oci/pull.go
  • pkg/oci/push.go
  • pkg/skillcard/skillcard.go
  • pkg/skillcard/skillcard_test.go
  • pkg/skillcard/validate.go
  • schemas/embed.go
  • schemas/skillcard-v1.json

Comment thread internal/cli/install.go
Comment on lines +47 to +48
cmd.Flags().StringVarP(&target, "target", "t", "", "agent name (claude, cursor, windsurf, opencode, openclaw)")
cmd.Flags().StringVarP(&outputDir, "output", "o", "", "custom output directory")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

-o values with ~ won't expand when not quoted-for-shell.

The help example shows -o ~/custom/skills/, which relies on shell tilde expansion. When users pass "~/custom/skills/" (quoted) or the flag is set programmatically, ~ is treated literally and a directory named ~ is created under the current working directory. Consider expanding a leading ~ or ~/ against os.UserHomeDir() for the -o path, matching the --target behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cli/install.go` around lines 47 - 48, The output path flag
(outputDir set via cmd.Flags().StringVarP) can contain an unexpanded leading
tilde so when users pass "~" or "~/..." it becomes a literal folder; update the
command handler that reads outputDir (the Run/PreRun where outputDir is used) to
detect a leading "~" or "~/" and replace it with the current user home from
os.UserHomeDir(), preserving the rest of the path and using filepath.Clean;
ensure this normalization happens before any mkdir/create or path joins that use
outputDir so quoted or programmatic flag values behave like shell-expanded ones.

Comment thread internal/cli/pull.go
Comment thread pkg/oci/annotations.go
Comment thread pkg/oci/annotations.go
Comment thread pkg/oci/pack.go
Comment on lines +31 to +35
f, err := os.Open(skillPath)
if err != nil {
return ocispec.Descriptor{}, fmt.Errorf("opening skill.yaml: %w", err)
}
defer f.Close()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unchecked Close errors (errcheck).

golangci-lint flags f.Close() on line 35, rc.Close() on line 172, and f.Close() on line 261 (the loop case is addressed separately above). For the read-only opens on lines 35/172 these are benign, but silencing errcheck explicitly keeps the lint clean:

defer func() { _ = f.Close() }()
🧰 Tools
🪛 golangci-lint (2.11.4)

[error] 35-35: Error return value of f.Close is not checked

(errcheck)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/oci/pack.go` around lines 31 - 35, Replace the plain deferred Close calls
with explicit error-suppression closures to satisfy errcheck: for the file
handle opened as f when reading skillPath (defer f.Close()), the reader rc
closed at the later read (defer rc.Close()), and the other deferred f.Close() in
the loop, change each to a deferred anonymous func that assigns the Close error
to the blank identifier (e.g., defer func() { _ = <var>.Close() }()) so the code
keeps the read-only close but silences errcheck; locate these variables (f and
rc) in pkg/oci/pack.go and apply the same pattern to all three defer calls.

Comment thread pkg/oci/prune.go Outdated
Comment thread pkg/oci/pull.go Outdated
Comment thread pkg/oci/push.go
Comment on lines +47 to +66
// splitRefTag splits an OCI reference into repository and tag.
// Handles registry ports correctly: localhost:5000/ns/name:tag splits
// at the tag colon (after the last /), not the port colon.
func splitRefTag(ref string) (repo, tag string) {
// Find the last slash to isolate the name:tag portion.
lastSlash := strings.LastIndex(ref, "/")
if lastSlash < 0 {
// No slash: the whole ref might be name:tag.
if idx := strings.LastIndex(ref, ":"); idx >= 0 {
return ref[:idx], ref[idx+1:]
}
return ref, ""
}
// Look for a colon only after the last slash.
tail := ref[lastSlash+1:]
if idx := strings.LastIndex(tail, ":"); idx >= 0 {
return ref[:lastSlash+1+idx], tail[idx+1:]
}
return ref, ""
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

splitRefTag doesn't handle digest refs (@sha256:...).

A reference like quay.io/org/name@sha256:abc... will be treated as an untagged ref, and the @sha256:... portion will be included in the repo path passed to remote.NewRepository, which likely fails or misbehaves downstream. If digest refs are out of scope for MVP, consider explicitly rejecting them here with a clear error; otherwise add an @ split before the colon handling.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/oci/push.go` around lines 47 - 66, splitRefTag currently treats digest
refs like "quay.io/org/name@sha256:..." as part of the repo; change splitRefTag
to first detect and strip a digest by looking for '@' (e.g., idx :=
strings.Index(ref, "@")); if found, set ref = ref[:idx] before the existing
colon/slash logic so the "@sha256:..." is not included in the repo passed to
remote.NewRepository; this preserves tag handling and avoids misbehavior
downstream (alternatively, if digests are out of scope, explicitly detect '@'
and return an error from splitRefTag by changing its signature to (repo, tag
string, err error) and propagate that error).

Comment thread pkg/skillcard/validate.go
Comment on lines +102 to +107
message := ve.Error()
// Clean up the error message to remove the instance location prefix
// The error format is typically: "instanceLocation: message"
if idx := strings.Index(message, ": "); idx != -1 {
message = message[idx+2:]
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

github.com/santhosh-tekuri/jsonschema/v6 ValidationError Error() format and ErrorKind API

💡 Result:

In github.com/santhosh-tekuri/jsonschema/v6, ValidationError is the primary error type returned by schema validation methods like Schema.Validate. It embeds or has a field ErrorKind jsonschema.ErrorKind, which is an interface providing structured, introspectable information about the specific validation failure (e.g., required property missing, type mismatch). ErrorKind types are defined in the "github.com/santhosh-tekuri/jsonschema/v6/kind" subpackage, such as kind.Required{}, kind.Type{}, kind.AnyOf{}, etc. ValidationError implements the standard Go error interface via its Error method, which generates a human-readable string describing the error, including hierarchy, instance/schema locations (using JSON pointers or # notation), and details derived from ErrorKind. The output format can be customized using methods like DetailedOutput, FlagOutput, BasicOutput, which return OutputUnit structs marshalable to JSON in various formats (detailed, flag, basic). ErrorKind supports localization via ErrorKind.LocalizedString(printer), using golang.org/x/text/message, allowing internationalized error messages. Examples from usage: - Compare errors: assert.Equal(expected.ErrorKind, actual.ErrorKind) - Hierarchy: ValidationError.Causes []*ValidationError (recursive) - Stable comparison: Avoid string Error due to non-deterministic map order; use DetailedOutput and custom sorting. Migration notes from v5: InstanceLocation now []string (was string), Message removed (use ErrorKind), AbsoluteKeywordLocation removed. Official documentation: pkg.go.dev/github.com/santhosh-tekuri/jsonschema/v6 (Godoc links to full API). Repository default branch "boon" contains v6 source code.

Citations:


🏁 Script executed:

cd pkg/skillcard && wc -l validate.go

Repository: redhat-et/skillimage

Length of output: 79


🏁 Script executed:

cat -n pkg/skillcard/validate.go | sed -n '95,115p'

Repository: redhat-et/skillimage

Length of output: 718


🏁 Script executed:

# Also find the broader context of the collectValidationErrors function
cat -n pkg/skillcard/validate.go | sed -n '80,120p'

Repository: redhat-et/skillimage

Length of output: 1261


🏁 Script executed:

# Check imports in validate.go and go.mod
head -20 pkg/skillcard/validate.go

Repository: redhat-et/skillimage

Length of output: 550


🏁 Script executed:

# Check go.mod for jsonschema version
grep -i jsonschema go.mod

Repository: redhat-et/skillimage

Length of output: 112


🏁 Script executed:

# Search for any use of ErrorKind in the codebase
rg "ErrorKind" --type go

Repository: redhat-et/skillimage

Length of output: 46


Fragile message cleanup via first ": " occurrence.

Since ve.Error() includes instance/schema locations in its formatted output and you're already extracting the structured InstanceLocation separately, the string surgery to remove the location prefix is fragile. If the underlying error message itself contains ": ", this will truncate legitimate content. Instead, access ve.ErrorKind directly and use its structured representation (e.g., ErrorKind.LocalizedString() from the jsonschema/v6/kind subpackage) to get a clean message without string parsing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/skillcard/validate.go` around lines 102 - 107, The current fragile
cleanup of ve.Error() by slicing on the first ": " should be replaced by using
the structured error kind; instead of calling ve.Error() and performing string
surgery on the result (the code around the message variable and the
strings.Index slice), obtain the clean text from ve.ErrorKind (e.g., call
ve.ErrorKind.LocalizedString() from the jsonschema/v6/kind package) and use that
as the message; add the kind package import if missing and remove the idx-based
trimming so the message comes directly from ve.ErrorKind.LocalizedString().

Comment thread README.md Outdated
- Fix FD leak: extract file copy to helper with scoped defer
- Skip non-regular files (symlinks, devices) in tar creation
- Fix bare-tag collision: pass full ref to oras.Copy destination
- Guard against empty version annotation in promote
- Simplify looksLocal: any ref with / is treated as remote
- Fix UTF-8 truncation in description annotation
- Thread context through prune/listLocalWithTags
- Update stale comment and README command name

Signed-off-by: Pavel Anni <panni@redhat.com>
@pavelanni pavelanni merged commit 8e0baf2 into main Apr 17, 2026
3 checks passed
@pavelanni pavelanni deleted the feature/skillctl-mvp branch April 17, 2026 00:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants