diff --git a/api/v1alpha1/agentteam_types.go b/api/v1alpha1/agentteam_types.go
index 046e701..8238035 100644
--- a/api/v1alpha1/agentteam_types.go
+++ b/api/v1alpha1/agentteam_types.go
@@ -66,6 +66,16 @@ type AgentTeamSpec struct {
// +kubebuilder:default="claude-code"
// +optional
Harness string `json:"harness,omitempty"`
+
+ // ImagePullSecrets are credentials for pulling private container
+ // images, including OCI-distributed skills. The same secrets are
+ // applied to agent pods (for the runner image) and to skill-puller
+ // init containers (for pulling skill artifacts via ORAS). Use
+ // kubernetes.io/dockerconfigjson Secrets — the operator mounts them
+ // into the init container so ORAS can resolve registry credentials
+ // from $DOCKER_CONFIG.
+ // +optional
+ ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"`
}
// RepositorySpec defines the git repository configuration.
@@ -127,20 +137,37 @@ type LeadSpec struct {
Resources corev1.ResourceRequirements `json:"resources,omitempty"`
}
-// SkillSource identifies where to load a skill from. Exactly one field should be set.
+// SkillSource identifies where to load a skill from. Exactly one of
+// ConfigMap or OCI must be set (enforced by CEL on SkillSpec).
+//
+// ConfigMap is simplest and lives entirely within the cluster — good
+// for skills authored alongside the team CRs. OCI distributes skills
+// as registry artifacts so they can be versioned, signed, shared
+// across clusters, and pulled from public or private registries.
type SkillSource struct {
// ConfigMap references a ConfigMap in the same namespace.
// Each key in the ConfigMap becomes a file in the skill directory.
// +optional
ConfigMap string `json:"configMap,omitempty"`
- // OCI is an OCI artifact reference containing the skill files (e.g. "ghcr.io/org/skills/web-research:v1").
- // TODO: OCI skill pulling is not yet implemented; use ConfigMap instead.
+ // OCI is an OCI artifact reference containing the skill files
+ // (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs
+ // an `oras pull` init container at pod startup to materialize the
+ // skill onto an emptyDir; the main container then sees the files
+ // under ~/.claude/skills/{name}/. Private registries are supported
+ // via spec.imagePullSecrets.
+ //
+ // Re-pull semantics: the init container runs once per pod start,
+ // so the artifact is re-pulled on every pod create. There is no
+ // shared cache between pods — operators who want one should pin
+ // to immutable digests so the registry can short-circuit identical
+ // pulls cheaply.
// +optional
OCI string `json:"oci,omitempty"`
}
// SkillSpec defines a Claude Code skill to mount into an agent pod.
+// +kubebuilder:validation:XValidation:rule="(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci) ? 1 : 0) == 1",message="skill source must set exactly one of configMap or oci"
type SkillSpec struct {
// Name is the skill directory name under .claude/skills/.
Name string `json:"name"`
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index a94b092..07f02fa 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -21,7 +21,8 @@ limitations under the License.
package v1alpha1
import (
- "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
)
@@ -360,6 +361,11 @@ func (in *AgentTeamSpec) DeepCopyInto(out *AgentTeamSpec) {
*out = new(PipelineSpec)
(*in).DeepCopyInto(*out)
}
+ if in.ImagePullSecrets != nil {
+ in, out := &in.ImagePullSecrets, &out.ImagePullSecrets
+ *out = make([]v1.LocalObjectReference, len(*in))
+ copy(*out, *in)
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentTeamSpec.
@@ -424,7 +430,7 @@ func (in *AgentTeamStatus) DeepCopyInto(out *AgentTeamStatus) {
}
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
- *out = make([]v1.Condition, len(*in))
+ *out = make([]metav1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
@@ -542,7 +548,7 @@ func (in *AgentTeamTemplateStatus) DeepCopyInto(out *AgentTeamTemplateStatus) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
- *out = make([]v1.Condition, len(*in))
+ *out = make([]metav1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
diff --git a/charts/kagents/crds/kagents.dev_agentteamruns.yaml b/charts/kagents/crds/kagents.dev_agentteamruns.yaml
index e48869e..2cb0014 100644
--- a/charts/kagents/crds/kagents.dev_agentteamruns.yaml
+++ b/charts/kagents/crds/kagents.dev_agentteamruns.yaml
@@ -187,14 +187,30 @@ spec:
Each key in the ConfigMap becomes a file in the skill directory.
type: string
oci:
- description: OCI is an OCI artifact reference containing
- the skill files (e.g. "ghcr.io/org/skills/web-research:v1").
+ description: |-
+ OCI is an OCI artifact reference containing the skill files
+ (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs
+ an `oras pull` init container at pod startup to materialize the
+ skill onto an emptyDir; the main container then sees the files
+ under ~/.claude/skills/{name}/. Private registries are supported
+ via spec.imagePullSecrets.
+
+ Re-pull semantics: the init container runs once per pod start,
+ so the artifact is re-pulled on every pod create. There is no
+ shared cache between pods — operators who want one should pin
+ to immutable digests so the registry can short-circuit identical
+ pulls cheaply.
type: string
type: object
required:
- name
- source
type: object
+ x-kubernetes-validations:
+ - message: skill source must set exactly one of configMap or
+ oci
+ rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci)
+ ? 1 : 0) == 1'
type: array
required:
- prompt
diff --git a/charts/kagents/crds/kagents.dev_agentteams.yaml b/charts/kagents/crds/kagents.dev_agentteams.yaml
index 5d3f1bd..fd17ac4 100644
--- a/charts/kagents/crds/kagents.dev_agentteams.yaml
+++ b/charts/kagents/crds/kagents.dev_agentteams.yaml
@@ -119,6 +119,32 @@ spec:
enum:
- claude-code
type: string
+ imagePullSecrets:
+ description: |-
+ ImagePullSecrets are credentials for pulling private container
+ images, including OCI-distributed skills. The same secrets are
+ applied to agent pods (for the runner image) and to skill-puller
+ init containers (for pulling skill artifacts via ORAS). Use
+ kubernetes.io/dockerconfigjson Secrets — the operator mounts them
+ into the init container so ORAS can resolve registry credentials
+ from $DOCKER_CONFIG.
+ items:
+ description: |-
+ LocalObjectReference contains enough information to let you locate the
+ referenced object inside the same namespace.
+ properties:
+ name:
+ default: ""
+ description: |-
+ Name of the referent.
+ This field is effectively required, but due to backwards compatibility is
+ allowed to be empty. Instances of this type with an empty value here are
+ almost certainly wrong.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ type: string
+ type: object
+ x-kubernetes-map-type: atomic
+ type: array
lead:
description: Lead configures the team lead agent.
properties:
@@ -244,14 +270,30 @@ spec:
Each key in the ConfigMap becomes a file in the skill directory.
type: string
oci:
- description: OCI is an OCI artifact reference containing
- the skill files (e.g. "ghcr.io/org/skills/web-research:v1").
+ description: |-
+ OCI is an OCI artifact reference containing the skill files
+ (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs
+ an `oras pull` init container at pod startup to materialize the
+ skill onto an emptyDir; the main container then sees the files
+ under ~/.claude/skills/{name}/. Private registries are supported
+ via spec.imagePullSecrets.
+
+ Re-pull semantics: the init container runs once per pod start,
+ so the artifact is re-pulled on every pod create. There is no
+ shared cache between pods — operators who want one should pin
+ to immutable digests so the registry can short-circuit identical
+ pulls cheaply.
type: string
type: object
required:
- name
- source
type: object
+ x-kubernetes-validations:
+ - message: skill source must set exactly one of configMap or
+ oci
+ rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci)
+ ? 1 : 0) == 1'
type: array
required:
- prompt
@@ -825,14 +867,30 @@ spec:
Each key in the ConfigMap becomes a file in the skill directory.
type: string
oci:
- description: OCI is an OCI artifact reference containing
- the skill files (e.g. "ghcr.io/org/skills/web-research:v1").
+ description: |-
+ OCI is an OCI artifact reference containing the skill files
+ (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs
+ an `oras pull` init container at pod startup to materialize the
+ skill onto an emptyDir; the main container then sees the files
+ under ~/.claude/skills/{name}/. Private registries are supported
+ via spec.imagePullSecrets.
+
+ Re-pull semantics: the init container runs once per pod start,
+ so the artifact is re-pulled on every pod create. There is no
+ shared cache between pods — operators who want one should pin
+ to immutable digests so the registry can short-circuit identical
+ pulls cheaply.
type: string
type: object
required:
- name
- source
type: object
+ x-kubernetes-validations:
+ - message: skill source must set exactly one of configMap
+ or oci
+ rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci)
+ ? 1 : 0) == 1'
type: array
required:
- name
diff --git a/charts/kagents/crds/kagents.dev_agentteamschedules.yaml b/charts/kagents/crds/kagents.dev_agentteamschedules.yaml
index 2ca45e8..68a717b 100644
--- a/charts/kagents/crds/kagents.dev_agentteamschedules.yaml
+++ b/charts/kagents/crds/kagents.dev_agentteamschedules.yaml
@@ -206,14 +206,30 @@ spec:
Each key in the ConfigMap becomes a file in the skill directory.
type: string
oci:
- description: OCI is an OCI artifact reference containing
- the skill files (e.g. "ghcr.io/org/skills/web-research:v1").
+ description: |-
+ OCI is an OCI artifact reference containing the skill files
+ (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs
+ an `oras pull` init container at pod startup to materialize the
+ skill onto an emptyDir; the main container then sees the files
+ under ~/.claude/skills/{name}/. Private registries are supported
+ via spec.imagePullSecrets.
+
+ Re-pull semantics: the init container runs once per pod start,
+ so the artifact is re-pulled on every pod create. There is no
+ shared cache between pods — operators who want one should pin
+ to immutable digests so the registry can short-circuit identical
+ pulls cheaply.
type: string
type: object
required:
- name
- source
type: object
+ x-kubernetes-validations:
+ - message: skill source must set exactly one of configMap or
+ oci
+ rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci)
+ ? 1 : 0) == 1'
type: array
required:
- prompt
diff --git a/charts/kagents/crds/kagents.dev_agentteamtemplates.yaml b/charts/kagents/crds/kagents.dev_agentteamtemplates.yaml
index 49435c8..4171747 100644
--- a/charts/kagents/crds/kagents.dev_agentteamtemplates.yaml
+++ b/charts/kagents/crds/kagents.dev_agentteamtemplates.yaml
@@ -522,14 +522,30 @@ spec:
Each key in the ConfigMap becomes a file in the skill directory.
type: string
oci:
- description: OCI is an OCI artifact reference containing
- the skill files (e.g. "ghcr.io/org/skills/web-research:v1").
+ description: |-
+ OCI is an OCI artifact reference containing the skill files
+ (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs
+ an `oras pull` init container at pod startup to materialize the
+ skill onto an emptyDir; the main container then sees the files
+ under ~/.claude/skills/{name}/. Private registries are supported
+ via spec.imagePullSecrets.
+
+ Re-pull semantics: the init container runs once per pod start,
+ so the artifact is re-pulled on every pod create. There is no
+ shared cache between pods — operators who want one should pin
+ to immutable digests so the registry can short-circuit identical
+ pulls cheaply.
type: string
type: object
required:
- name
- source
type: object
+ x-kubernetes-validations:
+ - message: skill source must set exactly one of configMap
+ or oci
+ rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci)
+ ? 1 : 0) == 1'
type: array
required:
- name
diff --git a/charts/kagents/crds/kagents.dev_agentteamtriggers.yaml b/charts/kagents/crds/kagents.dev_agentteamtriggers.yaml
index 4cc54d1..8d30397 100644
--- a/charts/kagents/crds/kagents.dev_agentteamtriggers.yaml
+++ b/charts/kagents/crds/kagents.dev_agentteamtriggers.yaml
@@ -217,14 +217,30 @@ spec:
Each key in the ConfigMap becomes a file in the skill directory.
type: string
oci:
- description: OCI is an OCI artifact reference containing
- the skill files (e.g. "ghcr.io/org/skills/web-research:v1").
+ description: |-
+ OCI is an OCI artifact reference containing the skill files
+ (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs
+ an `oras pull` init container at pod startup to materialize the
+ skill onto an emptyDir; the main container then sees the files
+ under ~/.claude/skills/{name}/. Private registries are supported
+ via spec.imagePullSecrets.
+
+ Re-pull semantics: the init container runs once per pod start,
+ so the artifact is re-pulled on every pod create. There is no
+ shared cache between pods — operators who want one should pin
+ to immutable digests so the registry can short-circuit identical
+ pulls cheaply.
type: string
type: object
required:
- name
- source
type: object
+ x-kubernetes-validations:
+ - message: skill source must set exactly one of configMap or
+ oci
+ rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci)
+ ? 1 : 0) == 1'
type: array
required:
- prompt
diff --git a/cmd/manager/main.go b/cmd/manager/main.go
index fecaebf..f877d4c 100644
--- a/cmd/manager/main.go
+++ b/cmd/manager/main.go
@@ -37,6 +37,7 @@ func main() {
var enableLeaderElection bool
var agentImage string
var initImage string
+ var skillPullerImage string
var skipInitScript bool
var pvcAccessMode string
var agentCommand string
@@ -46,6 +47,7 @@ func main() {
flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager.")
flag.StringVar(&agentImage, "agent-image", "", "Override the container image used for agent pods (default: ghcr.io/amcheste/claude-code-runner:latest).")
flag.StringVar(&initImage, "init-image", "", "Override the container image used for the repo init Job (default: alpine/git:latest).")
+ flag.StringVar(&skillPullerImage, "skill-puller-image", "", "Override the container image used to pull OCI-distributed skills (default: ghcr.io/oras-project/oras:v1.2.0). Air-gapped clusters can pin to an internal mirror.")
flag.BoolVar(&skipInitScript, "skip-init-script", false, "Replace the init Job git-clone script with a no-op exit 0. Use in acceptance tests where no real repo is available.")
flag.StringVar(&pvcAccessMode, "pvc-access-mode", "", "Override PVC access mode for all operator-managed PVCs (ReadWriteMany|ReadWriteOnce). Defaults to ReadWriteMany. Set to ReadWriteOnce for single-node clusters like Kind.")
flag.StringVar(&agentCommand, "agent-command", "", "Override the agent container command as a comma-separated list (e.g. sh,-c,sleep 30 && exit 0). Used in acceptance tests to keep pods alive long enough to observe.")
@@ -71,13 +73,14 @@ func main() {
}
reconciler := &controller.AgentTeamReconciler{
- Client: mgr.GetClient(),
- Scheme: mgr.GetScheme(),
- AgentImage: agentImage,
- InitImage: initImage,
- SkipInitScript: skipInitScript,
- Harnesses: harness.DefaultRegistry(),
- Delivery: delivery.NewDispatcher(),
+ Client: mgr.GetClient(),
+ Scheme: mgr.GetScheme(),
+ AgentImage: agentImage,
+ InitImage: initImage,
+ SkillPullerImage: skillPullerImage,
+ SkipInitScript: skipInitScript,
+ Harnesses: harness.DefaultRegistry(),
+ Delivery: delivery.NewDispatcher(),
}
if agentCommand != "" {
reconciler.AgentCommand = strings.Split(agentCommand, ",")
diff --git a/config/crd/bases/kagents.dev_agentteamruns.yaml b/config/crd/bases/kagents.dev_agentteamruns.yaml
index e48869e..2cb0014 100644
--- a/config/crd/bases/kagents.dev_agentteamruns.yaml
+++ b/config/crd/bases/kagents.dev_agentteamruns.yaml
@@ -187,14 +187,30 @@ spec:
Each key in the ConfigMap becomes a file in the skill directory.
type: string
oci:
- description: OCI is an OCI artifact reference containing
- the skill files (e.g. "ghcr.io/org/skills/web-research:v1").
+ description: |-
+ OCI is an OCI artifact reference containing the skill files
+ (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs
+ an `oras pull` init container at pod startup to materialize the
+ skill onto an emptyDir; the main container then sees the files
+ under ~/.claude/skills/{name}/. Private registries are supported
+ via spec.imagePullSecrets.
+
+ Re-pull semantics: the init container runs once per pod start,
+ so the artifact is re-pulled on every pod create. There is no
+ shared cache between pods — operators who want one should pin
+ to immutable digests so the registry can short-circuit identical
+ pulls cheaply.
type: string
type: object
required:
- name
- source
type: object
+ x-kubernetes-validations:
+ - message: skill source must set exactly one of configMap or
+ oci
+ rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci)
+ ? 1 : 0) == 1'
type: array
required:
- prompt
diff --git a/config/crd/bases/kagents.dev_agentteams.yaml b/config/crd/bases/kagents.dev_agentteams.yaml
index 5d3f1bd..fd17ac4 100644
--- a/config/crd/bases/kagents.dev_agentteams.yaml
+++ b/config/crd/bases/kagents.dev_agentteams.yaml
@@ -119,6 +119,32 @@ spec:
enum:
- claude-code
type: string
+ imagePullSecrets:
+ description: |-
+ ImagePullSecrets are credentials for pulling private container
+ images, including OCI-distributed skills. The same secrets are
+ applied to agent pods (for the runner image) and to skill-puller
+ init containers (for pulling skill artifacts via ORAS). Use
+ kubernetes.io/dockerconfigjson Secrets — the operator mounts them
+ into the init container so ORAS can resolve registry credentials
+ from $DOCKER_CONFIG.
+ items:
+ description: |-
+ LocalObjectReference contains enough information to let you locate the
+ referenced object inside the same namespace.
+ properties:
+ name:
+ default: ""
+ description: |-
+ Name of the referent.
+ This field is effectively required, but due to backwards compatibility is
+ allowed to be empty. Instances of this type with an empty value here are
+ almost certainly wrong.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ type: string
+ type: object
+ x-kubernetes-map-type: atomic
+ type: array
lead:
description: Lead configures the team lead agent.
properties:
@@ -244,14 +270,30 @@ spec:
Each key in the ConfigMap becomes a file in the skill directory.
type: string
oci:
- description: OCI is an OCI artifact reference containing
- the skill files (e.g. "ghcr.io/org/skills/web-research:v1").
+ description: |-
+ OCI is an OCI artifact reference containing the skill files
+ (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs
+ an `oras pull` init container at pod startup to materialize the
+ skill onto an emptyDir; the main container then sees the files
+ under ~/.claude/skills/{name}/. Private registries are supported
+ via spec.imagePullSecrets.
+
+ Re-pull semantics: the init container runs once per pod start,
+ so the artifact is re-pulled on every pod create. There is no
+ shared cache between pods — operators who want one should pin
+ to immutable digests so the registry can short-circuit identical
+ pulls cheaply.
type: string
type: object
required:
- name
- source
type: object
+ x-kubernetes-validations:
+ - message: skill source must set exactly one of configMap or
+ oci
+ rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci)
+ ? 1 : 0) == 1'
type: array
required:
- prompt
@@ -825,14 +867,30 @@ spec:
Each key in the ConfigMap becomes a file in the skill directory.
type: string
oci:
- description: OCI is an OCI artifact reference containing
- the skill files (e.g. "ghcr.io/org/skills/web-research:v1").
+ description: |-
+ OCI is an OCI artifact reference containing the skill files
+ (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs
+ an `oras pull` init container at pod startup to materialize the
+ skill onto an emptyDir; the main container then sees the files
+ under ~/.claude/skills/{name}/. Private registries are supported
+ via spec.imagePullSecrets.
+
+ Re-pull semantics: the init container runs once per pod start,
+ so the artifact is re-pulled on every pod create. There is no
+ shared cache between pods — operators who want one should pin
+ to immutable digests so the registry can short-circuit identical
+ pulls cheaply.
type: string
type: object
required:
- name
- source
type: object
+ x-kubernetes-validations:
+ - message: skill source must set exactly one of configMap
+ or oci
+ rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci)
+ ? 1 : 0) == 1'
type: array
required:
- name
diff --git a/config/crd/bases/kagents.dev_agentteamschedules.yaml b/config/crd/bases/kagents.dev_agentteamschedules.yaml
index 2ca45e8..68a717b 100644
--- a/config/crd/bases/kagents.dev_agentteamschedules.yaml
+++ b/config/crd/bases/kagents.dev_agentteamschedules.yaml
@@ -206,14 +206,30 @@ spec:
Each key in the ConfigMap becomes a file in the skill directory.
type: string
oci:
- description: OCI is an OCI artifact reference containing
- the skill files (e.g. "ghcr.io/org/skills/web-research:v1").
+ description: |-
+ OCI is an OCI artifact reference containing the skill files
+ (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs
+ an `oras pull` init container at pod startup to materialize the
+ skill onto an emptyDir; the main container then sees the files
+ under ~/.claude/skills/{name}/. Private registries are supported
+ via spec.imagePullSecrets.
+
+ Re-pull semantics: the init container runs once per pod start,
+ so the artifact is re-pulled on every pod create. There is no
+ shared cache between pods — operators who want one should pin
+ to immutable digests so the registry can short-circuit identical
+ pulls cheaply.
type: string
type: object
required:
- name
- source
type: object
+ x-kubernetes-validations:
+ - message: skill source must set exactly one of configMap or
+ oci
+ rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci)
+ ? 1 : 0) == 1'
type: array
required:
- prompt
diff --git a/config/crd/bases/kagents.dev_agentteamtemplates.yaml b/config/crd/bases/kagents.dev_agentteamtemplates.yaml
index 49435c8..4171747 100644
--- a/config/crd/bases/kagents.dev_agentteamtemplates.yaml
+++ b/config/crd/bases/kagents.dev_agentteamtemplates.yaml
@@ -522,14 +522,30 @@ spec:
Each key in the ConfigMap becomes a file in the skill directory.
type: string
oci:
- description: OCI is an OCI artifact reference containing
- the skill files (e.g. "ghcr.io/org/skills/web-research:v1").
+ description: |-
+ OCI is an OCI artifact reference containing the skill files
+ (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs
+ an `oras pull` init container at pod startup to materialize the
+ skill onto an emptyDir; the main container then sees the files
+ under ~/.claude/skills/{name}/. Private registries are supported
+ via spec.imagePullSecrets.
+
+ Re-pull semantics: the init container runs once per pod start,
+ so the artifact is re-pulled on every pod create. There is no
+ shared cache between pods — operators who want one should pin
+ to immutable digests so the registry can short-circuit identical
+ pulls cheaply.
type: string
type: object
required:
- name
- source
type: object
+ x-kubernetes-validations:
+ - message: skill source must set exactly one of configMap
+ or oci
+ rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci)
+ ? 1 : 0) == 1'
type: array
required:
- name
diff --git a/config/crd/bases/kagents.dev_agentteamtriggers.yaml b/config/crd/bases/kagents.dev_agentteamtriggers.yaml
index 4cc54d1..8d30397 100644
--- a/config/crd/bases/kagents.dev_agentteamtriggers.yaml
+++ b/config/crd/bases/kagents.dev_agentteamtriggers.yaml
@@ -217,14 +217,30 @@ spec:
Each key in the ConfigMap becomes a file in the skill directory.
type: string
oci:
- description: OCI is an OCI artifact reference containing
- the skill files (e.g. "ghcr.io/org/skills/web-research:v1").
+ description: |-
+ OCI is an OCI artifact reference containing the skill files
+ (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs
+ an `oras pull` init container at pod startup to materialize the
+ skill onto an emptyDir; the main container then sees the files
+ under ~/.claude/skills/{name}/. Private registries are supported
+ via spec.imagePullSecrets.
+
+ Re-pull semantics: the init container runs once per pod start,
+ so the artifact is re-pulled on every pod create. There is no
+ shared cache between pods — operators who want one should pin
+ to immutable digests so the registry can short-circuit identical
+ pulls cheaply.
type: string
type: object
required:
- name
- source
type: object
+ x-kubernetes-validations:
+ - message: skill source must set exactly one of configMap or
+ oci
+ rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci)
+ ? 1 : 0) == 1'
type: array
required:
- prompt
diff --git a/config/samples/oci-skills-team.yaml b/config/samples/oci-skills-team.yaml
new file mode 100644
index 0000000..f4f5110
--- /dev/null
+++ b/config/samples/oci-skills-team.yaml
@@ -0,0 +1,71 @@
+# OCI skill distribution sample — a Cowork team whose teammates load
+# skills from an OCI registry instead of ConfigMaps.
+#
+# Behavior:
+# - For each skill with `source.oci`, the operator adds an init
+# container that runs `oras pull` into a per-skill emptyDir.
+# - The main container mounts the same emptyDir read-only at
+# /var/claude-skills/{name}/; the runner entrypoint copies it to
+# ~/.claude/skills/{name}/ before launching Claude Code.
+# - ConfigMap-backed skills still work — both sources can coexist
+# on the same teammate.
+# - Private registries: list a kubernetes.io/dockerconfigjson Secret
+# under spec.imagePullSecrets. The operator both (a) propagates it
+# to the pod for kubelet image pulls, and (b) mounts its
+# .dockerconfigjson into the puller init container so ORAS sees
+# credentials via $DOCKER_CONFIG.
+#
+# Setup before applying (only if any skill is in a private registry):
+# kubectl -n cowork-agents create secret docker-registry ghcr-creds \
+# --docker-server=ghcr.io \
+# --docker-username=$GHCR_USER \
+# --docker-password=$GHCR_PAT
+#
+# Apply with:
+# kubectl apply -n cowork-agents -f config/samples/oci-skills-team.yaml
+apiVersion: kagents.dev/v1alpha1
+kind: AgentTeam
+metadata:
+ name: market-research
+ namespace: cowork-agents
+spec:
+ workspace:
+ output:
+ mountPath: "/workspace/output"
+ size: "1Gi"
+
+ auth:
+ apiKeySecret: "anthropic-api-key"
+
+ # Optional — only needed for private skill registries. The operator
+ # propagates this to the agent pod and to skill-puller init
+ # containers so both layers see credentials.
+ imagePullSecrets:
+ - name: ghcr-creds
+
+ lead:
+ model: "opus"
+ prompt: |
+ Coordinate a market-research brief. Use the web-research skill
+ to gather sources and the report-writing skill to format the
+ output.
+
+ teammates:
+ - name: "researcher"
+ model: "sonnet"
+ prompt: |
+ Research the topic and write findings to
+ /workspace/output/findings.md. Use the skills you've been
+ given for instructions.
+ skills:
+ # Public skill — pulled from a public registry, no creds needed.
+ - name: web-research
+ source:
+ oci: "ghcr.io/kagents/skills/web-research:v1"
+ # Private skill — works because spec.imagePullSecrets is set.
+ - name: report-writing
+ source:
+ oci: "ghcr.io/acme-internal/skills/report-writing:v3"
+ outputs:
+ - path: "/workspace/output/findings.md"
+ description: "Research findings."
diff --git a/docs/how-to/skill-authoring.md b/docs/how-to/skill-authoring.md
new file mode 100644
index 0000000..59c1879
--- /dev/null
+++ b/docs/how-to/skill-authoring.md
@@ -0,0 +1,150 @@
+# Authoring and Publishing Skills
+
+Skills are the unit of expertise a Claude Code agent loads at startup.
+They're a directory mounted at `~/.claude/skills/{name}/` containing
+instructions, examples, and templates. kagents supports two
+distribution mechanisms:
+
+- **ConfigMap** — skill files live inside the cluster as a ConfigMap.
+ Simplest, no network. Good for skills authored alongside the team CR.
+- **OCI** — skill files are packaged as an OCI artifact in a registry
+ (ghcr.io, ECR, GCR, internal Harbor, etc.) and pulled by an init
+ container at pod startup. Versioned, signed, shared across clusters.
+
+This page covers the OCI path. ConfigMap skills are just key/value
+files — see the API reference for `SkillSource.configMap`.
+
+## Anatomy of a skill
+
+A skill is a directory. Anthropic's Claude Code reads `SKILL.md` at
+load time and treats anything else in the directory as supplementary
+material it can reference.
+
+```
+my-skill/
+├── SKILL.md # required: the skill's instructions
+├── examples/ # optional: example inputs and expected outputs
+│ ├── input-1.md
+│ └── output-1.md
+└── templates/ # optional: output formats the agent fills in
+ └── report.tmpl
+```
+
+`SKILL.md` is markdown. It typically describes what the skill does,
+when the agent should reach for it, and any conventions the agent
+should follow.
+
+## Publishing as an OCI artifact
+
+[ORAS](https://oras.land) is the CLI that pushes arbitrary directories
+to an OCI-compatible registry. The kagents operator uses the same tool
+to pull them back at pod startup.
+
+```bash
+# From the skill's directory:
+oras push ghcr.io/myorg/skills/my-skill:v1 \
+ --artifact-type application/vnd.kagents.skill.v1 \
+ .
+```
+
+A few notes on the command above:
+
+- `--artifact-type` is conventional. The operator does not enforce it
+ today; future versions may use it to validate that an OCI reference
+ points at a skill rather than a random container image.
+- Tag immutably (`:v1`, `:v1.0.3`, or `@sha256:...`) — `:latest`
+ works but means every pod re-pulls in case the content moved.
+- Pin to a digest (`@sha256:...`) in production for byte-for-byte
+ reproducibility and to let the registry short-circuit identical
+ pulls cheaply.
+
+## Referencing a skill from an AgentTeam
+
+```yaml
+teammates:
+ - name: researcher
+ skills:
+ - name: my-skill
+ source:
+ oci: ghcr.io/myorg/skills/my-skill:v1
+```
+
+The operator pulls `ghcr.io/myorg/skills/my-skill:v1` into a per-skill
+emptyDir, mounts it at `/var/claude-skills/my-skill/`, and the runner
+entrypoint copies it to `~/.claude/skills/my-skill/` before launching
+Claude Code.
+
+## Private registries
+
+For skills in a private registry, point the team at a
+`kubernetes.io/dockerconfigjson` Secret:
+
+```bash
+kubectl create secret docker-registry ghcr-creds \
+ --docker-server=ghcr.io \
+ --docker-username=$GHCR_USER \
+ --docker-password=$GHCR_PAT
+```
+
+```yaml
+spec:
+ imagePullSecrets:
+ - name: ghcr-creds
+ teammates:
+ - name: researcher
+ skills:
+ - name: internal-skill
+ source:
+ oci: ghcr.io/internal/skills/private:v1
+```
+
+The operator does two things with the secret:
+
+1. **Propagates it to the pod** so the kubelet can pull the runner
+ image and the ORAS puller image from a private registry.
+2. **Mounts its `.dockerconfigjson` into the puller init container**
+ so `oras pull` sees credentials via `$DOCKER_CONFIG`.
+
+Multi-registry deployments: combine all credentials into a single
+dockerconfigjson Secret. The operator only mounts the first listed
+secret into the init container, so consolidating keeps things
+predictable.
+
+## Re-pull semantics
+
+The skill-puller init container runs once per pod start. There is no
+shared cache between pods. Two practical consequences:
+
+- **Pin to digests** in production. The registry can cheaply skip
+ identical content; mutable tags force a fresh pull each time.
+- **Skill artifacts should be small**. They're text + examples; if
+ yours is hundreds of MB, something is off — most skills measure in
+ kilobytes.
+
+## Air-gapped clusters
+
+The default puller image is `ghcr.io/oras-project/oras:v1.2.0`. In
+air-gapped or registry-restricted environments, mirror it to an
+internal registry and tell the operator via the
+`--skill-puller-image` flag:
+
+```yaml
+# Helm values.yaml
+manager:
+ skillPullerImage: registry.internal/oras:v1.2.0
+```
+
+## Skill content patterns
+
+Some patterns we've found useful:
+
+- **State the agent's persona up front.** "You are a financial
+ analyst." Clear framing improves output quality.
+- **Show, don't just tell.** A worked example in `examples/` is worth
+ ten paragraphs of "you should follow this structure."
+- **Templates beat prose for output shape.** If the agent should
+ produce a structured artifact, ship a `templates/report.tmpl` with
+ placeholders and reference it from `SKILL.md`.
+
+See `config/samples/oci-skills-team.yaml` for a working team that uses
+both public and private OCI skills.
diff --git a/docs/reference/api/index.md b/docs/reference/api/index.md
index 743d5e7..7030f98 100644
--- a/docs/reference/api/index.md
+++ b/docs/reference/api/index.md
@@ -187,6 +187,7 @@ _Appears in:_
| `observability` _[ObservabilitySpec](#observabilityspec)_ | Observability configures metrics and notifications. | | Optional: \{\}
|
| `pipeline` _[PipelineSpec](#pipelinespec)_ | Pipeline declares an ordered set of stages with explicit fan-out/merge
semantics. When set, the operator derives each teammate's effective
dependencies from the stage graph instead of the per-teammate DependsOn
field, which becomes mutually exclusive (enforced by CEL validation
on this spec). Inputs[].From still contributes regardless. | | Optional: \{\}
|
| `harness` _string_ | Harness selects the agent runtime that powers this team's pods.
Today the only supported value is "claude-code" (Anthropic's native
Claude Code Agent Teams protocol), which is also the default when
omitted. The field exists so the operator's API stays neutral to a
single agent runtime; future harnesses for other team-based agent
systems can plug in behind the same CRD without an API break. | claude-code | Enum: [claude-code]
Optional: \{\}
|
+| `imagePullSecrets` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#localobjectreference-v1-core) array_ | ImagePullSecrets are credentials for pulling private container
images, including OCI-distributed skills. The same secrets are
applied to agent pods (for the runner image) and to skill-puller
init containers (for pulling skill artifacts via ORAS). Use
kubernetes.io/dockerconfigjson Secrets — the operator mounts them
into the init container so ORAS can resolve registry credentials
from $DOCKER_CONFIG. | | Optional: \{\}
|
#### AgentTeamStatus
@@ -821,7 +822,13 @@ _Appears in:_
-SkillSource identifies where to load a skill from. Exactly one field should be set.
+SkillSource identifies where to load a skill from. Exactly one of
+ConfigMap or OCI must be set (enforced by CEL on SkillSpec).
+
+ConfigMap is simplest and lives entirely within the cluster — good
+for skills authored alongside the team CRs. OCI distributes skills
+as registry artifacts so they can be versioned, signed, shared
+across clusters, and pulled from public or private registries.
@@ -831,7 +838,7 @@ _Appears in:_
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `configMap` _string_ | ConfigMap references a ConfigMap in the same namespace.
Each key in the ConfigMap becomes a file in the skill directory. | | Optional: \{\}
|
-| `oci` _string_ | OCI is an OCI artifact reference containing the skill files (e.g. "ghcr.io/org/skills/web-research:v1"). | | Optional: \{\}
|
+| `oci` _string_ | OCI is an OCI artifact reference containing the skill files
(e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs
an `oras pull` init container at pod startup to materialize the
skill onto an emptyDir; the main container then sees the files
under ~/.claude/skills/\{name\}/. Private registries are supported
via spec.imagePullSecrets.
Re-pull semantics: the init container runs once per pod start,
so the artifact is re-pulled on every pod create. There is no
shared cache between pods — operators who want one should pin
to immutable digests so the registry can short-circuit identical
pulls cheaply. | | Optional: \{\}
|
#### SkillSpec
diff --git a/internal/controller/agentteam_controller.go b/internal/controller/agentteam_controller.go
index 6d353c1..e6bcb7b 100644
--- a/internal/controller/agentteam_controller.go
+++ b/internal/controller/agentteam_controller.go
@@ -35,6 +35,12 @@ import (
const (
defaultInitImage = "alpine/git:latest"
+
+ // defaultSkillPullerImage is the OCI artifact puller used to
+ // materialize skills declared with SkillSource.OCI. ORAS speaks
+ // the OCI distribution spec directly and supports artifacts that
+ // aren't container images, which is what skills are.
+ defaultSkillPullerImage = "ghcr.io/oras-project/oras:v1.2.0"
)
// AgentTeamReconciler reconciles an AgentTeam object.
@@ -49,6 +55,12 @@ type AgentTeamReconciler struct {
// InitImage overrides the default alpine/git image used for the repo init Job.
InitImage string
+ // SkillPullerImage overrides the default ORAS image used to pull
+ // OCI-distributed skills into agent pods. Tests can swap in a
+ // no-op image; air-gapped clusters can pin to an internally
+ // mirrored ORAS build.
+ SkillPullerImage string
+
// SkipInitScript replaces the init Job's git-clone script with a no-op (exit 0).
// Used in acceptance tests where no real git repository is available.
SkipInitScript bool
@@ -153,6 +165,13 @@ func (r *AgentTeamReconciler) initImage() string {
return defaultInitImage
}
+func (r *AgentTeamReconciler) skillPullerImage() string {
+ if r.SkillPullerImage != "" {
+ return r.SkillPullerImage
+ }
+ return defaultSkillPullerImage
+}
+
// +kubebuilder:rbac:groups=kagents.dev,resources=agentteams,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=kagents.dev,resources=agentteams/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=kagents.dev,resources=agentteams/finalizers,verbs=update
@@ -1297,26 +1316,84 @@ func (r *AgentTeamReconciler) buildAgentPod(
}
}
- // Skills: each ConfigMap-backed skill gets mounted at /var/claude-skills/{name}/.
- // The entrypoint copies them into ~/.claude/skills/{name}/.
+ // Skills: each skill is materialized at /var/claude-skills/{name}/;
+ // the runner entrypoint copies that directory into ~/.claude/skills/{name}/
+ // before launching Claude Code.
+ //
+ // ConfigMap source: mount directly. Cheapest path — no network, no
+ // extra container, just a projected ConfigMap volume.
+ //
+ // OCI source: an init container pulls the artifact via `oras` into
+ // a per-skill emptyDir that the main container also mounts. Pull
+ // credentials come from spec.imagePullSecrets — the first listed
+ // kubernetes.io/dockerconfigjson Secret is projected at
+ // /auth/.docker/config.json and DOCKER_CONFIG points at it, which
+ // ORAS picks up natively. Multi-registry deployments combine creds
+ // into a single dockerconfigjson; this keeps the init container's
+ // auth surface to one mount.
+ var skillInitContainers []corev1.Container
+ needSkillAuth := false
for _, skill := range skills {
- if skill.Source.ConfigMap == "" {
- continue // OCI not yet implemented.
- }
volName := "skill-" + skill.Name
+ switch {
+ case skill.Source.ConfigMap != "":
+ volumes = append(volumes, corev1.Volume{
+ Name: volName,
+ VolumeSource: corev1.VolumeSource{
+ ConfigMap: &corev1.ConfigMapVolumeSource{
+ LocalObjectReference: corev1.LocalObjectReference{Name: skill.Source.ConfigMap},
+ },
+ },
+ })
+ volumeMounts = append(volumeMounts, corev1.VolumeMount{
+ Name: volName,
+ MountPath: fmt.Sprintf("/var/claude-skills/%s", skill.Name),
+ ReadOnly: true,
+ })
+ case skill.Source.OCI != "":
+ volumes = append(volumes, corev1.Volume{
+ Name: volName,
+ VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}},
+ })
+ volumeMounts = append(volumeMounts, corev1.VolumeMount{
+ Name: volName,
+ MountPath: fmt.Sprintf("/var/claude-skills/%s", skill.Name),
+ ReadOnly: true,
+ })
+ initMounts := []corev1.VolumeMount{
+ {Name: volName, MountPath: "/skill-out"},
+ }
+ var initEnv []corev1.EnvVar
+ if len(team.Spec.ImagePullSecrets) > 0 {
+ needSkillAuth = true
+ initMounts = append(initMounts, corev1.VolumeMount{
+ Name: "skill-auth", MountPath: "/auth/.docker", ReadOnly: true,
+ })
+ initEnv = append(initEnv, corev1.EnvVar{Name: "DOCKER_CONFIG", Value: "/auth/.docker"})
+ }
+ skillInitContainers = append(skillInitContainers, corev1.Container{
+ Name: "pull-skill-" + skill.Name,
+ Image: r.skillPullerImage(),
+ Command: []string{"oras", "pull", "--output", "/skill-out", skill.Source.OCI},
+ Env: initEnv,
+ VolumeMounts: initMounts,
+ })
+ }
+ }
+ if needSkillAuth {
+ // Project the first imagePullSecret's .dockerconfigjson into a
+ // fixed path the init containers read. Items[] is explicit so
+ // the mounted filename is always "config.json", regardless of
+ // the Secret's key name preference.
volumes = append(volumes, corev1.Volume{
- Name: volName,
+ Name: "skill-auth",
VolumeSource: corev1.VolumeSource{
- ConfigMap: &corev1.ConfigMapVolumeSource{
- LocalObjectReference: corev1.LocalObjectReference{Name: skill.Source.ConfigMap},
+ Secret: &corev1.SecretVolumeSource{
+ SecretName: team.Spec.ImagePullSecrets[0].Name,
+ Items: []corev1.KeyToPath{{Key: ".dockerconfigjson", Path: "config.json"}},
},
},
})
- volumeMounts = append(volumeMounts, corev1.VolumeMount{
- Name: volName,
- MountPath: fmt.Sprintf("/var/claude-skills/%s", skill.Name),
- ReadOnly: true,
- })
}
// MCP config: mount the per-agent ConfigMap at /var/claude-mcp/mcp.json.
@@ -1351,6 +1428,9 @@ func (r *AgentTeamReconciler) buildAgentPod(
// staged. The implicit-dependency wiring in effectiveDependencies ensures
// the producer has already reached Succeeded before this pod is created.
var initContainers []corev1.Container
+ // Skill pulls run first so they're available before any input
+ // staging that might reference skill-produced configs.
+ initContainers = append(initContainers, skillInitContainers...)
if team.Spec.Workspace != nil && team.Spec.Workspace.Output != nil && len(inputs) > 0 {
outMountPath := team.Spec.Workspace.Output.MountPath
if outMountPath == "" {
@@ -1394,6 +1474,7 @@ func (r *AgentTeamReconciler) buildAgentPod(
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyNever,
ServiceAccountName: agentServiceAccountName(team, agentName),
+ ImagePullSecrets: team.Spec.ImagePullSecrets,
InitContainers: initContainers,
Containers: []corev1.Container{
{
diff --git a/internal/controller/agentteam_ociskill_test.go b/internal/controller/agentteam_ociskill_test.go
new file mode 100644
index 0000000..21031d4
--- /dev/null
+++ b/internal/controller/agentteam_ociskill_test.go
@@ -0,0 +1,168 @@
+package controller
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ corev1 "k8s.io/api/core/v1"
+
+ claudev1alpha1 "github.com/amcheste/kagents/api/v1alpha1"
+)
+
+// findInitContainer returns the first init container whose name starts
+// with the given prefix, or nil. The init-container set on a Pod is
+// order-dependent (k8s runs them sequentially) but tests should index
+// by name rather than position so future ordering changes don't break
+// every assertion.
+func findInitContainer(pod *corev1.Pod, prefix string) *corev1.Container {
+ for i := range pod.Spec.InitContainers {
+ if strings.HasPrefix(pod.Spec.InitContainers[i].Name, prefix) {
+ return &pod.Spec.InitContainers[i]
+ }
+ }
+ return nil
+}
+
+// findVolume returns the first Volume by name, or nil.
+func findVolume(pod *corev1.Pod, name string) *corev1.Volume {
+ for i := range pod.Spec.Volumes {
+ if pod.Spec.Volumes[i].Name == name {
+ return &pod.Spec.Volumes[i]
+ }
+ }
+ return nil
+}
+
+func TestBuildAgentPod_OCISkill_ProducesInitContainerAndEmptyDir(t *testing.T) {
+ t.Parallel()
+ team := minimalTeam("oci-skills")
+ r := newReconciler(team)
+ skills := []claudev1alpha1.SkillSpec{
+ {Name: "web-research", Source: claudev1alpha1.SkillSource{OCI: "ghcr.io/acme/skills/web-research:v1"}},
+ }
+
+ pod := r.buildAgentPod(team, "worker", "sonnet", "do work", "auto-accept", false,
+ corev1.ResourceRequirements{}, nil, skills, nil, nil)
+
+ // EmptyDir holds the pulled artifact between the init container and
+ // the main container — read-only on the main side, RW on init.
+ v := findVolume(pod, "skill-web-research")
+ require.NotNil(t, v, "skill-web-research volume must exist")
+ assert.NotNil(t, v.EmptyDir, "OCI skill volume must be emptyDir")
+ assert.Nil(t, v.ConfigMap, "OCI skill volume must not be a ConfigMap projection")
+
+ // Init container runs `oras pull` into /skill-out, which is the
+ // pod-side view of the emptyDir.
+ ic := findInitContainer(pod, "pull-skill-web-research")
+ require.NotNil(t, ic, "pull-skill init container must exist")
+ assert.Equal(t, defaultSkillPullerImage, ic.Image)
+ assert.Contains(t, ic.Command, "oras")
+ assert.Contains(t, ic.Command, "ghcr.io/acme/skills/web-research:v1")
+
+ // No registry creds configured → no DOCKER_CONFIG and no auth volume.
+ for _, e := range ic.Env {
+ assert.NotEqual(t, "DOCKER_CONFIG", e.Name, "no auth secret = no DOCKER_CONFIG")
+ }
+ assert.Nil(t, findVolume(pod, "skill-auth"), "no auth secret = no skill-auth volume")
+}
+
+func TestBuildAgentPod_OCISkill_PrivateRegistryWiresAuth(t *testing.T) {
+ t.Parallel()
+ team := minimalTeam("oci-private")
+ team.Spec.ImagePullSecrets = []corev1.LocalObjectReference{{Name: "ghcr-creds"}}
+ r := newReconciler(team)
+ skills := []claudev1alpha1.SkillSpec{
+ {Name: "internal", Source: claudev1alpha1.SkillSource{OCI: "ghcr.io/acme-internal/skills/secret:v3"}},
+ }
+
+ pod := r.buildAgentPod(team, "worker", "sonnet", "do work", "auto-accept", false,
+ corev1.ResourceRequirements{}, nil, skills, nil, nil)
+
+ // Pod-level imagePullSecrets propagated so the kubelet can pull
+ // both the puller image and any private agent images.
+ require.Len(t, pod.Spec.ImagePullSecrets, 1)
+ assert.Equal(t, "ghcr-creds", pod.Spec.ImagePullSecrets[0].Name)
+
+ // Auth volume projects .dockerconfigjson → config.json.
+ authVol := findVolume(pod, "skill-auth")
+ require.NotNil(t, authVol)
+ require.NotNil(t, authVol.Secret)
+ assert.Equal(t, "ghcr-creds", authVol.Secret.SecretName)
+ require.Len(t, authVol.Secret.Items, 1)
+ assert.Equal(t, ".dockerconfigjson", authVol.Secret.Items[0].Key)
+ assert.Equal(t, "config.json", authVol.Secret.Items[0].Path)
+
+ // Init container picks up DOCKER_CONFIG so ORAS finds creds.
+ ic := findInitContainer(pod, "pull-skill-internal")
+ require.NotNil(t, ic)
+ var dockerConfig string
+ for _, e := range ic.Env {
+ if e.Name == "DOCKER_CONFIG" {
+ dockerConfig = e.Value
+ }
+ }
+ assert.Equal(t, "/auth/.docker", dockerConfig)
+ var mountedAuth bool
+ for _, m := range ic.VolumeMounts {
+ if m.Name == "skill-auth" {
+ mountedAuth = true
+ assert.Equal(t, "/auth/.docker", m.MountPath)
+ assert.True(t, m.ReadOnly)
+ }
+ }
+ assert.True(t, mountedAuth, "skill-auth must be mounted into the puller init container")
+}
+
+func TestBuildAgentPod_ConfigMapSkill_StillProjectsDirectly(t *testing.T) {
+ t.Parallel()
+ team := minimalTeam("cm-skills")
+ r := newReconciler(team)
+ skills := []claudev1alpha1.SkillSpec{
+ {Name: "report-writing", Source: claudev1alpha1.SkillSource{ConfigMap: "report-skill"}},
+ }
+
+ pod := r.buildAgentPod(team, "worker", "sonnet", "do work", "auto-accept", false,
+ corev1.ResourceRequirements{}, nil, skills, nil, nil)
+
+ v := findVolume(pod, "skill-report-writing")
+ require.NotNil(t, v)
+ require.NotNil(t, v.ConfigMap, "ConfigMap skill must use a ConfigMap volume")
+ assert.Equal(t, "report-skill", v.ConfigMap.Name)
+
+ assert.Nil(t, findInitContainer(pod, "pull-skill-"), "ConfigMap skills should not produce a puller init container")
+}
+
+func TestBuildAgentPod_MixedSkills(t *testing.T) {
+ t.Parallel()
+ team := minimalTeam("mixed-skills")
+ r := newReconciler(team)
+ skills := []claudev1alpha1.SkillSpec{
+ {Name: "config-skill", Source: claudev1alpha1.SkillSource{ConfigMap: "cm"}},
+ {Name: "oci-skill", Source: claudev1alpha1.SkillSource{OCI: "ghcr.io/x/y:1"}},
+ }
+
+ pod := r.buildAgentPod(team, "worker", "sonnet", "do work", "auto-accept", false,
+ corev1.ResourceRequirements{}, nil, skills, nil, nil)
+
+ require.NotNil(t, findVolume(pod, "skill-config-skill"))
+ require.NotNil(t, findVolume(pod, "skill-oci-skill"))
+ require.NotNil(t, findInitContainer(pod, "pull-skill-oci-skill"))
+ assert.Nil(t, findInitContainer(pod, "pull-skill-config-skill"))
+}
+
+func TestBuildAgentPod_OCISkill_PullerImageOverride(t *testing.T) {
+ t.Parallel()
+ team := minimalTeam("override-image")
+ r := newReconciler(team)
+ r.SkillPullerImage = "my-mirror.example.com/oras:v1.2.0"
+ skills := []claudev1alpha1.SkillSpec{
+ {Name: "x", Source: claudev1alpha1.SkillSource{OCI: "ghcr.io/x/x:1"}},
+ }
+ pod := r.buildAgentPod(team, "worker", "sonnet", "do work", "auto-accept", false,
+ corev1.ResourceRequirements{}, nil, skills, nil, nil)
+ ic := findInitContainer(pod, "pull-skill-x")
+ require.NotNil(t, ic)
+ assert.Equal(t, "my-mirror.example.com/oras:v1.2.0", ic.Image)
+}
diff --git a/mkdocs.yml b/mkdocs.yml
index f87701f..cce420c 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -108,6 +108,8 @@ nav:
- Expose the dashboard: how-to/operate/expose-dashboard.md
- Configure shared storage: how-to/operate/shared-storage.md
- Set budget alerts: how-to/operate/budget-alerts.md
+ - Author skills:
+ - Package and publish skills: how-to/skill-authoring.md
- Reference:
- reference/index.md
- API reference: reference/api/index.md