From b1dd24bc4a4637afa992ea569c8e9b7c85cc42e3 Mon Sep 17 00:00:00 2001 From: Pavel Anni Date: Tue, 5 May 2026 20:17:55 -0400 Subject: [PATCH 1/2] refactor: restructure docs/ and site/ directories Separate GitHub Pages content from documentation: - site/ for published HTML (CNAME, pages, slides, llms.txt) - docs/ for all documentation (design, research, slide sources) - docs/dev/ absorbs former top-level dev/ (plans, specs) - Add GitHub Actions workflow to deploy Pages from site/ - Update CLAUDE.md project structure table Closes #41 Assisted-By: Claude (Anthropic AI) Signed-off-by: Pavel Anni --- .github/workflows/pages.yaml | 31 + CLAUDE.md | 5 +- .../dev}/plans/2026-04-16-skillctl-mvp.md | 0 ...2026-04-20-catalog-metadata-annotations.md | 0 .../2026-04-24-phase2a-catalog-server.md | 0 .../plans/2026-04-27-skill-collections.md | 0 .../plans/2026-04-29-remote-git-sources.md | 0 .../2026-04-30-installed-skills-listing.md | 0 .../plans/2026-04-30-rm-delete-command.md | 0 .../dev}/plans/2026-04-30-upgrade-command.md | 0 ...26-05-01-collection-install-git-sources.md | 971 ++++++++++++++++++ .../2026-05-01-list-output-formatting.md | 393 +++++++ ...-20-catalog-metadata-annotations-design.md | 0 ...026-04-24-phase2a-catalog-server-design.md | 0 .../2026-04-27-skill-collections-design.md | 0 .../2026-04-29-remote-git-sources-design.md | 0 ...6-04-30-installed-skills-listing-design.md | 0 .../2026-04-30-rm-delete-command-design.md | 0 .../2026-04-30-upgrade-command-design.md | 0 ...1-collection-install-git-sources-design.md | 214 ++++ ...026-05-01-list-output-formatting-design.md | 84 ++ {docs => site}/CNAME | 0 {docs => site}/demo/index.html | 0 {docs => site}/examples.html | 0 {docs => site}/index.html | 0 {docs => site}/llms-full.txt | 0 {docs => site}/llms.txt | 0 {docs => site}/slides/index.html | 0 .../slides/oci-skill-distribution-deck.html | 0 {docs => site}/slides/skillimage-deck.html | 0 30 files changed, 1696 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/pages.yaml rename {dev => docs/dev}/plans/2026-04-16-skillctl-mvp.md (100%) rename {dev => docs/dev}/plans/2026-04-20-catalog-metadata-annotations.md (100%) rename {dev => docs/dev}/plans/2026-04-24-phase2a-catalog-server.md (100%) rename {dev => docs/dev}/plans/2026-04-27-skill-collections.md (100%) rename {dev => docs/dev}/plans/2026-04-29-remote-git-sources.md (100%) rename {dev => docs/dev}/plans/2026-04-30-installed-skills-listing.md (100%) rename {dev => docs/dev}/plans/2026-04-30-rm-delete-command.md (100%) rename {dev => docs/dev}/plans/2026-04-30-upgrade-command.md (100%) create mode 100644 docs/dev/plans/2026-05-01-collection-install-git-sources.md create mode 100644 docs/dev/plans/2026-05-01-list-output-formatting.md rename {dev => docs/dev}/specs/2026-04-20-catalog-metadata-annotations-design.md (100%) rename {dev => docs/dev}/specs/2026-04-24-phase2a-catalog-server-design.md (100%) rename {dev => docs/dev}/specs/2026-04-27-skill-collections-design.md (100%) rename {dev => docs/dev}/specs/2026-04-29-remote-git-sources-design.md (100%) rename {dev => docs/dev}/specs/2026-04-30-installed-skills-listing-design.md (100%) rename {dev => docs/dev}/specs/2026-04-30-rm-delete-command-design.md (100%) rename {dev => docs/dev}/specs/2026-04-30-upgrade-command-design.md (100%) create mode 100644 docs/dev/specs/2026-05-01-collection-install-git-sources-design.md create mode 100644 docs/dev/specs/2026-05-01-list-output-formatting-design.md rename {docs => site}/CNAME (100%) rename {docs => site}/demo/index.html (100%) rename {docs => site}/examples.html (100%) rename {docs => site}/index.html (100%) rename {docs => site}/llms-full.txt (100%) rename {docs => site}/llms.txt (100%) rename {docs => site}/slides/index.html (100%) rename {docs => site}/slides/oci-skill-distribution-deck.html (100%) rename {docs => site}/slides/skillimage-deck.html (100%) diff --git a/.github/workflows/pages.yaml b/.github/workflows/pages.yaml new file mode 100644 index 0000000..e1395a9 --- /dev/null +++ b/.github/workflows/pages.yaml @@ -0,0 +1,31 @@ +name: Deploy GitHub Pages + +on: + push: + branches: [main] + paths: [site/**] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: site + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/CLAUDE.md b/CLAUDE.md index 9b1caf0..773b308 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,8 +37,9 @@ make fmt # Format code | `schemas/` | JSON Schema for SkillCard | | `api/` | OpenAPI 3.1 spec | | `deploy/` | Dockerfile, Kustomize overlays | -| `docs/` | Published site (GitHub Pages) | -| `dev/` | Internal plans and specs | +| `site/` | GitHub Pages content (HTML, CNAME, slides) | +| `docs/` | Documentation (design, research, slide sources) | +| `docs/dev/` | Internal plans and specs | ## Architecture diff --git a/dev/plans/2026-04-16-skillctl-mvp.md b/docs/dev/plans/2026-04-16-skillctl-mvp.md similarity index 100% rename from dev/plans/2026-04-16-skillctl-mvp.md rename to docs/dev/plans/2026-04-16-skillctl-mvp.md diff --git a/dev/plans/2026-04-20-catalog-metadata-annotations.md b/docs/dev/plans/2026-04-20-catalog-metadata-annotations.md similarity index 100% rename from dev/plans/2026-04-20-catalog-metadata-annotations.md rename to docs/dev/plans/2026-04-20-catalog-metadata-annotations.md diff --git a/dev/plans/2026-04-24-phase2a-catalog-server.md b/docs/dev/plans/2026-04-24-phase2a-catalog-server.md similarity index 100% rename from dev/plans/2026-04-24-phase2a-catalog-server.md rename to docs/dev/plans/2026-04-24-phase2a-catalog-server.md diff --git a/dev/plans/2026-04-27-skill-collections.md b/docs/dev/plans/2026-04-27-skill-collections.md similarity index 100% rename from dev/plans/2026-04-27-skill-collections.md rename to docs/dev/plans/2026-04-27-skill-collections.md diff --git a/dev/plans/2026-04-29-remote-git-sources.md b/docs/dev/plans/2026-04-29-remote-git-sources.md similarity index 100% rename from dev/plans/2026-04-29-remote-git-sources.md rename to docs/dev/plans/2026-04-29-remote-git-sources.md diff --git a/dev/plans/2026-04-30-installed-skills-listing.md b/docs/dev/plans/2026-04-30-installed-skills-listing.md similarity index 100% rename from dev/plans/2026-04-30-installed-skills-listing.md rename to docs/dev/plans/2026-04-30-installed-skills-listing.md diff --git a/dev/plans/2026-04-30-rm-delete-command.md b/docs/dev/plans/2026-04-30-rm-delete-command.md similarity index 100% rename from dev/plans/2026-04-30-rm-delete-command.md rename to docs/dev/plans/2026-04-30-rm-delete-command.md diff --git a/dev/plans/2026-04-30-upgrade-command.md b/docs/dev/plans/2026-04-30-upgrade-command.md similarity index 100% rename from dev/plans/2026-04-30-upgrade-command.md rename to docs/dev/plans/2026-04-30-upgrade-command.md diff --git a/docs/dev/plans/2026-05-01-collection-install-git-sources.md b/docs/dev/plans/2026-05-01-collection-install-git-sources.md new file mode 100644 index 0000000..a07aaa2 --- /dev/null +++ b/docs/dev/plans/2026-05-01-collection-install-git-sources.md @@ -0,0 +1,971 @@ +# Collection install with Git source support + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. +> Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `skillctl collection install` to install skills +from a collection YAML (local file or Git URL) into an agent's +skill directory, with support for `source:` entries pointing to +Git repos and SHA/digest-based skip logic. + +**Architecture:** Extend `SkillRef` in `pkg/collection/` with a +`Source` field. Add `LsRemote()` to `pkg/source/` for lightweight +SHA checking. Add `collection install` subcommand in +`internal/cli/collection.go` that orchestrates the flow: parse +collection, check provenance, build/pull, unpack, write provenance. + +**Tech Stack:** Go, Cobra/Viper, oras-go, git CLI + +--- + +### Task 1: Extend `SkillRef` struct and update validation + +**Files:** + +- Modify: `pkg/collection/collection.go` +- Modify: `pkg/collection/collection_test.go` + +- [ ] **Step 1: Write failing tests for new validation rules** + +Add to `pkg/collection/collection_test.go`: + +```go +func TestParseSourceField(t *testing.T) { + input := `apiVersion: skillimage.io/v1alpha1 +kind: SkillCollection +metadata: + name: dev-skills + version: 0.1.0 +skills: + - source: https://github.com/myorg/skills/tree/main/code-reviewer + - name: stable-tool + image: quay.io/myorg/stable-tool:1.0.0 +` + col, err := collection.Parse(strings.NewReader(input)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if col.Skills[0].Source != "https://github.com/myorg/skills/tree/main/code-reviewer" { + t.Errorf("skill[0].source = %q", col.Skills[0].Source) + } + if col.Skills[1].Image != "quay.io/myorg/stable-tool:1.0.0" { + t.Errorf("skill[1].image = %q", col.Skills[1].Image) + } +} + +func TestValidateSourceAndImageExclusive(t *testing.T) { + col := &collection.SkillCollection{ + APIVersion: "skillimage.io/v1alpha1", + Kind: "SkillCollection", + Metadata: collection.Metadata{Name: "test", Version: "1.0.0"}, + Skills: []collection.SkillRef{ + {Name: "both", Image: "quay.io/org/s:1.0.0", Source: "https://github.com/org/repo/tree/main/s"}, + }, + } + errs := collection.Validate(col) + found := false + for _, e := range errs { + if strings.Contains(e, "mutually exclusive") { + found = true + } + } + if !found { + t.Errorf("expected mutually exclusive error, got: %v", errs) + } +} + +func TestValidateNeitherImageNorSource(t *testing.T) { + col := &collection.SkillCollection{ + APIVersion: "skillimage.io/v1alpha1", + Kind: "SkillCollection", + Metadata: collection.Metadata{Name: "test", Version: "1.0.0"}, + Skills: []collection.SkillRef{ + {Name: "empty"}, + }, + } + errs := collection.Validate(col) + found := false + for _, e := range errs { + if strings.Contains(e, "image or source is required") { + found = true + } + } + if !found { + t.Errorf("expected 'image or source is required' error, got: %v", errs) + } +} + +func TestValidateSourceWithoutName(t *testing.T) { + col := &collection.SkillCollection{ + APIVersion: "skillimage.io/v1alpha1", + Kind: "SkillCollection", + Metadata: collection.Metadata{Name: "test", Version: "1.0.0"}, + Skills: []collection.SkillRef{ + {Source: "https://github.com/org/repo/tree/main/skill"}, + }, + } + errs := collection.Validate(col) + if len(errs) != 0 { + t.Errorf("source without name should be valid, got errors: %v", errs) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /Users/panni/work/skillimage && go test ./pkg/collection/ -run 'TestParseSourceField|TestValidateSource|TestValidateNeither' -v` + +Expected: `TestParseSourceField` fails because `source` is an +unknown field (strict parsing). The validation tests fail +because the struct doesn't have a `Source` field. + +- [ ] **Step 3: Update `SkillRef` struct** + +In `pkg/collection/collection.go`, replace the `SkillRef` +struct: + +```go +type SkillRef struct { + Name string `yaml:"name,omitempty"` + Image string `yaml:"image,omitempty"` + Source string `yaml:"source,omitempty"` +} +``` + +- [ ] **Step 4: Update `Validate()` function** + +In `pkg/collection/collection.go`, replace the validation loop +inside `Validate()`: + +```go +seen := make(map[string]bool) +for i, s := range col.Skills { + switch { + case s.Image != "" && s.Source != "": + errs = append(errs, fmt.Sprintf("skills[%d]: image and source are mutually exclusive", i)) + case s.Image == "" && s.Source == "": + errs = append(errs, fmt.Sprintf("skills[%d]: image or source is required", i)) + case s.Image != "" && s.Name == "": + errs = append(errs, fmt.Sprintf("skills[%d].name is required", i)) + } + if s.Name != "" && seen[s.Name] { + errs = append(errs, fmt.Sprintf("duplicate skill name %q", s.Name)) + } + if s.Name != "" { + seen[s.Name] = true + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd /Users/panni/work/skillimage && go test ./pkg/collection/ -v` + +Expected: All tests pass, including existing tests. The +existing `TestValidateMissingFields` test validated that +`name == ""` and `image == ""` each produce errors. With the +new rules, `name == ""` with `image != ""` still produces the +name-required error. But `image == ""` with `name != ""` now +triggers the "image or source is required" error instead of the +old `skills[N].image is required`. We need to update that test. + +- [ ] **Step 6: Fix `TestValidateMissingFields` if needed** + +The existing test expects at least 2 errors for entries +`{Name: "", Image: "quay.io/org/s:1.0.0"}` and +`{Name: "s2", Image: ""}`. With the new logic: + +- Entry 0: `name == ""`, `image != ""`, `source == ""` → + triggers `skills[0].name is required` (correct) +- Entry 1: `name == "s2"`, `image == ""`, `source == ""` → + triggers `skills[1]: image or source is required` (new msg) + +The test checks `len(errs) < 2`, so it should still pass. +Verify by running all tests. + +- [ ] **Step 7: Commit** + +```bash +git add pkg/collection/collection.go pkg/collection/collection_test.go +git commit -s -m "feat(collection): add source field to SkillRef with validation + +Add Source field to SkillRef struct for Git URL references. +Update Validate() to enforce mutual exclusivity between image +and source fields. Name is optional for source entries (derived +at install time from SKILL.md frontmatter). + +Refs: #35 + +Assisted-By: Claude (Anthropic AI) " +``` + +--- + +### Task 2: Add `LsRemote()` to `pkg/source/` + +**Files:** + +- Create: `pkg/source/lsremote.go` +- Create: `pkg/source/lsremote_test.go` + +- [ ] **Step 1: Write failing test for `LsRemote`** + +Create `pkg/source/lsremote_test.go`: + +```go +package source + +import ( + "context" + "testing" +) + +func TestLsRemoteReturnsCommitSHA(t *testing.T) { + if err := CheckGit(); err != nil { + t.Skip("git not available") + } + + // Use a well-known public repo with a known branch. + sha, err := LsRemote(context.Background(), "https://github.com/octocat/Hello-World.git", "master") + if err != nil { + t.Fatalf("LsRemote: %v", err) + } + if len(sha) < 7 { + t.Errorf("expected commit SHA, got %q", sha) + } + if !commitSHAPattern.MatchString(sha) { + t.Errorf("SHA %q does not match commit pattern", sha) + } +} + +func TestLsRemoteBadRef(t *testing.T) { + if err := CheckGit(); err != nil { + t.Skip("git not available") + } + + _, err := LsRemote(context.Background(), "https://github.com/octocat/Hello-World.git", "nonexistent-branch-xyz") + if err == nil { + t.Fatal("expected error for nonexistent ref") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/panni/work/skillimage && go test ./pkg/source/ -run TestLsRemote -v` + +Expected: Compilation error — `LsRemote` not defined. + +- [ ] **Step 3: Implement `LsRemote`** + +Create `pkg/source/lsremote.go`: + +```go +package source + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" +) + +// LsRemote queries a remote Git repository for the commit SHA +// of the given ref without cloning. Returns the full commit SHA. +func LsRemote(ctx context.Context, cloneURL, ref string) (string, error) { + if err := CheckGit(); err != nil { + return "", err + } + + var stdout, stderr bytes.Buffer + cmd := exec.CommandContext(ctx, "git", "ls-remote", cloneURL, ref) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("git ls-remote %s %s: %s", cloneURL, ref, sanitizeGitOutput(stderr.String())) + } + + output := strings.TrimSpace(stdout.String()) + if output == "" { + return "", fmt.Errorf("ref %q not found in %s", ref, cloneURL) + } + + // Output format: "\t\n" — may have multiple lines. + // Take the first line's SHA. + firstLine := strings.SplitN(output, "\n", 2)[0] + fields := strings.Fields(firstLine) + if len(fields) < 1 { + return "", fmt.Errorf("unexpected ls-remote output for %s %s", cloneURL, ref) + } + + return fields[0], nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /Users/panni/work/skillimage && go test ./pkg/source/ -run TestLsRemote -v` + +Expected: Both tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add pkg/source/lsremote.go pkg/source/lsremote_test.go +git commit -s -m "feat(source): add LsRemote for lightweight ref checking + +LsRemote queries a remote Git repo for the commit SHA of a ref +without cloning. Used by collection install to skip unchanged +source entries. + +Refs: #35 + +Assisted-By: Claude (Anthropic AI) " +``` + +--- + +### Task 3: Extract shared helpers from `install.go` + +The `collection install` command needs the same output directory +resolution and provenance-writing logic that the individual +`install` command uses. Extract the reusable pieces. + +**Files:** + +- Modify: `internal/cli/install.go` + +- [ ] **Step 1: Run existing install tests to establish baseline** + +Run: `cd /Users/panni/work/skillimage && go test ./internal/cli/ -v` + +Expected: All existing tests pass. + +- [ ] **Step 2: Export `writeProvenance` and helpers** + +In `internal/cli/install.go`, rename `writeProvenance` to +`WriteProvenance` (exported), and `newSkillCardFromRef` to +`NewSkillCardFromRef`: + +```go +func WriteProvenance(ctx context.Context, client *oci.Client, ref, skillDir string) error { +``` + +```go +func NewSkillCardFromRef(ref string) *skillcard.SkillCard { +``` + +Update the call site in `runInstall` from `writeProvenance` to +`WriteProvenance` and `newSkillCardFromRef` to +`NewSkillCardFromRef`. + +- [ ] **Step 3: Run tests to verify nothing broke** + +Run: `cd /Users/panni/work/skillimage && go test ./internal/cli/ -v` + +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add internal/cli/install.go +git commit -s -m "refactor(cli): export WriteProvenance for reuse + +Export WriteProvenance and NewSkillCardFromRef so the collection +install command can reuse them. + +Refs: #35 + +Assisted-By: Claude (Anthropic AI) " +``` + +--- + +### Task 4: Implement `collection install` command + +**Files:** + +- Modify: `internal/cli/collection.go` + +- [ ] **Step 1: Add `newCollectionInstallCmd` to the collection command** + +In `internal/cli/collection.go`, add the install subcommand +registration inside `newCollectionCmd()`: + +```go +func newCollectionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "collection", + Short: "Manage skill collections", + } + cmd.AddCommand(newCollectionPushCmd()) + cmd.AddCommand(newCollectionPullCmd()) + cmd.AddCommand(newCollectionInstallCmd()) + cmd.AddCommand(newCollectionVolumeCmd()) + cmd.AddCommand(newCollectionGenerateCmd()) + return cmd +} +``` + +- [ ] **Step 2: Implement the command and install flow** + +Add to the bottom of `internal/cli/collection.go`: + +```go +func newCollectionInstallCmd() *cobra.Command { + var file string + var target string + var outputDir string + var force bool + var ref string + cmd := &cobra.Command{ + Use: "install [-f | ]", + Short: "Install skills from a collection into an agent's skill directory", + Long: `Install skills defined in a collection YAML into a directory +where an agent can find them. Skills can be referenced by OCI +image (image:) or Git source URL (source:). + +Source entries clone the repo, build locally, and install. +Image entries pull from the registry and install. + +Skills that haven't changed since last install are skipped +unless --force is set. + +Examples: + skillctl collection install -f ./collection.yaml --target claude + skillctl collection install https://github.com/myorg/skills/tree/main/collection.yaml -t claude + skillctl collection install -f ./collection.yaml -o ~/my-skills --force`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runCollectionInstall(cmd, file, args, target, outputDir, force, ref) + }, + } + cmd.Flags().StringVarP(&file, "file", "f", "", "path to local collection YAML file") + cmd.Flags().StringVarP(&target, "target", "t", "", "agent name (claude, cursor, windsurf, opencode, openclaw)") + cmd.Flags().StringVarP(&outputDir, "output", "o", "", "custom output directory") + cmd.Flags().BoolVar(&force, "force", false, "reinstall even if skills are up to date") + cmd.Flags().StringVar(&ref, "ref", "", "Git ref override (for collection YAML URL)") + return cmd +} + +func runCollectionInstall(cmd *cobra.Command, file string, args []string, target, outputDir string, force bool, ref string) error { + col, cleanup, err := resolveCollectionInput(cmd.Context(), file, args, ref) + if cleanup != nil { + defer cleanup() + } + if err != nil { + return err + } + + if errs := collection.Validate(col); len(errs) > 0 { + return fmt.Errorf("invalid collection:\n %s", strings.Join(errs, "\n ")) + } + + dirs, err := resolveTargetDirs(target, outputDir, false) + if err != nil { + return err + } + var destDir string + for _, d := range dirs { + destDir = d + } + if err := os.MkdirAll(destDir, 0o755); err != nil { + return fmt.Errorf("creating directory %s: %w", destDir, err) + } + + client, err := defaultClient() + if err != nil { + return err + } + + ctx := cmd.Context() + w := cmd.OutOrStdout() + errW := cmd.ErrOrStderr() + + fmt.Fprintf(w, "Installing collection %q (%d skills)\n", col.Metadata.Name, len(col.Skills)) + + var installed, skipped, failed int + for _, s := range col.Skills { + switch { + case s.Source != "": + result, err := installFromSource(ctx, client, s, destDir, force, w, errW) + switch { + case err != nil: + fmt.Fprintf(errW, " %s (source) error: %v\n", skillLabel(s), err) + failed++ + case result == "skipped": + skipped++ + default: + installed++ + } + case s.Image != "": + result, err := installFromImage(ctx, client, s, destDir, force, w) + switch { + case err != nil: + fmt.Fprintf(errW, " %s (image) error: %v\n", s.Name, err) + failed++ + case result == "skipped": + skipped++ + default: + installed++ + } + } + } + + fmt.Fprintf(w, "Installed %d skills", installed) + if skipped > 0 { + fmt.Fprintf(w, ", %d up to date", skipped) + } + fmt.Fprintln(w) + + if failed > 0 { + return fmt.Errorf("%d skill(s) failed to install", failed) + } + return nil +} + +func resolveCollectionInput(ctx context.Context, file string, args []string, ref string) (*collection.SkillCollection, func(), error) { + if file != "" && len(args) > 0 { + return nil, nil, fmt.Errorf("specify -f or a Git URL, not both") + } + if file != "" { + col, err := collection.ParseFile(file) + return col, nil, err + } + if len(args) == 0 { + return nil, nil, fmt.Errorf("specify -f or a Git URL") + } + + rawURL := args[0] + if !source.IsRemote(rawURL) { + return nil, nil, fmt.Errorf("not a valid URL: %s\n\nUse -f for local files", rawURL) + } + + src, err := source.ParseGitURL(rawURL) + if err != nil { + return nil, nil, err + } + + cloneResult, err := source.Clone(ctx, src, source.CloneOptions{RefOverride: ref}) + if err != nil { + return nil, nil, fmt.Errorf("cloning collection: %w", err) + } + + yamlPath := cloneResult.Dir + info, statErr := os.Stat(yamlPath) + if statErr != nil { + cloneResult.Cleanup() + return nil, nil, fmt.Errorf("collection file not found at %s in repository", src.SubPath) + } + if info.IsDir() { + cloneResult.Cleanup() + return nil, nil, fmt.Errorf("URL must point to a collection YAML file, not a directory: %s", src.SubPath) + } + + col, err := collection.ParseFile(yamlPath) + if err != nil { + cloneResult.Cleanup() + return nil, nil, err + } + + return col, cloneResult.Cleanup, nil +} + +func installFromSource(ctx context.Context, client *oci.Client, s collection.SkillRef, destDir string, force bool, w io.Writer, errW io.Writer) (string, error) { + label := skillLabel(s) + + src, err := source.ParseGitURL(s.Source) + if err != nil { + return "", err + } + + // Skip check: compare stored provenance commit against remote HEAD. + if !force && label != "" { + installedSHA := readInstalledCommit(destDir, label) + if installedSHA != "" { + refToCheck := src.Ref + if refToCheck == "" { + refToCheck = "HEAD" + } + remoteSHA, lsErr := source.LsRemote(ctx, src.CloneURL, refToCheck) + if lsErr == nil && remoteSHA == installedSHA { + fmt.Fprintf(w, " %s (source) up to date\n", label) + return "skipped", nil + } + } + } + + fmt.Fprintf(w, " %s (source) cloning...", label) + + result, err := source.Resolve(ctx, s.Source, "", "") + if err != nil { + fmt.Fprintln(w) + return "", err + } + defer result.Cleanup() + + if len(result.Skills) == 0 { + fmt.Fprintln(w) + return "", fmt.Errorf("no skills found at %s", s.Source) + } + + skill := result.Skills[0] + if label == "" { + label = skill.Name + } + + fmt.Fprintf(w, " building...") + + desc, err := client.Build(ctx, skill.Dir, oci.BuildOptions{SkillCard: skill.SkillCard}) + if err != nil { + fmt.Fprintln(w) + return "", fmt.Errorf("building: %w", err) + } + + ref := fmt.Sprintf("%s/%s:%s", skill.SkillCard.Metadata.Namespace, skill.SkillCard.Metadata.Name, skill.SkillCard.Metadata.Version) + if err := client.Unpack(ctx, ref, destDir); err != nil { + fmt.Fprintln(w) + return "", fmt.Errorf("unpacking: %w", err) + } + + skillDir := filepath.Join(destDir, skill.Name) + writeSourceProvenance(skillDir, skill.SkillCard, desc.Digest.String()) + + fmt.Fprintf(w, " installed\n") + return "installed", nil +} + +func installFromImage(ctx context.Context, client *oci.Client, s collection.SkillRef, destDir string, force bool, w io.Writer) (string, error) { + // Skip check: compare stored provenance digest against local store. + if !force { + installedDigest := readInstalledCommit(destDir, s.Name) + if installedDigest != "" { + localDigest, err := client.ResolveDigest(ctx, s.Image) + if err == nil && localDigest == installedDigest { + fmt.Fprintf(w, " %s (image) up to date\n", s.Name) + return "skipped", nil + } + } + } + + fmt.Fprintf(w, " %s (image) pulling...", s.Name) + + if !looksLocal(s.Image) { + if _, err := client.ResolveDigest(ctx, s.Image); err != nil { + if _, pullErr := client.Pull(ctx, s.Image, oci.PullOptions{}); pullErr != nil { + fmt.Fprintln(w) + return "", fmt.Errorf("pulling %s: %w", s.Image, pullErr) + } + } + } + + if err := client.Unpack(ctx, s.Image, destDir); err != nil { + fmt.Fprintln(w) + return "", fmt.Errorf("unpacking %s: %w", s.Image, err) + } + + skillDir := filepath.Join(destDir, oci.SkillNameFromRef(s.Image)) + if err := WriteProvenance(ctx, client, s.Image, skillDir); err != nil { + fmt.Fprintf(os.Stderr, " warning: provenance write failed: %v\n", err) + } + + fmt.Fprintf(w, " installed\n") + return "installed", nil +} + +func skillLabel(s collection.SkillRef) string { + if s.Name != "" { + return s.Name + } + return "" +} + +// readInstalledCommit reads provenance.commit from skill.yaml +// in destDir/skillName, returning empty string if not found. +func readInstalledCommit(destDir, skillName string) string { + skillPath := filepath.Join(destDir, skillName, "skill.yaml") + f, err := os.Open(skillPath) + if err != nil { + return "" + } + defer func() { _ = f.Close() }() + + sc, err := skillcard.Parse(f) + if err != nil { + return "" + } + if sc.Provenance == nil { + return "" + } + return sc.Provenance.Commit +} + +// writeSourceProvenance writes provenance data for a source-built +// skill into its skill.yaml. +func writeSourceProvenance(skillDir string, sc *skillcard.SkillCard, digest string) { + if sc.Provenance == nil { + sc.Provenance = &skillcard.Provenance{} + } + + skillPath := filepath.Join(skillDir, "skill.yaml") + wf, err := os.Create(skillPath) + if err != nil { + fmt.Fprintf(os.Stderr, " warning: could not write provenance: %v\n", err) + return + } + defer func() { _ = wf.Close() }() + if err := skillcard.Serialize(sc, wf); err != nil { + fmt.Fprintf(os.Stderr, " warning: could not serialize provenance: %v\n", err) + } +} +``` + +- [ ] **Step 3: Add required imports** + +Update the imports at the top of `internal/cli/collection.go`: + +```go +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/redhat-et/skillimage/pkg/collection" + "github.com/redhat-et/skillimage/pkg/oci" + "github.com/redhat-et/skillimage/pkg/skillcard" + "github.com/redhat-et/skillimage/pkg/source" + "github.com/spf13/cobra" +) +``` + +- [ ] **Step 4: Verify it compiles** + +Run: `cd /Users/panni/work/skillimage && go build ./...` + +Expected: Clean compilation. + +- [ ] **Step 5: Commit** + +```bash +git add internal/cli/collection.go +git commit -s -m "feat(cli): add collection install subcommand + +Implements skillctl collection install with support for both +image: (OCI registry) and source: (Git URL) entries. Includes +SHA/digest-based skip logic and --force flag to override. + +Refs: #35 + +Assisted-By: Claude (Anthropic AI) " +``` + +--- + +### Task 5: Add CLI tests for `collection install` + +**Files:** + +- Create: `internal/cli/collection_install_test.go` + +- [ ] **Step 1: Write tests for input validation and image-based install** + +Create `internal/cli/collection_install_test.go`: + +```go +package cli + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestCollectionInstallRequiresInput(t *testing.T) { + cmd := NewRootCmd("test") + cmd.SetArgs([]string{"collection", "install", "--target", "claude"}) + var stderr bytes.Buffer + cmd.SetErr(&stderr) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error when no -f or URL given") + } +} + +func TestCollectionInstallRequiresTarget(t *testing.T) { + dir := t.TempDir() + colFile := filepath.Join(dir, "collection.yaml") + content := []byte(`apiVersion: skillimage.io/v1alpha1 +kind: SkillCollection +metadata: + name: test + version: 1.0.0 +skills: + - name: s1 + image: quay.io/org/s1:1.0.0 +`) + if err := os.WriteFile(colFile, content, 0o644); err != nil { + t.Fatal(err) + } + + cmd := NewRootCmd("test") + cmd.SetArgs([]string{"collection", "install", "-f", colFile}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error when no --target or -o given") + } +} + +func TestCollectionInstallInvalidYAML(t *testing.T) { + dir := t.TempDir() + colFile := filepath.Join(dir, "bad.yaml") + if err := os.WriteFile(colFile, []byte("not: valid: yaml: ["), 0o644); err != nil { + t.Fatal(err) + } + + cmd := NewRootCmd("test") + cmd.SetArgs([]string{"collection", "install", "-f", colFile, "-o", dir}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for invalid YAML") + } +} + +func TestCollectionInstallValidationError(t *testing.T) { + dir := t.TempDir() + colFile := filepath.Join(dir, "collection.yaml") + content := []byte(`apiVersion: skillimage.io/v1alpha1 +kind: SkillCollection +metadata: + name: test + version: 1.0.0 +skills: + - name: both-set + image: quay.io/org/s:1.0.0 + source: https://github.com/org/repo/tree/main/s +`) + if err := os.WriteFile(colFile, content, 0o644); err != nil { + t.Fatal(err) + } + + cmd := NewRootCmd("test") + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"collection", "install", "-f", colFile, "-o", dir}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected validation error") + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cd /Users/panni/work/skillimage && go test ./internal/cli/ -run TestCollectionInstall -v` + +Expected: All tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add internal/cli/collection_install_test.go +git commit -s -m "test(cli): add collection install validation tests + +Tests input validation, target resolution, invalid YAML handling, +and mutual exclusivity validation for the collection install +command. + +Refs: #35 + +Assisted-By: Claude (Anthropic AI) " +``` + +--- + +### Task 6: Add example collection YAML with mixed entries + +**Files:** + +- Create: `examples/dev-collection.yaml` + +- [ ] **Step 1: Create the example file** + +Create `examples/dev-collection.yaml`: + +```yaml +apiVersion: skillimage.io/v1alpha1 +kind: SkillCollection +metadata: + name: dev-skills + version: 0.1.0 + description: Development collection with Git source and OCI image entries +skills: + - source: https://github.com/anthropics/courses/tree/master/prompt_engineering_interactive_tutorial/skills/code-review + - name: document-summarizer + image: quay.io/skillimage/business/document-summarizer:1.0.0-testing +``` + +- [ ] **Step 2: Verify the example parses** + +Run: `cd /Users/panni/work/skillimage && go run ./cmd/skillctl/ validate examples/dev-collection.yaml 2>&1 || echo "(validate may not support collections — that's fine, parse test below is sufficient)"` + +If `validate` doesn't support collections, add a quick parse +test instead: + +```bash +cd /Users/panni/work/skillimage && go test ./pkg/collection/ -run TestParseFile -v +``` + +- [ ] **Step 3: Commit** + +```bash +git add examples/dev-collection.yaml +git commit -s -m "docs: add example dev collection with source and image entries + +Shows mixed source: (Git URL) and image: (OCI ref) entries in +a collection YAML for the development workflow. + +Refs: #35 + +Assisted-By: Claude (Anthropic AI) " +``` + +--- + +### Task 7: Run full test suite and lint + +**Files:** None (verification only) + +- [ ] **Step 1: Run all tests** + +Run: `cd /Users/panni/work/skillimage && make test` + +Expected: All tests pass. + +- [ ] **Step 2: Run linter** + +Run: `cd /Users/panni/work/skillimage && make lint` + +Expected: No lint errors. Fix any that appear. + +- [ ] **Step 3: Run build** + +Run: `cd /Users/panni/work/skillimage && make build` + +Expected: Clean build. + +- [ ] **Step 4: Smoke test the command** + +Run: `cd /Users/panni/work/skillimage && ./bin/skillctl collection install --help` + +Expected: Help text shows usage with `-f`, `--target`, `-o`, +`--force`, and `--ref` flags. + +- [ ] **Step 5: Fix any issues found, commit if needed** diff --git a/docs/dev/plans/2026-05-01-list-output-formatting.md b/docs/dev/plans/2026-05-01-list-output-formatting.md new file mode 100644 index 0000000..0ce2cea --- /dev/null +++ b/docs/dev/plans/2026-05-01-list-output-formatting.md @@ -0,0 +1,393 @@ +# List Output Formatting Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. +> Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `skillctl list` output match container tool +conventions by stripping digest prefixes and humanizing timestamps. + +**Architecture:** All changes are in the CLI display layer +(`internal/cli/list.go`). The OCI data model is unchanged. +A `--no-trunc` flag preserves raw values for scripting. + +**Tech Stack:** Go stdlib `time`, `strings`; `dustin/go-humanize` +(already an indirect dep via modernc/sqlite — promote to direct). + +--- + +### Task 1: Add go-humanize as direct dependency + +**Files:** +- Modify: `go.mod` + +- [ ] **Step 1: Promote go-humanize to direct dependency** + +Run: + +```bash +go get github.com/dustin/go-humanize@v1.0.1 +``` + +This moves `go-humanize` from `// indirect` to a direct +dependency in `go.mod`. No `go.sum` changes needed since +the module is already resolved. + +- [ ] **Step 2: Verify** + +Run: + +```bash +grep 'go-humanize' go.mod +``` + +Expected: line without `// indirect` comment. + +- [ ] **Step 3: Commit** + +```bash +git add go.mod go.sum +git commit -s -m "build: promote go-humanize to direct dependency + +Needed for humanized timestamps in skillctl list output. + +Assisted-By: Claude (Anthropic AI) " +``` + +--- + +### Task 2: Write tests for digest and timestamp formatting + +**Files:** +- Create: `internal/cli/list_test.go` + +- [ ] **Step 1: Write tests for formatDigest helper** + +Create `internal/cli/list_test.go`: + +```go +package cli + +import ( + "testing" + "time" +) + +func TestFormatDigest(t *testing.T) { + tests := []struct { + name string + digest string + noTrunc bool + want string + }{ + { + name: "strips sha256 prefix and truncates", + digest: "sha256:a593244d38f0e1b2c3d4e5f6a7b8c9d0e1f2a3b4", + want: "a593244d38f0", + }, + { + name: "strips other algo prefix", + digest: "sha512:abcdef123456789012345678", + want: "abcdef123456", + }, + { + name: "handles digest shorter than 12 chars", + digest: "sha256:abcd", + want: "abcd", + }, + { + name: "handles digest with no prefix", + digest: "a593244d38f0e1b2c3d4", + want: "a593244d38f0", + }, + { + name: "no-trunc preserves full digest", + digest: "sha256:a593244d38f0e1b2c3d4e5f6a7b8c9d0e1f2a3b4", + noTrunc: true, + want: "sha256:a593244d38f0e1b2c3d4e5f6a7b8c9d0e1f2a3b4", + }, + { + name: "empty digest", + digest: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatDigest(tt.digest, tt.noTrunc) + if got != tt.want { + t.Errorf("formatDigest(%q, %v) = %q, want %q", + tt.digest, tt.noTrunc, got, tt.want) + } + }) + } +} + +func TestFormatCreated(t *testing.T) { + tests := []struct { + name string + created string + noTrunc bool + want string + }{ + { + name: "no-trunc returns raw timestamp", + created: "2026-04-29T22:07:29Z", + noTrunc: true, + want: "2026-04-29T22:07:29Z", + }, + { + name: "empty string returns empty", + created: "", + want: "", + }, + { + name: "unparseable falls back to raw string", + created: "not-a-timestamp", + want: "not-a-timestamp", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatCreated(tt.created, tt.noTrunc) + if got != tt.want { + t.Errorf("formatCreated(%q, %v) = %q, want %q", + tt.created, tt.noTrunc, got, tt.want) + } + }) + } + + t.Run("recent timestamp shows relative time", func(t *testing.T) { + recent := time.Now().Add(-2 * time.Hour).UTC().Format(time.RFC3339) + got := formatCreated(recent, false) + if got == recent { + t.Errorf("expected humanized time, got raw timestamp %q", got) + } + if got == "" { + t.Error("expected non-empty result") + } + }) +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +go test ./internal/cli/ -run 'TestFormat' -v +``` + +Expected: compilation error — `formatDigest` and `formatCreated` +are undefined. + +--- + +### Task 3: Implement formatting helpers + +**Files:** +- Modify: `internal/cli/list.go` + +- [ ] **Step 1: Add the two formatting functions to list.go** + +Add these functions at the bottom of `internal/cli/list.go`: + +```go +func formatDigest(digest string, noTrunc bool) string { + if digest == "" { + return "" + } + if noTrunc { + return digest + } + if idx := strings.IndexByte(digest, ':'); idx >= 0 { + digest = digest[idx+1:] + } + if len(digest) > 12 { + digest = digest[:12] + } + return digest +} + +func formatCreated(created string, noTrunc bool) string { + if created == "" { + return "" + } + if noTrunc { + return created + } + t, err := time.Parse(time.RFC3339, created) + if err != nil { + return created + } + return humanize.Time(t) +} +``` + +- [ ] **Step 2: Update the imports** + +Replace the import block at the top of `list.go` with: + +```go +import ( + "fmt" + "strings" + "text/tabwriter" + "time" + + humanize "github.com/dustin/go-humanize" + "github.com/spf13/cobra" + + "github.com/redhat-et/skillimage/pkg/installed" + "github.com/redhat-et/skillimage/pkg/oci" +) +``` + +- [ ] **Step 3: Run tests to verify they pass** + +Run: + +```bash +go test ./internal/cli/ -run 'TestFormat' -v +``` + +Expected: all tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add internal/cli/list.go internal/cli/list_test.go +git commit -s -m "feat: add digest and timestamp formatting helpers + +formatDigest strips the algo: prefix and truncates to 12 chars. +formatCreated renders relative time using go-humanize. +Both accept a noTrunc flag to preserve raw values. + +Closes #23 + +Assisted-By: Claude (Anthropic AI) " +``` + +--- + +### Task 4: Wire formatting into list output and add --no-trunc flag + +**Files:** +- Modify: `internal/cli/list.go` + +- [ ] **Step 1: Add --no-trunc flag to newListCmd** + +In `newListCmd()`, add a new variable and flag registration. +After the existing `var upgradable bool` line, add: + +```go +var noTrunc bool +``` + +After the existing `cmd.Flags().BoolVarP(&upgradable, ...)` line, +add: + +```go +cmd.Flags().BoolVar(&noTrunc, "no-trunc", false, "show full digest and raw timestamps") +``` + +- [ ] **Step 2: Pass noTrunc to runList** + +Change the `runList` call in the `RunE` closure from: + +```go +return runList(cmd) +``` + +to: + +```go +return runList(cmd, noTrunc) +``` + +- [ ] **Step 3: Update runList signature and formatting** + +Change `runList` to accept the `noTrunc` parameter and use +the formatting helpers. Replace the entire `runList` function: + +```go +func runList(cmd *cobra.Command, noTrunc bool) error { + client, err := defaultClient() + if err != nil { + return err + } + + images, err := client.ListLocal() + if err != nil { + return fmt.Errorf("listing images: %w", err) + } + + if len(images) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No images found in local store.") + return nil + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tTAG\tSTATUS\tDIGEST\tCREATED") + for _, img := range images { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", + img.Name, img.Tag, img.Status, + formatDigest(img.Digest, noTrunc), + formatCreated(img.Created, noTrunc)) + } + return w.Flush() +} +``` + +- [ ] **Step 4: Run full test suite** + +Run: + +```bash +go test ./internal/cli/ -v +``` + +Expected: all tests pass. + +- [ ] **Step 5: Run linter** + +Run: + +```bash +make lint +``` + +Expected: no new warnings. + +- [ ] **Step 6: Build and smoke test** + +Run: + +```bash +make build && bin/skillctl list +``` + +Verify output shows short digests (no `sha256:` prefix) and +relative timestamps. Then: + +```bash +bin/skillctl list --no-trunc +``` + +Verify output shows full digests with `sha256:` prefix and +raw RFC 3339 timestamps. + +- [ ] **Step 7: Commit** + +```bash +git add internal/cli/list.go +git commit -s -m "feat: wire formatting into list output with --no-trunc flag + +Default output now strips the sha256: prefix from digests and +shows relative timestamps. Use --no-trunc for raw values. + +Assisted-By: Claude (Anthropic AI) " +``` diff --git a/dev/specs/2026-04-20-catalog-metadata-annotations-design.md b/docs/dev/specs/2026-04-20-catalog-metadata-annotations-design.md similarity index 100% rename from dev/specs/2026-04-20-catalog-metadata-annotations-design.md rename to docs/dev/specs/2026-04-20-catalog-metadata-annotations-design.md diff --git a/dev/specs/2026-04-24-phase2a-catalog-server-design.md b/docs/dev/specs/2026-04-24-phase2a-catalog-server-design.md similarity index 100% rename from dev/specs/2026-04-24-phase2a-catalog-server-design.md rename to docs/dev/specs/2026-04-24-phase2a-catalog-server-design.md diff --git a/dev/specs/2026-04-27-skill-collections-design.md b/docs/dev/specs/2026-04-27-skill-collections-design.md similarity index 100% rename from dev/specs/2026-04-27-skill-collections-design.md rename to docs/dev/specs/2026-04-27-skill-collections-design.md diff --git a/dev/specs/2026-04-29-remote-git-sources-design.md b/docs/dev/specs/2026-04-29-remote-git-sources-design.md similarity index 100% rename from dev/specs/2026-04-29-remote-git-sources-design.md rename to docs/dev/specs/2026-04-29-remote-git-sources-design.md diff --git a/dev/specs/2026-04-30-installed-skills-listing-design.md b/docs/dev/specs/2026-04-30-installed-skills-listing-design.md similarity index 100% rename from dev/specs/2026-04-30-installed-skills-listing-design.md rename to docs/dev/specs/2026-04-30-installed-skills-listing-design.md diff --git a/dev/specs/2026-04-30-rm-delete-command-design.md b/docs/dev/specs/2026-04-30-rm-delete-command-design.md similarity index 100% rename from dev/specs/2026-04-30-rm-delete-command-design.md rename to docs/dev/specs/2026-04-30-rm-delete-command-design.md diff --git a/dev/specs/2026-04-30-upgrade-command-design.md b/docs/dev/specs/2026-04-30-upgrade-command-design.md similarity index 100% rename from dev/specs/2026-04-30-upgrade-command-design.md rename to docs/dev/specs/2026-04-30-upgrade-command-design.md diff --git a/docs/dev/specs/2026-05-01-collection-install-git-sources-design.md b/docs/dev/specs/2026-05-01-collection-install-git-sources-design.md new file mode 100644 index 0000000..ecb936e --- /dev/null +++ b/docs/dev/specs/2026-05-01-collection-install-git-sources-design.md @@ -0,0 +1,214 @@ +# Collection install with Git source support + +## Summary + +Add `skillctl collection install` to install skills from a +collection YAML directly into an agent's skill directory. +Extend the `SkillRef` struct to support a `source:` field +pointing to Git repository URLs, enabling a faster inner loop +for teams iterating on skills without the full +build-push-pull cycle. + +Closes #35. + +## Motivation + +Teams collaborating on skills across Git branches currently +must build, push to a registry, and pull before testing +changes. This friction slows the development inner loop. +A `source:` field in collection YAML lets developers point +directly at Git branches and install skills with a single +command, while `image:` entries continue to work for +production/stable skills. + +## Design + +### Data model: `SkillRef` in `pkg/collection/` + +Add `Source` field to `SkillRef`: + +```go +type SkillRef struct { + Name string `yaml:"name,omitempty"` + Image string `yaml:"image,omitempty"` + Source string `yaml:"source,omitempty"` +} +``` + +- `name` becomes optional for `source:` entries (derived from + SKILL.md frontmatter via `source.Resolve()`) +- `image` and `source` are mutually exclusive per entry +- Each entry must have exactly one of `image` or `source` + +### Collection YAML example + +```yaml +apiVersion: skillimage.io/v1alpha1 +kind: SkillCollection +metadata: + name: team-skills + version: 0.1.0 + description: Dev manifest for our team +skills: + - source: https://github.com/myorg/skills/tree/feature-branch/code-reviewer + - source: https://github.com/myorg/skills/tree/main/meeting-notes + - name: stable-tool + image: quay.io/myorg/stable-tool:1.0.0 +``` + +### Validation changes + +Update `Validate()` in `pkg/collection/collection.go`: + +| Rule | Error message | +| ---- | ------------- | +| Neither `image` nor `source` set | `skills[N]: image or source is required` | +| Both `image` and `source` set | `skills[N]: image and source are mutually exclusive` | +| `image` entry without `name` | `skills[N].name is required` (unchanged) | +| `source` entry without `name` | Valid (name derived at install time) | +| Duplicate names (when known) | `duplicate skill name "X"` (unchanged; for `source:` entries without `name`, duplicates are detected at install time after name resolution) | + +Strict YAML parsing (`KnownFields(true)`) already rejects +unknown fields, so the `source` field must be added to the +struct for YAML files that use it to parse successfully. + +### Command: `collection install` + +```text +skillctl collection install [-f | ] \ + [--target | -o ] \ + [--force] [--ref ] +``` + +**Flags:** + +| Flag | Short | Type | Default | Description | +| ---- | ----- | ---- | ------- | ----------- | +| `--file` | `-f` | string | `""` | Path to local collection YAML | +| `--target` | `-t` | string | `""` | Agent name (claude, cursor, etc.) | +| `--output` | `-o` | string | `""` | Custom output directory | +| `--force` | | bool | false | Reinstall even if up to date | +| `--ref` | | string | `""` | Git ref override for collection YAML URL | + +**Input resolution:** + +- `-f ` reads a local YAML file +- Positional Git URL: uses `source.ParseGitURL()` to get + clone URL and subpath, clones with sparse checkout, reads + the YAML file at the resolved subpath. The URL must point + to the collection YAML file itself (e.g., + `https://github.com/myorg/skills/tree/main/collection.yaml`) +- Exactly one of `-f` or positional URL required + +**Target resolution** reuses the existing `agentTargets` map +from `install.go`. One of `--target` or `-o` is required. + +### Install flow + +For each entry in `skills[]`: + +**`source:` entries:** + +1. Check provenance: read `skill.yaml` in target dir for + matching skill name, extract stored commit SHA +1. Run `git ls-remote` against the source URL's ref to get + current commit SHA +1. If SHAs match and `--force` is not set, skip ("up to date") +1. Call `source.Resolve()` to clone, discover SKILL.md, + generate SkillCard with provenance +1. Call `oci.Build()` to build into local OCI store +1. Call `client.Unpack()` to install to target directory +1. Write provenance with Git commit SHA via `writeProvenance()` + +**`image:` entries:** + +1. Check provenance: read stored digest from `skill.yaml` + in target dir +1. Call `client.ResolveDigest()` to get current remote digest +1. If digests match and `--force` is not set, skip +1. Call `client.Pull()` if not in local store +1. Call `client.Unpack()` to install to target directory +1. Write provenance with OCI digest + +### Skip logic detail + +For `source:` entries, the skip check uses `git ls-remote`: + +```bash +git ls-remote +``` + +This returns the commit SHA for the ref without cloning. +Compare against `provenance.commit` in the installed +`skill.yaml`. This is a single lightweight network call +per source entry. + +For `image:` entries, `client.ResolveDigest()` does a HEAD +request to the registry. Compare against `provenance.commit` +(which stores the digest for OCI-sourced skills). + +### Error handling + +| Scenario | Behavior | +| -------- | -------- | +| Bad YAML / validation failure | Fail fast, no partial install | +| Both `image` and `source` on entry | Validation error | +| Individual skill build/pull fails | Print error, continue | +| `git` not in PATH (source entries) | Error from `source.CheckGit()` | +| Network unreachable | Error with wrapped details | +| Neither `-f` nor URL provided | Error: usage message | +| Neither `--target` nor `-o` | Error | + +Continue-on-error for individual skills: if one fails, the +rest still install. Exit with error if any failed. + +### Output format + +```text +Installing collection "team-skills" (3 skills) + code-reviewer (source) cloning... building... installed + meeting-notes (source) up to date + stable-tool (image) pulling... installed +Installed 2 skills, 1 up to date +``` + +### Files changed + +| File | Change | +| ---- | ------ | +| `pkg/collection/collection.go` | Add `Source` to `SkillRef`, update `Validate()` | +| `pkg/collection/collection_test.go` | Tests for source/image mutual exclusivity, optional name | +| `internal/cli/collection.go` | New `newCollectionInstallCmd()` and `runCollectionInstall()` | +| `internal/cli/install.go` | Extract shared helpers (e.g., `resolveOutputDir()`) | +| `examples/dev-collection.yaml` | Example collection with mixed source and image entries | + +### Testing + +**`pkg/collection/` unit tests:** + +- Parse YAML with `source:` field succeeds +- Parse YAML with both `image:` and `source:` on same entry + fails strict parsing (or validation catches it) +- Validate: entry with only `source:` and no `name:` is valid +- Validate: entry with only `image:` and no `name:` is invalid +- Validate: entry with neither `image:` nor `source:` is invalid +- Validate: duplicate names still detected when names are set + +**CLI tests:** + +- `collection install -f` with local file containing + `image:` entries installs to target dir +- `collection install -f` with `source:` entries (needs Git + repo fixture or skip in CI) +- `--force` flag causes reinstall even when SHA matches +- Missing `--target`/`-o` produces error +- Invalid YAML produces error before any install attempt + +## Out of scope + +- `collection pull` handling `source:` entries (pull remains + OCI-only) +- Authentication for private Git repositories +- Parallel cloning/building of source entries +- Lock file for pinning exact commit SHAs +- Auto-upgrade / watch mode diff --git a/docs/dev/specs/2026-05-01-list-output-formatting-design.md b/docs/dev/specs/2026-05-01-list-output-formatting-design.md new file mode 100644 index 0000000..bd94321 --- /dev/null +++ b/docs/dev/specs/2026-05-01-list-output-formatting-design.md @@ -0,0 +1,84 @@ +# Design: Improve list output formatting + +**Issue:** #23 +**Date:** 2026-05-01 + +## Summary + +Update `skillctl list` output to match container tool conventions +by stripping the `sha256:` prefix from digests and humanizing +timestamps. Add `--no-trunc` flag to preserve access to raw values +for scripting. + +## Current output + +```text +NAME TAG STATUS DIGEST CREATED +examples/hello-world 1.0.0-draft draft sha256:a593244d38f0 2026-04-29T22:07:29Z +``` + +## Proposed output + +```text +NAME TAG STATUS DIGEST CREATED +examples/hello-world 1.0.0-draft draft a593244d38f0 2 days ago +``` + +With `--no-trunc`: + +```text +NAME TAG STATUS DIGEST CREATED +examples/hello-world 1.0.0-draft draft sha256:a593244d38f0e1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4 2026-04-29T22:07:29Z +``` + +## Changes + +| Aspect | Current | New | +| ------ | ------- | --- | +| Digest display | `sha256:a593244d38f0` (19 chars, includes prefix) | `a593244d38f0` (12 hex chars, no prefix) | +| Timestamp display | `2026-04-29T22:07:29Z` (RFC 3339) | `2 days ago` (relative) | +| Column header | DIGEST | DIGEST (unchanged) | +| `--no-trunc` flag | Does not exist | Shows full digest with prefix + raw RFC 3339 | + +## Scope + +- Only the local store listing (`skillctl list` without `--installed`) + is affected. The `--installed` and `--upgradable` views do not + show digest or timestamp columns. +- No SIZE column. Skills are small text files; the value would not + be actionable. +- No `--format` flag in this iteration. + +## Implementation + +### Dependency + +Add `github.com/dustin/go-humanize` for relative time formatting. + +### File changes + +**`internal/cli/list.go`** + +1. Add `--no-trunc` bool flag to the `list` command. +2. In `runList()`, pass `noTrunc` to the rendering logic. +3. Digest formatting: + - Default: strip `sha256:` prefix (or any `algo:` prefix), + truncate to 12 hex characters. + - `--no-trunc`: show the full digest string as-is. +4. Timestamp formatting: + - Default: parse RFC 3339 string with `time.Parse`, format with + `humanize.Time(t)`. On parse failure, fall back to the raw + string. + - `--no-trunc`: show the raw RFC 3339 string. + +### No other file changes + +The `LocalImage` struct and OCI layer remain unchanged. Formatting +is a display concern handled entirely in the CLI layer. + +## Testing + +- Update or add tests in `internal/cli/list_test.go` to verify: + - Default output strips prefix and shows relative time + - `--no-trunc` output preserves full digest and raw timestamp + - Graceful fallback when `Created` is empty or unparseable diff --git a/docs/CNAME b/site/CNAME similarity index 100% rename from docs/CNAME rename to site/CNAME diff --git a/docs/demo/index.html b/site/demo/index.html similarity index 100% rename from docs/demo/index.html rename to site/demo/index.html diff --git a/docs/examples.html b/site/examples.html similarity index 100% rename from docs/examples.html rename to site/examples.html diff --git a/docs/index.html b/site/index.html similarity index 100% rename from docs/index.html rename to site/index.html diff --git a/docs/llms-full.txt b/site/llms-full.txt similarity index 100% rename from docs/llms-full.txt rename to site/llms-full.txt diff --git a/docs/llms.txt b/site/llms.txt similarity index 100% rename from docs/llms.txt rename to site/llms.txt diff --git a/docs/slides/index.html b/site/slides/index.html similarity index 100% rename from docs/slides/index.html rename to site/slides/index.html diff --git a/docs/slides/oci-skill-distribution-deck.html b/site/slides/oci-skill-distribution-deck.html similarity index 100% rename from docs/slides/oci-skill-distribution-deck.html rename to site/slides/oci-skill-distribution-deck.html diff --git a/docs/slides/skillimage-deck.html b/site/slides/skillimage-deck.html similarity index 100% rename from docs/slides/skillimage-deck.html rename to site/slides/skillimage-deck.html From ccf505c7bcab3661f1cdde27b4832a16933be645 Mon Sep 17 00:00:00 2001 From: Pavel Anni Date: Tue, 5 May 2026 20:19:42 -0400 Subject: [PATCH 2/2] docs: add demo link to landing page Assisted-By: Claude (Anthropic AI) Signed-off-by: Pavel Anni --- site/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/site/index.html b/site/index.html index 4079548..4edb10a 100644 --- a/site/index.html +++ b/site/index.html @@ -338,6 +338,7 @@

AI Agent Skills as OCI Images

GitHub Install Examples + Demo Presentations