types: add Kind field to model.Name for 5-part naming

Extends the model name structure from 4-part to 5-part:
  host/namespace/kind/model:tag

The Kind field is optional and supports:
- "skill" for skill packages
- "agent" for agent packages (future)
- empty for regular models

Parser detects valid kinds to distinguish between old format
(host/namespace/model) and new format (host/namespace/kind/model).
This commit is contained in:
ParthSareen 2025-12-29 00:12:24 -05:00
parent 18fdcc94e5
commit d4f9bd5fe5
1 changed files with 100 additions and 29 deletions

View File

@ -59,6 +59,7 @@ type partKind int
const ( const (
kindHost partKind = iota kindHost partKind = iota
kindNamespace kindNamespace
kindKind
kindModel kindModel
kindTag kindTag
kindDigest kindDigest
@ -70,6 +71,8 @@ func (k partKind) String() string {
return "host" return "host"
case kindNamespace: case kindNamespace:
return "namespace" return "namespace"
case kindKind:
return "kind"
case kindModel: case kindModel:
return "model" return "model"
case kindTag: case kindTag:
@ -89,6 +92,7 @@ func (k partKind) String() string {
type Name struct { type Name struct {
Host string Host string
Namespace string Namespace string
Kind string // Optional: "skill", "agent", or empty for models
Model string Model string
Tag string Tag string
} }
@ -97,34 +101,27 @@ type Name struct {
// format of a valid name string is: // format of a valid name string is:
// //
// s: // s:
// { host } "/" { namespace } "/" { model } ":" { tag } "@" { digest } // { host } "/" { namespace } "/" { kind } "/" { model } ":" { tag }
// { host } "/" { namespace } "/" { model } ":" { tag } // { host } "/" { namespace } "/" { model } ":" { tag }
// { host } "/" { namespace } "/" { model } "@" { digest } // { namespace } "/" { kind } "/" { model } ":" { tag }
// { host } "/" { namespace } "/" { model }
// { namespace } "/" { model } ":" { tag } "@" { digest }
// { namespace } "/" { model } ":" { tag } // { namespace } "/" { model } ":" { tag }
// { namespace } "/" { model } "@" { digest }
// { namespace } "/" { model }
// { model } ":" { tag } "@" { digest }
// { model } ":" { tag } // { model } ":" { tag }
// { model } "@" { digest }
// { model } // { model }
// "@" { digest }
// host: // host:
// pattern: { alphanum | "_" } { alphanum | "-" | "_" | "." | ":" }* // pattern: { alphanum | "_" } { alphanum | "-" | "_" | "." | ":" }*
// length: [1, 350] // length: [1, 350]
// namespace: // namespace:
// pattern: { alphanum | "_" } { alphanum | "-" | "_" }* // pattern: { alphanum | "_" } { alphanum | "-" | "_" }*
// length: [1, 80] // length: [1, 80]
// kind:
// pattern: "skill" | "agent" | "" (empty for models)
// length: [0, 80]
// model: // model:
// pattern: { alphanum | "_" } { alphanum | "-" | "_" | "." }* // pattern: { alphanum | "_" } { alphanum | "-" | "_" | "." }*
// length: [1, 80] // length: [1, 80]
// tag: // tag:
// pattern: { alphanum | "_" } { alphanum | "-" | "_" | "." }* // pattern: { alphanum | "_" } { alphanum | "-" | "_" | "." }*
// length: [1, 80] // length: [1, 80]
// digest:
// pattern: { alphanum | "_" } { alphanum | "-" | ":" }*
// length: [1, 80]
// //
// Most users should use [ParseName] instead, unless need to support // Most users should use [ParseName] instead, unless need to support
// different defaults than DefaultName. // different defaults than DefaultName.
@ -136,6 +133,12 @@ func ParseName(s string) Name {
return Merge(ParseNameBare(s), DefaultName()) return Merge(ParseNameBare(s), DefaultName())
} }
// ValidKinds are the allowed values for the Kind field
var ValidKinds = map[string]bool{
"skill": true,
"agent": true,
}
// ParseNameBare parses s as a name string and returns a Name. No merge with // ParseNameBare parses s as a name string and returns a Name. No merge with
// [DefaultName] is performed. // [DefaultName] is performed.
func ParseNameBare(s string) Name { func ParseNameBare(s string) Name {
@ -153,6 +156,30 @@ func ParseNameBare(s string) Name {
return n return n
} }
s, n.Kind, promised = cutPromised(s, "/")
if !promised {
// Only 2 parts: namespace/model - what we parsed as Kind is actually Namespace
n.Namespace = n.Kind
n.Kind = ""
return n
}
// Check if what we parsed as Kind is actually a valid kind value
if !ValidKinds[n.Kind] {
// Not a valid kind - this is the old 3-part format: host/namespace/model
// Shift: Kind -> Namespace, s -> Host
n.Namespace = n.Kind
n.Kind = ""
scheme, host, ok := strings.Cut(s, "://")
if !ok {
host = scheme
}
n.Host = host
return n
}
// Valid kind found - continue parsing for namespace and optional host
s, n.Namespace, promised = cutPromised(s, "/") s, n.Namespace, promised = cutPromised(s, "/")
if !promised { if !promised {
n.Namespace = s n.Namespace = s
@ -168,20 +195,32 @@ func ParseNameBare(s string) Name {
return n return n
} }
// ParseNameFromFilepath parses a 4-part filepath as a Name. The parts are // ParseNameFromFilepath parses a 4 or 5-part filepath as a Name. The parts are
// expected to be in the form: // expected to be in the form:
// //
// { host } "/" { namespace } "/" { model } "/" { tag } // { host } "/" { namespace } "/" { model } "/" { tag }
// { host } "/" { namespace } "/" { kind } "/" { model } "/" { tag }
func ParseNameFromFilepath(s string) (n Name) { func ParseNameFromFilepath(s string) (n Name) {
parts := strings.Split(s, string(filepath.Separator)) parts := strings.Split(s, string(filepath.Separator))
if len(parts) != 4 {
return Name{}
}
switch len(parts) {
case 4:
// Old format: host/namespace/model/tag
n.Host = parts[0] n.Host = parts[0]
n.Namespace = parts[1] n.Namespace = parts[1]
n.Model = parts[2] n.Model = parts[2]
n.Tag = parts[3] n.Tag = parts[3]
case 5:
// New format: host/namespace/kind/model/tag
n.Host = parts[0]
n.Namespace = parts[1]
n.Kind = parts[2]
n.Model = parts[3]
n.Tag = parts[4]
default:
return Name{}
}
if !n.IsFullyQualified() { if !n.IsFullyQualified() {
return Name{} return Name{}
} }
@ -189,11 +228,12 @@ func ParseNameFromFilepath(s string) (n Name) {
return n return n
} }
// Merge merges the host, namespace, and tag parts of the two names, // Merge merges the host, namespace, kind, and tag parts of the two names,
// preferring the non-empty parts of a. // preferring the non-empty parts of a.
func Merge(a, b Name) Name { func Merge(a, b Name) Name {
a.Host = cmp.Or(a.Host, b.Host) a.Host = cmp.Or(a.Host, b.Host)
a.Namespace = cmp.Or(a.Namespace, b.Namespace) a.Namespace = cmp.Or(a.Namespace, b.Namespace)
a.Kind = cmp.Or(a.Kind, b.Kind)
a.Tag = cmp.Or(a.Tag, b.Tag) a.Tag = cmp.Or(a.Tag, b.Tag)
return a return a
} }
@ -211,6 +251,10 @@ func (n Name) String() string {
b.WriteString(n.Namespace) b.WriteString(n.Namespace)
b.WriteByte('/') b.WriteByte('/')
} }
if n.Kind != "" {
b.WriteString(n.Kind)
b.WriteByte('/')
}
b.WriteString(n.Model) b.WriteString(n.Model)
if n.Tag != "" { if n.Tag != "" {
b.WriteByte(':') b.WriteByte(':')
@ -233,6 +277,12 @@ func (n Name) DisplayShortest() string {
sb.WriteByte('/') sb.WriteByte('/')
} }
// include kind if present
if n.Kind != "" {
sb.WriteString(n.Kind)
sb.WriteByte('/')
}
// always include model and tag // always include model and tag
sb.WriteString(n.Model) sb.WriteString(n.Model)
sb.WriteString(":") sb.WriteString(":")
@ -256,18 +306,23 @@ func (n Name) IsValid() bool {
} }
// IsFullyQualified returns true if all parts of the name are present and // IsFullyQualified returns true if all parts of the name are present and
// valid without the digest. // valid without the digest. Kind is optional and only validated if non-empty.
func (n Name) IsFullyQualified() bool { func (n Name) IsFullyQualified() bool {
parts := []string{ if !isValidPart(kindHost, n.Host) {
n.Host,
n.Namespace,
n.Model,
n.Tag,
}
for i, part := range parts {
if !isValidPart(partKind(i), part) {
return false return false
} }
if !isValidPart(kindNamespace, n.Namespace) {
return false
}
// Kind is optional - only validate if present
if n.Kind != "" && !isValidPart(kindKind, n.Kind) {
return false
}
if !isValidPart(kindModel, n.Model) {
return false
}
if !isValidPart(kindTag, n.Tag) {
return false
} }
return true return true
} }
@ -276,6 +331,7 @@ func (n Name) IsFullyQualified() bool {
// host to tag as a directory in the form: // host to tag as a directory in the form:
// //
// {host}/{namespace}/{model}/{tag} // {host}/{namespace}/{model}/{tag}
// {host}/{namespace}/{kind}/{model}/{tag}
// //
// It uses the system's filepath separator and ensures the path is clean. // It uses the system's filepath separator and ensures the path is clean.
// //
@ -285,6 +341,15 @@ func (n Name) Filepath() string {
if !n.IsFullyQualified() { if !n.IsFullyQualified() {
panic("illegal attempt to get filepath of invalid name") panic("illegal attempt to get filepath of invalid name")
} }
if n.Kind != "" {
return filepath.Join(
n.Host,
n.Namespace,
n.Kind,
n.Model,
n.Tag,
)
}
return filepath.Join( return filepath.Join(
n.Host, n.Host,
n.Namespace, n.Namespace,
@ -301,6 +366,7 @@ func (n Name) LogValue() slog.Value {
func (n Name) EqualFold(o Name) bool { func (n Name) EqualFold(o Name) bool {
return strings.EqualFold(n.Host, o.Host) && return strings.EqualFold(n.Host, o.Host) &&
strings.EqualFold(n.Namespace, o.Namespace) && strings.EqualFold(n.Namespace, o.Namespace) &&
strings.EqualFold(n.Kind, o.Kind) &&
strings.EqualFold(n.Model, o.Model) && strings.EqualFold(n.Model, o.Model) &&
strings.EqualFold(n.Tag, o.Tag) strings.EqualFold(n.Tag, o.Tag)
} }
@ -317,6 +383,11 @@ func isValidLen(kind partKind, s string) bool {
} }
func isValidPart(kind partKind, s string) bool { func isValidPart(kind partKind, s string) bool {
// Kind must be one of the valid values
if kind == kindKind {
return ValidKinds[s]
}
if !isValidLen(kind, s) { if !isValidLen(kind, s) {
return false return false
} }