Content Editing UI — Phase 1 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: Add schema-driven TUI editor for all 10 content YAML types across all 4 content layers, with value help (enum selects, format validation, pattern matching) and batch validation.
Architecture: New internal/schema/ package parses JSON Schema Draft-07 files into a generic FieldSpec model that drives both validation and form generation. New internal/editor/ package builds a Bubbletea TUI with two states — list view (array content) and form view (charmbracelet/huh v2). Layer resolution auto-detects whether to edit in-place (contributor checkout) or create user/project overrides. Three new CLI commands under sap-devs content: edit, validate, list.
Tech Stack: Go, cobra, charmbracelet/bubbletea, charmbracelet/huh v2, charmbracelet/lipgloss, JSON Schema Draft-07, gopkg.in/yaml.v3
Spec: docs/superpowers/specs/2026-04-19-content-editing-ui-design.md
Task 1: Add charmbracelet/huh v2 dependency
Files:
Modify:
go.modModify:
go.sum[ ] Step 1: Add the huh v2 dependency
go get charm.land/huh/v2@latestNote: huh v2 uses the charm.land/huh/v2 import path (vanity URL), not github.com/charmbracelet/huh. huh v2 may pull in charm.land/lipgloss/v2 — if so, update list.go to use the v2 lipgloss import instead of the existing github.com/charmbracelet/lipgloss v1. Verify both compile together after go get.
- [ ] Step 2: Verify compilation
Run: go build ./... Expected: Compiles successfully. New entries in go.mod and go.sum.
- [ ] Step 3: Commit
git add go.mod go.sum
git commit -m "chore: add charmbracelet/huh v2 dependency"Task 2: Schema types (internal/schema/schema.go)
Files:
Create:
internal/schema/schema.go[ ] Step 1: Create the schema types file
package schema
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// Schema represents a parsed JSON Schema for a content type.
type Schema struct {
Type string // "object" or "array"
ItemSpec *ObjectSpec // for arrays: schema of each item
ObjectSpec *ObjectSpec // for single objects (e.g., pack.yaml)
}
// ObjectSpec holds the field definitions for an object.
type ObjectSpec struct {
Fields []FieldSpec
Required []string
}
// FieldSpec describes a single field extracted from a JSON Schema property.
type FieldSpec struct {
Key string
Title string
Description string
Type string // "string", "integer", "boolean", "array", "object", "map"
Required bool
Enum []string
Format string
Pattern string
Default any
ItemType string
ItemEnum []string
MinItems int
MaxItems int
Children []FieldSpec
MapValueType string // for additionalProperties maps: type of values
Condition *Condition
}
// Condition makes a field visible only when another field has a specific value.
type Condition struct {
Field string
Value any
}
// knownFiles maps YAML filenames to their schema base names.
var knownFiles = map[string]string{
"pack.yaml": "pack",
"resources.yaml": "resources",
"influencers.yaml": "influencers",
"event-types.yaml": "event-types",
"event-instances.yaml": "event-instances",
"mcp.yaml": "mcp",
"tools.yaml": "tools",
"hook.yaml": "hook",
"samples.yaml": "samples",
"known_errors.yaml": "known_errors",
}
// KnownFiles returns the mapping of YAML filenames to schema names.
func KnownFiles() map[string]string {
out := make(map[string]string, len(knownFiles))
for k, v := range knownFiles {
out[k] = v
}
return out
}
// SchemaForFile returns the schema base name for a YAML filename, or "" if unknown.
func SchemaForFile(filename string) string {
return knownFiles[filename]
}
// Load reads a JSON Schema file and returns a parsed Schema.
// schemasDir is the directory containing .schema.json files.
// name is the base name (e.g., "resources" for resources.schema.json).
func Load(schemasDir, name string) (*Schema, error) {
path := filepath.Join(schemasDir, name+".schema.json")
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read schema %s: %w", name, err)
}
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parse schema %s: %w", name, err)
}
s := &Schema{}
topType, _ := raw["type"].(string)
s.Type = topType
switch topType {
case "array":
items, _ := raw["items"].(map[string]any)
if items == nil {
return nil, fmt.Errorf("schema %s: array type missing items", name)
}
required := extractStringSlice(raw, "items", "required")
if required == nil {
required = extractStringSlice(items, "required")
}
spec, err := parseObjectSpec(items, required)
if err != nil {
return nil, fmt.Errorf("schema %s items: %w", name, err)
}
s.ItemSpec = spec
case "object":
required := extractStringSlice(raw, "required")
spec, err := parseObjectSpec(raw, required)
if err != nil {
return nil, fmt.Errorf("schema %s: %w", name, err)
}
s.ObjectSpec = spec
parseConditionals(raw, spec)
default:
return nil, fmt.Errorf("schema %s: unsupported top-level type %q", name, topType)
}
return s, nil
}
func parseObjectSpec(obj map[string]any, required []string) (*ObjectSpec, error) {
props, _ := obj["properties"].(map[string]any)
if props == nil {
return &ObjectSpec{Required: required}, nil
}
reqSet := make(map[string]bool, len(required))
for _, r := range required {
reqSet[r] = true
}
spec := &ObjectSpec{Required: required}
for key, val := range props {
propMap, ok := val.(map[string]any)
if !ok {
continue
}
field := parseField(key, propMap, reqSet[key])
spec.Fields = append(spec.Fields, field)
}
sortFieldsBySchemaOrder(spec.Fields, props)
return spec, nil
}
func parseField(key string, prop map[string]any, required bool) FieldSpec {
f := FieldSpec{
Key: key,
Title: keyToTitle(key),
Description: strVal(prop, "description"),
Type: strVal(prop, "type"),
Required: required,
Format: strVal(prop, "format"),
Pattern: strVal(prop, "pattern"),
Default: prop["default"],
}
if enums, ok := prop["enum"].([]any); ok {
for _, e := range enums {
if s, ok := e.(string); ok {
f.Enum = append(f.Enum, s)
}
}
}
switch f.Type {
case "array":
items, _ := prop["items"].(map[string]any)
if items != nil {
f.ItemType = strVal(items, "type")
if itemEnums, ok := items["enum"].([]any); ok {
for _, e := range itemEnums {
if s, ok := e.(string); ok {
f.ItemEnum = append(f.ItemEnum, s)
}
}
}
}
if v, ok := prop["minItems"].(float64); ok {
f.MinItems = int(v)
}
if v, ok := prop["maxItems"].(float64); ok {
f.MaxItems = int(v)
}
case "object":
if addProps, ok := prop["additionalProperties"].(map[string]any); ok {
f.Type = "map"
f.MapValueType = strVal(addProps, "type")
if fmtVal := strVal(addProps, "format"); fmtVal != "" {
f.Format = fmtVal
}
} else if innerProps, ok := prop["properties"].(map[string]any); ok {
innerReq := extractStringSlice(prop, "required")
reqSet := make(map[string]bool, len(innerReq))
for _, r := range innerReq {
reqSet[r] = true
}
for childKey, childVal := range innerProps {
childMap, ok := childVal.(map[string]any)
if !ok {
continue
}
f.Children = append(f.Children, parseField(childKey, childMap, reqSet[childKey]))
}
sortFieldsBySchemaOrder(f.Children, innerProps)
}
}
return f
}
func parseConditionals(raw map[string]any, spec *ObjectSpec) {
ifBlock, _ := raw["if"].(map[string]any)
thenBlock, _ := raw["then"].(map[string]any)
if ifBlock == nil || thenBlock == nil {
return
}
ifProps, _ := ifBlock["properties"].(map[string]any)
if ifProps == nil {
return
}
var condField string
var condValue any
for k, v := range ifProps {
vMap, ok := v.(map[string]any)
if !ok {
continue
}
condField = k
condValue = vMap["const"]
break
}
thenProps, _ := thenBlock["properties"].(map[string]any)
if thenProps == nil {
return
}
for _, field := range spec.Fields {
if _, ok := thenProps[field.Key]; ok {
for i := range spec.Fields {
if spec.Fields[i].Key == field.Key {
spec.Fields[i].Condition = &Condition{
Field: condField,
Value: condValue,
}
}
}
}
}
}
func keyToTitle(key string) string {
s := strings.ReplaceAll(key, "_", " ")
if len(s) > 0 {
s = strings.ToUpper(s[:1]) + s[1:]
}
return s
}
func strVal(m map[string]any, key string) string {
v, _ := m[key].(string)
return v
}
func extractStringSlice(m map[string]any, keys ...string) []string {
current := m
for i, k := range keys {
if i == len(keys)-1 {
arr, ok := current[k].([]any)
if !ok {
return nil
}
out := make([]string, 0, len(arr))
for _, v := range arr {
if s, ok := v.(string); ok {
out = append(out, s)
}
}
return out
}
next, ok := current[k].(map[string]any)
if !ok {
return nil
}
current = next
}
return nil
}
func sortFieldsBySchemaOrder(fields []FieldSpec, props map[string]any) {
// JSON object keys don't have order, so we sort alphabetically by key
// with required fields first for better UX
reqFields := make([]FieldSpec, 0)
optFields := make([]FieldSpec, 0)
for _, f := range fields {
if f.Required {
reqFields = append(reqFields, f)
} else {
optFields = append(optFields, f)
}
}
copy(fields, append(reqFields, optFields...))
}- [ ] Step 2: Verify compilation
Run: go build ./... Expected: Compiles successfully.
- [ ] Step 3: Commit
git add internal/schema/schema.go
git commit -m "feat(schema): add JSON Schema parser with FieldSpec model"Task 3: Schema parser tests (internal/schema/schema_test.go)
Files:
Create:
internal/schema/schema_test.go[ ] Step 1: Write tests for schema loading
package schema_test
import (
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SAP-samples/sap-devs-cli/internal/schema"
)
func schemasDir() string {
_, file, _, _ := runtime.Caller(0)
return filepath.Join(filepath.Dir(file), "..", "..", "content", "schemas")
}
func TestLoad_Resources(t *testing.T) {
s, err := schema.Load(schemasDir(), "resources")
require.NoError(t, err)
assert.Equal(t, "array", s.Type)
require.NotNil(t, s.ItemSpec)
assert.Contains(t, s.ItemSpec.Required, "id")
assert.Contains(t, s.ItemSpec.Required, "type")
var typeField *schema.FieldSpec
for i, f := range s.ItemSpec.Fields {
if f.Key == "type" {
typeField = &s.ItemSpec.Fields[i]
break
}
}
require.NotNil(t, typeField)
assert.Equal(t, []string{"official-docs", "sample", "community", "tutorial", "blog"}, typeField.Enum)
assert.True(t, typeField.Required)
}
func TestLoad_Pack(t *testing.T) {
s, err := schema.Load(schemasDir(), "pack")
require.NoError(t, err)
assert.Equal(t, "object", s.Type)
require.NotNil(t, s.ObjectSpec)
assert.Contains(t, s.ObjectSpec.Required, "id")
assert.Contains(t, s.ObjectSpec.Required, "name")
var addPosField *schema.FieldSpec
for i, f := range s.ObjectSpec.Fields {
if f.Key == "additive_position" {
addPosField = &s.ObjectSpec.Fields[i]
break
}
}
require.NotNil(t, addPosField, "additive_position field should exist")
require.NotNil(t, addPosField.Condition, "additive_position should have a condition")
assert.Equal(t, "additive", addPosField.Condition.Field)
assert.Equal(t, true, addPosField.Condition.Value)
}
func TestLoad_Tools_NestedObject(t *testing.T) {
s, err := schema.Load(schemasDir(), "tools")
require.NoError(t, err)
var detectField *schema.FieldSpec
for i, f := range s.ItemSpec.Fields {
if f.Key == "detect" {
detectField = &s.ItemSpec.Fields[i]
break
}
}
require.NotNil(t, detectField)
assert.Equal(t, "object", detectField.Type)
assert.Len(t, detectField.Children, 2)
}
func TestLoad_Influencers_MapType(t *testing.T) {
s, err := schema.Load(schemasDir(), "influencers")
require.NoError(t, err)
var linksField *schema.FieldSpec
for i, f := range s.ItemSpec.Fields {
if f.Key == "links" {
linksField = &s.ItemSpec.Fields[i]
break
}
}
require.NotNil(t, linksField)
assert.Equal(t, "map", linksField.Type)
assert.Equal(t, "string", linksField.MapValueType)
assert.Equal(t, "uri", linksField.Format)
}
func TestLoad_AllSchemas(t *testing.T) {
for filename, name := range schema.KnownFiles() {
t.Run(filename, func(t *testing.T) {
s, err := schema.Load(schemasDir(), name)
require.NoError(t, err)
assert.NotEmpty(t, s.Type)
})
}
}
func TestLoad_NonExistent(t *testing.T) {
_, err := schema.Load(schemasDir(), "nonexistent")
assert.Error(t, err)
}
func TestSchemaForFile(t *testing.T) {
assert.Equal(t, "resources", schema.SchemaForFile("resources.yaml"))
assert.Equal(t, "pack", schema.SchemaForFile("pack.yaml"))
assert.Equal(t, "", schema.SchemaForFile("unknown.yaml"))
}- [ ] Step 2: Run tests to verify they pass
Run: go build ./internal/schema/... (use build instead of test on Windows) Expected: Compiles successfully.
- [ ] Step 3: Commit
git add internal/schema/schema_test.go
git commit -m "test(schema): add parser tests for all 10 content schemas"Task 4: Schema validation (internal/schema/validate.go)
Files:
Create:
internal/schema/validate.go[ ] Step 1: Write the validation function
package schema
import (
"fmt"
"net/url"
"regexp"
"strings"
)
// ValidationError represents a single validation failure.
type ValidationError struct {
Path string
Field string
Message string
Severity string // "error" or "warning"
}
func (e ValidationError) String() string {
if e.Path != "" {
return fmt.Sprintf("%s: %s", e.Path, e.Message)
}
return e.Message
}
// Validate checks data against a schema and returns all violations.
// data should be []any for array schemas or map[string]any for object schemas.
func Validate(s *Schema, data any) []ValidationError {
switch s.Type {
case "array":
arr, ok := data.([]any)
if !ok {
return []ValidationError{{Message: "expected array", Severity: "error"}}
}
var errs []ValidationError
for i, item := range arr {
itemMap, ok := item.(map[string]any)
if !ok {
errs = append(errs, ValidationError{
Path: fmt.Sprintf("[%d]", i),
Message: "expected object",
Severity: "error",
})
continue
}
for _, e := range validateObject(s.ItemSpec, itemMap) {
e.Path = fmt.Sprintf("[%d].%s", i, e.Path)
errs = append(errs, e)
}
}
return errs
case "object":
obj, ok := data.(map[string]any)
if !ok {
return []ValidationError{{Message: "expected object", Severity: "error"}}
}
return validateObject(s.ObjectSpec, obj)
}
return nil
}
func validateObject(spec *ObjectSpec, obj map[string]any) []ValidationError {
if spec == nil {
return nil
}
var errs []ValidationError
for _, f := range spec.Fields {
val, exists := obj[f.Key]
if f.Required && !exists {
errs = append(errs, ValidationError{
Path: f.Key,
Field: f.Key,
Message: "required field missing",
Severity: "error",
})
continue
}
if !exists {
continue
}
errs = append(errs, validateField(f, val)...)
}
return errs
}
func validateField(f FieldSpec, val any) []ValidationError {
var errs []ValidationError
switch f.Type {
case "string":
s, ok := val.(string)
if !ok {
return []ValidationError{{Path: f.Key, Field: f.Key, Message: "expected string", Severity: "error"}}
}
if f.Required && s == "" {
errs = append(errs, ValidationError{Path: f.Key, Field: f.Key, Message: "must not be empty", Severity: "error"})
}
if len(f.Enum) > 0 {
found := false
for _, e := range f.Enum {
if s == e {
found = true
break
}
}
if !found {
errs = append(errs, ValidationError{
Path: f.Key,
Field: f.Key,
Message: fmt.Sprintf("must be one of: %s", strings.Join(f.Enum, ", ")),
Severity: "error",
})
}
}
if f.Format == "uri" && s != "" {
if _, err := url.ParseRequestURI(s); err != nil || !strings.HasPrefix(s, "http") {
errs = append(errs, ValidationError{
Path: f.Key,
Field: f.Key,
Message: "not a valid URI",
Severity: "error",
})
}
}
if f.Pattern != "" && s != "" {
re, err := regexp.Compile(f.Pattern)
if err == nil && !re.MatchString(s) {
errs = append(errs, ValidationError{
Path: f.Key,
Field: f.Key,
Message: fmt.Sprintf("does not match pattern %s", f.Pattern),
Severity: "error",
})
}
}
case "integer":
if _, ok := val.(float64); !ok {
if _, ok := val.(int); !ok {
errs = append(errs, ValidationError{Path: f.Key, Field: f.Key, Message: "expected integer", Severity: "error"})
}
}
case "boolean":
if _, ok := val.(bool); !ok {
errs = append(errs, ValidationError{Path: f.Key, Field: f.Key, Message: "expected boolean", Severity: "error"})
}
case "array":
arr, ok := val.([]any)
if !ok {
errs = append(errs, ValidationError{Path: f.Key, Field: f.Key, Message: "expected array", Severity: "error"})
break
}
if f.MinItems > 0 && len(arr) < f.MinItems {
errs = append(errs, ValidationError{
Path: f.Key,
Field: f.Key,
Message: fmt.Sprintf("at least %d item(s) required", f.MinItems),
Severity: "error",
})
}
if f.MaxItems > 0 && len(arr) > f.MaxItems {
errs = append(errs, ValidationError{
Path: f.Key,
Field: f.Key,
Message: fmt.Sprintf("at most %d item(s) allowed", f.MaxItems),
Severity: "error",
})
}
for i, item := range arr {
if f.ItemType == "string" {
s, ok := item.(string)
if !ok {
errs = append(errs, ValidationError{
Path: fmt.Sprintf("%s[%d]", f.Key, i),
Field: f.Key,
Message: "expected string",
Severity: "error",
})
} else if len(f.ItemEnum) > 0 {
found := false
for _, e := range f.ItemEnum {
if s == e {
found = true
break
}
}
if !found {
errs = append(errs, ValidationError{
Path: fmt.Sprintf("%s[%d]", f.Key, i),
Field: f.Key,
Message: fmt.Sprintf("must be one of: %s", strings.Join(f.ItemEnum, ", ")),
Severity: "error",
})
}
}
}
}
case "object":
childObj, ok := val.(map[string]any)
if !ok {
errs = append(errs, ValidationError{Path: f.Key, Field: f.Key, Message: "expected object", Severity: "error"})
break
}
for _, child := range f.Children {
childVal, exists := childObj[child.Key]
if child.Required && !exists {
errs = append(errs, ValidationError{
Path: fmt.Sprintf("%s.%s", f.Key, child.Key),
Field: child.Key,
Message: "required field missing",
Severity: "error",
})
continue
}
if exists {
for _, e := range validateField(child, childVal) {
e.Path = fmt.Sprintf("%s.%s", f.Key, e.Path)
errs = append(errs, e)
}
}
}
case "map":
mapObj, ok := val.(map[string]any)
if !ok {
errs = append(errs, ValidationError{Path: f.Key, Field: f.Key, Message: "expected object (map)", Severity: "error"})
break
}
for k, v := range mapObj {
if f.MapValueType == "string" {
s, ok := v.(string)
if !ok {
errs = append(errs, ValidationError{
Path: fmt.Sprintf("%s.%s", f.Key, k),
Field: k,
Message: "expected string value",
Severity: "error",
})
} else if f.Format == "uri" {
if _, err := url.ParseRequestURI(s); err != nil || !strings.HasPrefix(s, "http") {
errs = append(errs, ValidationError{
Path: fmt.Sprintf("%s.%s", f.Key, k),
Field: k,
Message: "not a valid URI",
Severity: "error",
})
}
}
}
}
}
return errs
}- [ ] Step 2: Verify compilation
Run: go build ./internal/schema/... Expected: Compiles successfully.
- [ ] Step 3: Commit
git add internal/schema/validate.go
git commit -m "feat(schema): add validation engine for content YAML files"Task 5: Validation tests (internal/schema/validate_test.go)
Files:
Create:
internal/schema/validate_test.go[ ] Step 1: Write validation tests
package schema_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SAP-samples/sap-devs-cli/internal/schema"
)
func TestValidate_ValidResource(t *testing.T) {
s, err := schema.Load(schemasDir(), "resources")
require.NoError(t, err)
data := []any{
map[string]any{
"id": "cap/docs",
"title": "CAP Docs",
"url": "https://cap.cloud.sap/docs",
"type": "official-docs",
"tags": []any{"reference"},
},
}
errs := schema.Validate(s, data)
assert.Empty(t, errs)
}
func TestValidate_MissingRequired(t *testing.T) {
s, err := schema.Load(schemasDir(), "resources")
require.NoError(t, err)
data := []any{
map[string]any{
"id": "cap/docs",
"title": "CAP Docs",
},
}
errs := schema.Validate(s, data)
assert.NotEmpty(t, errs)
var fields []string
for _, e := range errs {
fields = append(fields, e.Field)
}
assert.Contains(t, fields, "url")
assert.Contains(t, fields, "type")
assert.Contains(t, fields, "tags")
}
func TestValidate_InvalidEnum(t *testing.T) {
s, err := schema.Load(schemasDir(), "resources")
require.NoError(t, err)
data := []any{
map[string]any{
"id": "cap/docs",
"title": "CAP Docs",
"url": "https://cap.cloud.sap",
"type": "invalid-type",
"tags": []any{"tag"},
},
}
errs := schema.Validate(s, data)
require.NotEmpty(t, errs)
assert.Contains(t, errs[0].Message, "must be one of")
}
func TestValidate_InvalidURI(t *testing.T) {
s, err := schema.Load(schemasDir(), "resources")
require.NoError(t, err)
data := []any{
map[string]any{
"id": "cap/docs",
"title": "CAP Docs",
"url": "not-a-url",
"type": "official-docs",
"tags": []any{"tag"},
},
}
errs := schema.Validate(s, data)
require.NotEmpty(t, errs)
assert.Contains(t, errs[0].Message, "not a valid URI")
}
func TestValidate_InvalidPattern(t *testing.T) {
s, err := schema.Load(schemasDir(), "influencers")
require.NoError(t, err)
data := []any{
map[string]any{
"id": "INVALID_ID",
"name": "Test",
"role": "Dev",
"org": "SAP",
"focus": []any{"cap"},
"links": map[string]any{"blog": "https://example.com"},
},
}
errs := schema.Validate(s, data)
require.NotEmpty(t, errs)
assert.Contains(t, errs[0].Message, "does not match pattern")
}
func TestValidate_NestedObject(t *testing.T) {
s, err := schema.Load(schemasDir(), "tools")
require.NoError(t, err)
data := []any{
map[string]any{
"id": "node",
"name": "Node.js",
"required": ">=18.0.0",
"detect": map[string]any{"command": "node --version"},
"install": map[string]any{"all": "npm install"},
"docs": "https://nodejs.org",
},
}
errs := schema.Validate(s, data)
var fields []string
for _, e := range errs {
fields = append(fields, e.Path)
}
assert.Contains(t, fields, "[0].detect.pattern")
}
func TestValidate_MinItems(t *testing.T) {
s, err := schema.Load(schemasDir(), "influencers")
require.NoError(t, err)
data := []any{
map[string]any{
"id": "test",
"name": "Test",
"role": "Dev",
"org": "SAP",
"focus": []any{},
"links": map[string]any{"blog": "https://example.com"},
},
}
errs := schema.Validate(s, data)
require.NotEmpty(t, errs)
assert.Contains(t, errs[0].Message, "at least 1")
}
func TestValidate_PackObject(t *testing.T) {
s, err := schema.Load(schemasDir(), "pack")
require.NoError(t, err)
data := map[string]any{
"id": "test",
"name": "Test Pack",
"description": "A test",
"tags": []any{"test"},
}
errs := schema.Validate(s, data)
assert.Empty(t, errs)
}
func TestValidate_MapValues(t *testing.T) {
s, err := schema.Load(schemasDir(), "influencers")
require.NoError(t, err)
data := []any{
map[string]any{
"id": "test",
"name": "Test",
"role": "Dev",
"org": "SAP",
"focus": []any{"cap"},
"links": map[string]any{"blog": "not-a-url"},
},
}
errs := schema.Validate(s, data)
require.NotEmpty(t, errs)
assert.Contains(t, errs[0].Message, "not a valid URI")
}- [ ] Step 2: Run tests to verify they pass
Run: go build ./internal/schema/... Expected: Compiles successfully.
- [ ] Step 3: Commit
git add internal/schema/validate_test.go
git commit -m "test(schema): add validation tests for all field types"Task 6: Layer resolution (internal/editor/resolve.go)
Files:
Create:
internal/editor/resolve.go[ ] Step 1: Write the layer resolution logic
package editor
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/SAP-samples/sap-devs-cli/internal/config"
"github.com/SAP-samples/sap-devs-cli/internal/schema"
"github.com/SAP-samples/sap-devs-cli/internal/xdg"
)
// Layer describes which content layer is being edited.
type Layer int
const (
LayerOfficial Layer = iota
LayerCompany
LayerUser
LayerProject
)
func (l Layer) String() string {
switch l {
case LayerOfficial:
return "official"
case LayerCompany:
return "company"
case LayerUser:
return "user"
case LayerProject:
return "project"
}
return "unknown"
}
// ResolvedFile contains the resolved editing target.
type ResolvedFile struct {
Layer Layer
PackID string
Filename string
SchemaName string
FilePath string // actual file path to edit
PackDir string // directory containing the pack
}
const officialRepoURL = "github.com/SAP-samples/sap-devs-cli"
// ResolveEditTarget determines the file path and layer for a content edit request.
// arg is the user-provided file argument (e.g., "resources.yaml", "cap/resources.yaml", or a path).
func ResolveEditTarget(cwd, arg string) (*ResolvedFile, error) {
// Direct path: starts with ./ or .sap-devs/ or content/
if strings.HasPrefix(arg, "./") || strings.HasPrefix(arg, ".sap-devs/") || strings.HasPrefix(arg, "content/") {
return resolveDirectPath(cwd, arg)
}
// Pack/file format: "cap/resources.yaml"
if parts := strings.SplitN(arg, "/", 2); len(parts) == 2 {
packID := parts[0]
filename := parts[1]
schemaName := schema.SchemaForFile(filename)
if schemaName == "" {
return nil, fmt.Errorf("unknown content file: %s", filename)
}
return resolveForPack(cwd, packID, filename, schemaName)
}
// Bare filename: "resources.yaml"
schemaName := schema.SchemaForFile(arg)
if schemaName == "" {
return nil, fmt.Errorf("unknown content file: %s", arg)
}
return resolveForFile(cwd, arg, schemaName)
}
func resolveDirectPath(cwd, arg string) (*ResolvedFile, error) {
fullPath := filepath.Join(cwd, arg)
filename := filepath.Base(fullPath)
schemaName := schema.SchemaForFile(filename)
if schemaName == "" {
return nil, fmt.Errorf("unknown content file: %s", filename)
}
packDir := filepath.Dir(fullPath)
packID := filepath.Base(packDir)
layer := LayerProject
if strings.Contains(fullPath, "content/packs/") {
layer = LayerOfficial
}
return &ResolvedFile{
Layer: layer,
PackID: packID,
Filename: filename,
SchemaName: schemaName,
FilePath: fullPath,
PackDir: packDir,
}, nil
}
func resolveForPack(cwd, packID, filename, schemaName string) (*ResolvedFile, error) {
layer, baseDir := detectLayer(cwd)
var packDir string
switch layer {
case LayerOfficial, LayerCompany:
packDir = filepath.Join(baseDir, "content", "packs", packID)
case LayerProject:
packDir = filepath.Join(baseDir, ".sap-devs", "packs", packID)
case LayerUser:
packDir = filepath.Join(baseDir, "packs", packID)
}
return &ResolvedFile{
Layer: layer,
PackID: packID,
Filename: filename,
SchemaName: schemaName,
FilePath: filepath.Join(packDir, filename),
PackDir: packDir,
}, nil
}
func resolveForFile(cwd, filename, schemaName string) (*ResolvedFile, error) {
layer, baseDir := detectLayer(cwd)
var packsDir string
switch layer {
case LayerOfficial, LayerCompany:
packsDir = filepath.Join(baseDir, "content", "packs")
case LayerProject:
packsDir = filepath.Join(baseDir, ".sap-devs", "packs")
case LayerUser:
packsDir = filepath.Join(baseDir, "packs")
}
// Scan for packs containing this file
entries, err := os.ReadDir(packsDir)
if err != nil {
return nil, fmt.Errorf("cannot read packs directory %s: %w", packsDir, err)
}
var matches []string
for _, e := range entries {
if !e.IsDir() {
continue
}
candidate := filepath.Join(packsDir, e.Name(), filename)
if _, err := os.Stat(candidate); err == nil {
matches = append(matches, e.Name())
}
}
if len(matches) == 0 {
return nil, fmt.Errorf("no pack contains %s in layer %s", filename, layer)
}
packID := matches[0]
if len(matches) > 1 {
// Return first match; caller should prompt user to disambiguate
// Attach all matches for the caller to present
packID = matches[0]
}
packDir := filepath.Join(packsDir, packID)
return &ResolvedFile{
Layer: layer,
PackID: packID,
Filename: filename,
SchemaName: schemaName,
FilePath: filepath.Join(packDir, filename),
PackDir: packDir,
}, nil
}
// AmbiguousPacks returns all pack IDs that contain a given filename, for disambiguation.
func AmbiguousPacks(cwd, filename string) []string {
layer, baseDir := detectLayer(cwd)
var packsDir string
switch layer {
case LayerOfficial, LayerCompany:
packsDir = filepath.Join(baseDir, "content", "packs")
case LayerProject:
packsDir = filepath.Join(baseDir, ".sap-devs", "packs")
case LayerUser:
packsDir = filepath.Join(baseDir, "packs")
}
entries, err := os.ReadDir(packsDir)
if err != nil {
return nil
}
var matches []string
for _, e := range entries {
if !e.IsDir() {
continue
}
if _, err := os.Stat(filepath.Join(packsDir, e.Name(), filename)); err == nil {
matches = append(matches, e.Name())
}
}
return matches
}
func detectLayer(cwd string) (Layer, string) {
// Check for official repo checkout
if _, err := os.Stat(filepath.Join(cwd, "content", "packs")); err == nil {
if isOfficialRepo(cwd) {
return LayerOfficial, cwd
}
if isCompanyRepo(cwd) {
return LayerCompany, cwd
}
}
// Check for project layer
if _, err := os.Stat(filepath.Join(cwd, ".sap-devs")); err == nil {
return LayerProject, cwd
}
// Fall back to user layer
paths, err := xdg.New()
if err != nil {
return LayerUser, ""
}
return LayerUser, paths.DataDir
}
func isOfficialRepo(dir string) bool {
out, err := exec.Command("git", "-C", dir, "remote", "get-url", "origin").Output()
if err != nil {
return false
}
return strings.Contains(string(out), officialRepoURL)
}
func isCompanyRepo(dir string) bool {
out, err := exec.Command("git", "-C", dir, "remote", "get-url", "origin").Output()
if err != nil {
return false
}
paths, err := xdg.New()
if err != nil {
return false
}
cfg, err := config.Load(paths.ConfigDir)
if err != nil || cfg.CompanyRepo == "" {
return false
}
return strings.Contains(string(out), cfg.CompanyRepo)
}
// AllLayers returns the directories for each content layer that exists.
func AllLayers(cwd string) []struct {
Layer Layer
Dir string
} {
paths, _ := xdg.New()
var layers []struct {
Layer Layer
Dir string
}
// Official: cache or CWD checkout
if _, err := os.Stat(filepath.Join(cwd, "content", "packs")); err == nil && isOfficialRepo(cwd) {
layers = append(layers, struct {
Layer Layer
Dir string
}{LayerOfficial, filepath.Join(cwd, "content", "packs")})
} else if paths != nil {
officialDir := filepath.Join(paths.CacheDir, "official", "content", "packs")
if _, err := os.Stat(officialDir); err == nil {
layers = append(layers, struct {
Layer Layer
Dir string
}{LayerOfficial, officialDir})
}
}
// Company
if paths != nil {
cfg, _ := config.Load(paths.ConfigDir)
if cfg != nil && cfg.CompanyRepo != "" {
companyDir := filepath.Join(paths.CacheDir, "company", "content", "packs")
if _, err := os.Stat(companyDir); err == nil {
layers = append(layers, struct {
Layer Layer
Dir string
}{LayerCompany, companyDir})
}
}
}
// User
if paths != nil {
userDir := filepath.Join(paths.DataDir, "packs")
if _, err := os.Stat(userDir); err == nil {
layers = append(layers, struct {
Layer Layer
Dir string
}{LayerUser, userDir})
}
}
// Project
projectDir := filepath.Join(cwd, ".sap-devs", "packs")
if _, err := os.Stat(projectDir); err == nil {
layers = append(layers, struct {
Layer Layer
Dir string
}{LayerProject, projectDir})
}
return layers
}- [ ] Step 2: Verify compilation
Run: go build ./internal/editor/... Expected: Compiles successfully.
- [ ] Step 3: Commit
git add internal/editor/resolve.go
git commit -m "feat(editor): add layer resolution and file path detection"Task 7: Merged view assembly (internal/editor/merge.go)
Files:
Create:
internal/editor/merge.go[ ] Step 1: Write the merged item model and assembly logic
package editor
import (
"os"
"path/filepath"
"github.com/SAP-samples/sap-devs-cli/internal/schema"
"gopkg.in/yaml.v3"
)
// MergedItem wraps a content item with its source layer information.
type MergedItem struct {
Data map[string]any
Layer Layer
IsOverride bool
}
// LoadMergedItems loads items from all layers for a given pack and file,
// merging by ID. Later layers override earlier ones.
func LoadMergedItems(cwd, packID, filename string) ([]MergedItem, error) {
layers := AllLayers(cwd)
seen := make(map[string]int) // id -> index in result
var result []MergedItem
for _, l := range layers {
packDir := filepath.Join(l.Dir, packID)
filePath := filepath.Join(packDir, filename)
data, err := os.ReadFile(filePath)
if err != nil {
continue
}
var items []map[string]any
if err := yaml.Unmarshal(data, &items); err != nil {
continue
}
for _, item := range items {
id, _ := item["id"].(string)
if id == "" {
id, _ = item["name"].(string)
}
if idx, exists := seen[id]; exists {
result[idx] = MergedItem{
Data: item,
Layer: l.Layer,
IsOverride: true,
}
} else {
seen[id] = len(result)
result = append(result, MergedItem{
Data: item,
Layer: l.Layer,
})
}
}
}
return result, nil
}
// LoadSingleObject loads a single-object file (e.g., pack.yaml) from the target layer.
func LoadSingleObject(filePath string) (map[string]any, error) {
data, err := os.ReadFile(filePath)
if err != nil {
if os.IsNotExist(err) {
return make(map[string]any), nil
}
return nil, err
}
var obj map[string]any
if err := yaml.Unmarshal(data, &obj); err != nil {
return nil, err
}
return obj, nil
}
// SaveItems marshals items to YAML and writes to filePath.
// Only items belonging to the target layer are included.
func SaveItems(filePath string, items []MergedItem, targetLayer Layer) error {
var toSave []map[string]any
for _, item := range items {
if item.Layer == targetLayer {
toSave = append(toSave, item.Data)
}
}
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
return err
}
data, err := yaml.Marshal(toSave)
if err != nil {
return err
}
return os.WriteFile(filePath, data, 0644)
}
// SaveObject marshals a single object to YAML and writes to filePath.
func SaveObject(filePath string, obj map[string]any) error {
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
return err
}
data, err := yaml.Marshal(obj)
if err != nil {
return err
}
return os.WriteFile(filePath, data, 0644)
}
// ColumnsForSchema returns the column keys to display in list view,
// based on the schema fields (ID + first 2-3 string fields).
func ColumnsForSchema(spec *schema.ObjectSpec) []string {
cols := []string{}
for _, f := range spec.Fields {
if f.Type == "string" && f.Key != "description" && f.Key != "pattern" && f.Key != "cause" && f.Key != "fix" {
cols = append(cols, f.Key)
if len(cols) >= 4 {
break
}
}
}
return cols
}- [ ] Step 2: Verify compilation
Run: go build ./internal/editor/... Expected: Compiles successfully.
- [ ] Step 3: Commit
git add internal/editor/merge.go
git commit -m "feat(editor): add merged view assembly with layer tracking"Task 8: Form generator (internal/editor/form.go)
Files:
Create:
internal/editor/form.go[ ] Step 1: Write the huh form generator from FieldSpec
The form generator uses heap-allocated binding structs so huh can write to stable pointers. Each field type gets its own binding allocated on the heap and stored in a bindings map. After form.Run(), the caller collects values from the bindings.
Known deviation from spec: Free-form string arrays use comma-separated input (not a custom type+Enter tag input). This is a pragmatic simplification — the spec's "Custom tag input" would require a custom Bubbletea component, which can be added later.
package editor
import (
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"charm.land/huh/v2"
"github.com/SAP-samples/sap-devs-cli/internal/schema"
)
// StringBinding holds a string value for huh form binding.
type StringBinding struct{ Value string }
// BoolBinding holds a bool value for huh form binding.
type BoolBinding struct{ Value bool }
// SliceBinding holds a string slice value for huh form binding.
type SliceBinding struct{ Value []string }
// Bindings maps field keys to their typed binding structs.
type Bindings struct {
Strings map[string]*StringBinding
Bools map[string]*BoolBinding
Slices map[string]*SliceBinding
Objects map[string]map[string]any // nested objects (passthrough)
Maps map[string]map[string]any // map fields (passthrough)
}
func NewBindings() *Bindings {
return &Bindings{
Strings: make(map[string]*StringBinding),
Bools: make(map[string]*BoolBinding),
Slices: make(map[string]*SliceBinding),
Objects: make(map[string]map[string]any),
Maps: make(map[string]map[string]any),
}
}
// ToMap collects all binding values into a flat map[string]any for YAML marshaling.
func (b *Bindings) ToMap(spec *schema.ObjectSpec) map[string]any {
result := make(map[string]any)
for k, v := range b.Strings {
if strings.HasSuffix(k, "_raw") {
continue
}
result[k] = v.Value
}
for k, v := range b.Bools {
result[k] = v.Value
}
for k, v := range b.Slices {
anySlice := make([]any, len(v.Value))
for i, s := range v.Value {
anySlice[i] = s
}
result[k] = anySlice
}
for k, v := range b.Objects {
result[k] = v
}
for k, v := range b.Maps {
result[k] = v
}
// Resolve comma-separated _raw fields back to arrays
for _, f := range spec.Fields {
if f.Type == "array" && len(f.ItemEnum) == 0 {
if raw, ok := b.Strings[f.Key+"_raw"]; ok {
tags := splitTags(raw.Value)
anyTags := make([]any, len(tags))
for i, t := range tags {
anyTags[i] = t
}
result[f.Key] = anyTags
}
}
// Convert integer strings back to int
if f.Type == "integer" {
if s, ok := b.Strings[f.Key]; ok && s.Value != "" {
if n, err := strconv.Atoi(s.Value); err == nil {
result[f.Key] = n
}
}
}
}
return result
}
// BuildForm creates a huh form from a schema ObjectSpec and current values.
func BuildForm(spec *schema.ObjectSpec, values map[string]any) (*huh.Form, *Bindings) {
bindings := NewBindings()
var fields []huh.Field
for _, f := range spec.Fields {
// Skip conditional fields whose condition is not met
if f.Condition != nil {
if current, ok := values[f.Condition.Field]; !ok || current != f.Condition.Value {
continue
}
}
field := buildField(f, values, bindings)
if field != nil {
fields = append(fields, field)
}
}
group := huh.NewGroup(fields...)
form := huh.NewForm(group).WithTheme(huh.ThemeDracula(true))
return form, bindings
}
func buildField(f schema.FieldSpec, values map[string]any, bindings *Bindings) huh.Field {
switch f.Type {
case "string":
return buildStringField(f, values, bindings)
case "integer":
return buildIntegerField(f, values, bindings)
case "boolean":
return buildBoolField(f, values, bindings)
case "array":
return buildArrayField(f, values, bindings)
case "object":
return buildObjectFields(f, values, bindings)
case "map":
return buildMapField(f, values, bindings)
}
return nil
}
func buildStringField(f schema.FieldSpec, values map[string]any, bindings *Bindings) huh.Field {
if len(f.Enum) > 0 {
current, _ := values[f.Key].(string)
if current == "" && f.Default != nil {
current, _ = f.Default.(string)
}
b := &StringBinding{Value: current}
bindings.Strings[f.Key] = b
opts := make([]huh.Option[string], 0, len(f.Enum))
for _, e := range f.Enum {
opts = append(opts, huh.NewOption(e, e))
}
return huh.NewSelect[string]().
Title(fieldTitle(f)).
Description(f.Description).
Options(opts...).
Value(&b.Value)
}
current, _ := values[f.Key].(string)
b := &StringBinding{Value: current}
bindings.Strings[f.Key] = b
input := huh.NewInput().
Title(fieldTitle(f)).
Description(f.Description).
Placeholder(placeholderForField(f)).
Value(&b.Value)
if v := validatorForString(f); v != nil {
input = input.Validate(v)
}
return input
}
func buildIntegerField(f schema.FieldSpec, values map[string]any, bindings *Bindings) huh.Field {
var current string
switch v := values[f.Key].(type) {
case float64:
current = strconv.Itoa(int(v))
case int:
current = strconv.Itoa(v)
}
b := &StringBinding{Value: current}
bindings.Strings[f.Key] = b
return huh.NewInput().
Title(fieldTitle(f)).
Description(f.Description).
Placeholder("0").
Value(&b.Value).
Validate(func(s string) error {
if s == "" && !f.Required {
return nil
}
if _, err := strconv.Atoi(s); err != nil {
return fmt.Errorf("must be an integer")
}
return nil
})
}
func buildBoolField(f schema.FieldSpec, values map[string]any, bindings *Bindings) huh.Field {
current, _ := values[f.Key].(bool)
b := &BoolBinding{Value: current}
bindings.Bools[f.Key] = b
return huh.NewConfirm().
Title(fieldTitle(f)).
Description(f.Description).
Value(&b.Value)
}
func buildArrayField(f schema.FieldSpec, values map[string]any, bindings *Bindings) huh.Field {
rawArr, _ := values[f.Key].([]any)
var current []string
for _, v := range rawArr {
if s, ok := v.(string); ok {
current = append(current, s)
}
}
if len(f.ItemEnum) > 0 {
b := &SliceBinding{Value: current}
bindings.Slices[f.Key] = b
opts := make([]huh.Option[string], 0, len(f.ItemEnum))
for _, e := range f.ItemEnum {
opt := huh.NewOption(e, e)
for _, c := range current {
if c == e {
opt = opt.Selected(true)
break
}
}
opts = append(opts, opt)
}
return huh.NewMultiSelect[string]().
Title(fieldTitle(f)).
Description(f.Description).
Options(opts...).
Value(&b.Value)
}
// Free-form string array: comma-separated input (spec deviation: not custom tag input)
joined := strings.Join(current, ", ")
b := &StringBinding{Value: joined}
bindings.Strings[f.Key+"_raw"] = b
return huh.NewInput().
Title(fieldTitle(f) + " (comma-separated)").
Description(f.Description).
Value(&b.Value).
Validate(func(s string) error {
parts := splitTags(s)
if f.MinItems > 0 && len(parts) < f.MinItems {
return fmt.Errorf("at least %d item(s) required", f.MinItems)
}
return nil
})
}
func buildObjectFields(f schema.FieldSpec, values map[string]any, bindings *Bindings) huh.Field {
childObj, ok := values[f.Key].(map[string]any)
if !ok {
childObj = make(map[string]any)
}
bindings.Objects[f.Key] = childObj
var summary []string
for _, child := range f.Children {
if v, ok := childObj[child.Key]; ok {
summary = append(summary, fmt.Sprintf("%s: %v", child.Key, v))
}
}
desc := strings.Join(summary, " | ")
if desc == "" {
desc = "(empty)"
}
return huh.NewNote().
Title(fieldTitle(f)).
Description(desc + "\n\nPress Enter to edit nested fields")
}
func buildMapField(f schema.FieldSpec, values map[string]any, bindings *Bindings) huh.Field {
mapObj, ok := values[f.Key].(map[string]any)
if !ok {
mapObj = make(map[string]any)
}
bindings.Maps[f.Key] = mapObj
var summary []string
for k, v := range mapObj {
summary = append(summary, fmt.Sprintf("%s: %v", k, v))
}
desc := strings.Join(summary, "\n")
if desc == "" {
desc = "(empty)"
}
return huh.NewNote().
Title(fieldTitle(f) + " (key-value map)").
Description(desc + "\n\nPress Enter to edit map entries")
}
func fieldTitle(f schema.FieldSpec) string {
title := f.Title
if f.Required {
title += " *"
}
return title
}
func placeholderForField(f schema.FieldSpec) string {
if f.Format == "uri" {
return "https://..."
}
if f.Pattern != "" {
return fmt.Sprintf("pattern: %s", f.Pattern)
}
return ""
}
func validatorForString(f schema.FieldSpec) func(string) error {
return func(s string) error {
if f.Required && s == "" {
return fmt.Errorf("required")
}
if s == "" {
return nil
}
if f.Format == "uri" {
if _, err := url.ParseRequestURI(s); err != nil || !strings.HasPrefix(s, "http") {
return fmt.Errorf("not a valid URI")
}
}
if f.Pattern != "" {
re, err := regexp.Compile(f.Pattern)
if err == nil && !re.MatchString(s) {
return fmt.Errorf("does not match expected format")
}
}
return nil
}
}
func splitTags(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
var out []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}- [ ] Step 2: Verify compilation
Run: go build ./internal/editor/... Expected: Compiles successfully.
- [ ] Step 3: Commit
git add internal/editor/form.go
git commit -m "feat(editor): add huh form generator from FieldSpec"Task 9: List view model (internal/editor/list.go)
Files:
Create:
internal/editor/list.go[ ] Step 1: Write the list view Bubbletea model
package editor
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/SAP-samples/sap-devs-cli/internal/schema"
)
var (
selectedStyle = lipgloss.NewStyle().Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230"))
headerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Bold(true)
layerOfficial = lipgloss.NewStyle().Foreground(lipgloss.Color("114")).Render("official")
layerCompany = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Render("company")
layerUser = lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Render("user")
layerProject = lipgloss.NewStyle().Foreground(lipgloss.Color("141")).Render("project")
overrideBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Render(" ✎")
)
func layerBadge(l Layer, isOverride bool) string {
badge := ""
switch l {
case LayerOfficial:
badge = layerOfficial
case LayerCompany:
badge = layerCompany
case LayerUser:
badge = layerUser
case LayerProject:
badge = layerProject
}
if isOverride {
badge += overrideBadge
}
return badge
}
type listModel struct {
items []MergedItem
columns []string
cursor int
width int
height int
filter string
filtering bool
target *ResolvedFile
schema *schema.Schema
dirty bool
// Result: which item to edit, or -1 for none
editIndex int
addNew bool
deleteIdx int
quit bool
save bool
}
func newListModel(items []MergedItem, columns []string, target *ResolvedFile, s *schema.Schema) listModel {
return listModel{
items: items,
columns: columns,
target: target,
schema: s,
editIndex: -1,
deleteIdx: -1,
width: 80,
height: 24,
}
}
func (m listModel) Init() tea.Cmd {
return nil
}
func (m listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case tea.KeyMsg:
if m.filtering {
switch msg.String() {
case "enter", "esc":
m.filtering = false
case "backspace":
if len(m.filter) > 0 {
m.filter = m.filter[:len(m.filter)-1]
}
default:
if len(msg.String()) == 1 {
m.filter += msg.String()
}
}
return m, nil
}
switch msg.String() {
case "q":
m.save = true
m.quit = true
return m, tea.Quit
case "esc":
m.quit = true
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
visible := m.visibleItems()
if m.cursor < len(visible)-1 {
m.cursor++
}
case "enter":
visible := m.visibleItems()
if m.cursor < len(visible) {
m.editIndex = visible[m.cursor].originalIndex
return m, tea.Quit
}
case "a":
m.addNew = true
return m, tea.Quit
case "d":
visible := m.visibleItems()
if m.cursor < len(visible) {
idx := visible[m.cursor].originalIndex
if m.items[idx].Layer == m.target.Layer {
m.deleteIdx = idx
m.dirty = true
}
}
case "/":
m.filtering = true
m.filter = ""
}
}
return m, nil
}
type visibleItem struct {
originalIndex int
item MergedItem
}
func (m listModel) visibleItems() []visibleItem {
var out []visibleItem
for i, item := range m.items {
if m.filter != "" {
id, _ := item.Data["id"].(string)
name, _ := item.Data["name"].(string)
title, _ := item.Data["title"].(string)
text := strings.ToLower(id + " " + name + " " + title)
if !strings.Contains(text, strings.ToLower(m.filter)) {
continue
}
}
out = append(out, visibleItem{originalIndex: i, item: item})
}
return out
}
func (m listModel) View() string {
var sb strings.Builder
// Header
sb.WriteString(fmt.Sprintf("\n %s · %s · %d items\n",
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("114")).Render(m.target.Filename),
fmt.Sprintf("Pack: %s · Layer: %s", m.target.PackID, m.target.Layer),
len(m.items),
))
if m.filtering {
sb.WriteString(fmt.Sprintf(" / %s█\n", m.filter))
}
sb.WriteString("\n")
// Column header
header := " "
for _, col := range m.columns {
header += fmt.Sprintf("%-20s", strings.ToUpper(col))
}
header += "LAYER"
sb.WriteString(headerStyle.Render(header))
sb.WriteString("\n")
// Items
visible := m.visibleItems()
maxVisible := m.height - 8
if maxVisible < 5 {
maxVisible = 5
}
start := 0
if m.cursor >= maxVisible {
start = m.cursor - maxVisible + 1
}
for i := start; i < len(visible) && i < start+maxVisible; i++ {
item := visible[i]
row := " "
if i == m.cursor {
row += "› "
} else {
row += " "
}
for _, col := range m.columns {
val, _ := item.item.Data[col].(string)
if len(val) > 18 {
val = val[:18] + "…"
}
row += fmt.Sprintf("%-20s", val)
}
row += layerBadge(item.item.Layer, item.item.IsOverride)
if i == m.cursor {
sb.WriteString(selectedStyle.Render(row))
} else {
sb.WriteString(row)
}
sb.WriteString("\n")
}
// Footer
sb.WriteString("\n")
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(
" ↑↓ navigate · Enter edit · a add · d delete · / filter · q save & quit · Esc quit",
))
sb.WriteString("\n")
return sb.String()
}- [ ] Step 2: Verify compilation
Run: go build ./internal/editor/... Expected: Compiles successfully.
- [ ] Step 3: Commit
git add internal/editor/list.go
git commit -m "feat(editor): add list view Bubbletea model with layer badges"Task 10: Main editor model (internal/editor/editor.go)
Files:
Create:
internal/editor/editor.go[ ] Step 1: Write the main editor orchestrator
package editor
import (
"fmt"
"os"
"path/filepath"
tea "github.com/charmbracelet/bubbletea"
"charm.land/huh/v2"
"github.com/SAP-samples/sap-devs-cli/internal/schema"
"gopkg.in/yaml.v3"
)
// Run opens the editor for the given resolved file.
func Run(target *ResolvedFile, schemasDir string) error {
s, err := schema.Load(schemasDir, target.SchemaName)
if err != nil {
return fmt.Errorf("load schema: %w", err)
}
switch s.Type {
case "object":
return runObjectEditor(target, s)
case "array":
return runArrayEditor(target, s)
}
return fmt.Errorf("unsupported schema type: %s", s.Type)
}
func runObjectEditor(target *ResolvedFile, s *schema.Schema) error {
obj, err := LoadSingleObject(target.FilePath)
if err != nil {
return err
}
form, bindings := BuildForm(s.ObjectSpec, obj)
if err := form.Run(); err != nil {
return err
}
obj = bindings.ToMap(s.ObjectSpec)
// Validate before saving
errs := schema.Validate(s, obj)
if hasErrors(errs) {
fmt.Fprintf(os.Stderr, "\nValidation errors:\n")
for _, e := range errs {
if e.Severity == "error" {
fmt.Fprintf(os.Stderr, " ✗ %s\n", e)
}
}
return fmt.Errorf("fix validation errors before saving")
}
return SaveObject(target.FilePath, obj)
}
func runArrayEditor(target *ResolvedFile, s *schema.Schema) error {
cwd, _ := os.Getwd()
items, err := LoadMergedItems(cwd, target.PackID, target.Filename)
if err != nil {
return err
}
columns := ColumnsForSchema(s.ItemSpec)
for {
listMdl := newListModel(items, columns, target, s)
p := tea.NewProgram(listMdl, tea.WithAltScreen())
finalModel, err := p.Run()
if err != nil {
return err
}
result := finalModel.(listModel)
if result.deleteIdx >= 0 {
items = append(items[:result.deleteIdx], items[result.deleteIdx+1:]...)
continue
}
if result.addNew {
newItem := make(map[string]any)
for _, f := range s.ItemSpec.Fields {
if f.Default != nil {
newItem[f.Key] = f.Default
}
}
if err := editItem(s.ItemSpec, newItem); err != nil {
continue
}
items = append(items, MergedItem{
Data: newItem,
Layer: target.Layer,
})
continue
}
if result.editIndex >= 0 {
item := &items[result.editIndex]
if item.Layer != target.Layer {
// Auto-create override copy
cloned := make(map[string]any)
for k, v := range item.Data {
cloned[k] = v
}
item.Data = cloned
item.Layer = target.Layer
item.IsOverride = true
}
if err := editItem(s.ItemSpec, item.Data); err != nil {
continue
}
continue
}
if result.save {
return SaveItems(target.FilePath, items, target.Layer)
}
// Quit without saving
return nil
}
}
func editItem(spec *schema.ObjectSpec, data map[string]any) error {
form, bindings := BuildForm(spec, data)
if err := form.Run(); err != nil {
return err
}
result := bindings.ToMap(spec)
for k, v := range result {
data[k] = v
}
return nil
}
func hasErrors(errs []schema.ValidationError) bool {
for _, e := range errs {
if e.Severity == "error" {
return true
}
}
return false
}
// ValidateFile validates a single YAML file against its schema.
func ValidateFile(filePath, schemasDir string) ([]schema.ValidationError, error) {
filename := filepath.Base(filePath)
schemaName := schema.SchemaForFile(filename)
if schemaName == "" {
return nil, fmt.Errorf("unknown content file: %s", filename)
}
s, err := schema.Load(schemasDir, schemaName)
if err != nil {
return nil, err
}
fileData, err := os.ReadFile(filePath)
if err != nil {
return nil, err
}
var data any
switch s.Type {
case "array":
var arr []any
if err := yaml.Unmarshal(fileData, &arr); err != nil {
return nil, fmt.Errorf("parse YAML: %w", err)
}
data = arr
case "object":
var obj map[string]any
if err := yaml.Unmarshal(fileData, &obj); err != nil {
return nil, fmt.Errorf("parse YAML: %w", err)
}
data = obj
}
return schema.Validate(s, data), nil
}- [ ] Step 2: Verify compilation
Run: go build ./internal/editor/... Expected: Compiles successfully.
- [ ] Step 3: Commit
git add internal/editor/editor.go
git commit -m "feat(editor): add main TUI editor with list→form flow"Task 11: CLI commands — content parent and content list
Files:
Create:
cmd/content.goCreate:
cmd/content_list.go[ ] Step 1: Write the content parent command
package cmd
import "github.com/spf13/cobra"
var contentCmd = &cobra.Command{
Use: "content",
Short: "Manage content YAML files",
Long: "Browse, edit, and validate content YAML files across all content layers.",
}
func init() {
rootCmd.AddCommand(contentCmd)
}- [ ] Step 2: Write the content list subcommand
package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/SAP-samples/sap-devs-cli/internal/editor"
"github.com/SAP-samples/sap-devs-cli/internal/schema"
"gopkg.in/yaml.v3"
)
var (
contentListPack string
contentListLayer string
)
var contentListCmd = &cobra.Command{
Use: "list",
Short: "Show all content files across layers",
RunE: runContentList,
}
func init() {
contentListCmd.Flags().StringVar(&contentListPack, "pack", "", "Filter to a specific pack")
contentListCmd.Flags().StringVar(&contentListLayer, "layer", "", "Filter to a specific layer (official, company, user, project)")
contentCmd.AddCommand(contentListCmd)
}
func runContentList(cmd *cobra.Command, args []string) error {
cwd, _ := os.Getwd()
layers := editor.AllLayers(cwd)
cmd.Println()
cmd.Printf(" %-12s %-28s %-12s %s\n", "PACK", "FILE", "LAYER", "ITEMS")
for _, l := range layers {
if contentListLayer != "" && l.Layer.String() != contentListLayer {
continue
}
entries, err := os.ReadDir(l.Dir)
if err != nil {
continue
}
for _, e := range entries {
if !e.IsDir() {
continue
}
packID := e.Name()
if contentListPack != "" && packID != contentListPack {
continue
}
packDir := filepath.Join(l.Dir, packID)
for filename := range schema.KnownFiles() {
filePath := filepath.Join(packDir, filename)
if _, err := os.Stat(filePath); err != nil {
continue
}
count := countItems(filePath)
cmd.Printf(" %-12s %-28s %-12s %s\n", packID, filename, l.Layer, count)
}
}
}
cmd.Println()
return nil
}
func countItems(filePath string) string {
data, err := os.ReadFile(filePath)
if err != nil {
return "—"
}
// Try as array first
var arr []any
if err := yaml.Unmarshal(data, &arr); err == nil && arr != nil {
return fmt.Sprintf("%d", len(arr))
}
// Single object
return "—"
}- [ ] Step 3: Verify compilation
Run: go build ./... Expected: Compiles successfully.
- [ ] Step 4: Commit
git add cmd/content.go cmd/content_list.go
git commit -m "feat(content): add content parent command and content list"Task 12: CLI command — content validate
Files:
Create:
cmd/content_validate.go[ ] Step 1: Write the content validate subcommand
package cmd
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/SAP-samples/sap-devs-cli/internal/editor"
"github.com/SAP-samples/sap-devs-cli/internal/schema"
"github.com/SAP-samples/sap-devs-cli/internal/xdg"
)
var (
validatePack string
validateLayer string
validateJSON bool
)
var contentValidateCmd = &cobra.Command{
Use: "validate",
Short: "Validate all content files against JSON schemas",
RunE: runContentValidate,
}
func init() {
contentValidateCmd.Flags().StringVar(&validatePack, "pack", "", "Validate only a specific pack")
contentValidateCmd.Flags().StringVar(&validateLayer, "layer", "", "Validate only a specific layer")
contentValidateCmd.Flags().BoolVar(&validateJSON, "json", false, "Output as JSON")
contentCmd.AddCommand(contentValidateCmd)
}
type validateResult struct {
Layer string `json:"layer"`
Pack string `json:"pack"`
File string `json:"file"`
Path string `json:"path"`
Errors []schema.ValidationError `json:"errors,omitempty"`
Passed bool `json:"passed"`
}
func runContentValidate(cmd *cobra.Command, args []string) error {
cwd, _ := os.Getwd()
layers := editor.AllLayers(cwd)
// Find schemas directory
schemasDir := findSchemasDir(cwd)
if schemasDir == "" {
return fmt.Errorf("cannot find content/schemas/ directory")
}
var results []validateResult
totalFiles := 0
totalErrors := 0
filesWithErrors := 0
if !validateJSON {
cmd.Println("\nValidating content across all layers...\n")
}
for _, l := range layers {
if validateLayer != "" && l.Layer.String() != validateLayer {
continue
}
entries, err := os.ReadDir(l.Dir)
if err != nil {
continue
}
for _, e := range entries {
if !e.IsDir() {
continue
}
packID := e.Name()
if validatePack != "" && packID != validatePack {
continue
}
packDir := filepath.Join(l.Dir, packID)
for filename := range schema.KnownFiles() {
filePath := filepath.Join(packDir, filename)
if _, err := os.Stat(filePath); err != nil {
continue
}
totalFiles++
errs, err := editor.ValidateFile(filePath, schemasDir)
if err != nil {
cmd.PrintErrln(fmt.Sprintf(" %s %s/%s error: %v", l.Layer, packID, filename, err))
continue
}
r := validateResult{
Layer: l.Layer.String(),
Pack: packID,
File: filename,
Path: filePath,
Errors: errs,
Passed: !hasValidationErrors(errs),
}
results = append(results, r)
if !validateJSON {
relPath := filepath.Join(packID, filename)
if r.Passed {
cmd.Printf(" %-10s %-40s ✓\n", l.Layer, relPath)
} else {
errCount := countValidationErrors(errs)
totalErrors += errCount
filesWithErrors++
cmd.Printf(" %-10s %-40s ✗ %d errors\n", l.Layer, relPath, errCount)
for _, e := range errs {
if e.Severity == "error" {
cmd.Printf(" %s\n", e)
}
}
}
}
}
}
}
if validateJSON {
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(results)
}
passed := totalFiles - filesWithErrors
cmd.Printf("\nValidated %d files across %d layers: %d passed, %d errors in %d files\n",
totalFiles, len(layers), passed, totalErrors, filesWithErrors)
if filesWithErrors > 0 {
os.Exit(1)
}
return nil
}
func findSchemasDir(cwd string) string {
// Check CWD first (contributor checkout)
candidate := filepath.Join(cwd, "content", "schemas")
if _, err := os.Stat(candidate); err == nil {
return candidate
}
// Check cache
paths, err := xdg.New()
if err != nil {
return ""
}
candidate = filepath.Join(paths.CacheDir, "official", "content", "schemas")
if _, err := os.Stat(candidate); err == nil {
return candidate
}
return ""
}
func hasValidationErrors(errs []schema.ValidationError) bool {
for _, e := range errs {
if e.Severity == "error" {
return true
}
}
return false
}
func countValidationErrors(errs []schema.ValidationError) int {
n := 0
for _, e := range errs {
if e.Severity == "error" {
n++
}
}
return n
}Note: Uses xdg.New() from the existing internal/xdg package. Ensure the import includes "github.com/SAP-samples/sap-devs-cli/internal/xdg".
- [ ] Step 2: Verify compilation
Run: go build ./... Expected: Compiles successfully.
- [ ] Step 3: Commit
git add cmd/content_validate.go
git commit -m "feat(content): add content validate command with JSON output"Task 13: CLI command — content edit
Files:
Create:
cmd/content_edit.go[ ] Step 1: Write the content edit subcommand
package cmd
import (
"fmt"
"os"
"charm.land/huh/v2"
"github.com/spf13/cobra"
"github.com/SAP-samples/sap-devs-cli/internal/editor"
"github.com/SAP-samples/sap-devs-cli/internal/schema"
)
var contentEditCmd = &cobra.Command{
Use: "edit <file>",
Short: "Open interactive editor for a content YAML file",
Long: `Open a TUI editor for a content YAML file. Supports:
resources.yaml (auto-detect pack)
cap/resources.yaml (explicit pack)
./content/packs/cap/resources.yaml (direct path)`,
Args: cobra.ExactArgs(1),
RunE: runContentEdit,
}
func init() {
contentCmd.AddCommand(contentEditCmd)
}
func runContentEdit(cmd *cobra.Command, args []string) error {
cwd, _ := os.Getwd()
arg := args[0]
// Check for ambiguous bare filename
if schema.SchemaForFile(arg) != "" && !containsSlash(arg) {
packs := editor.AmbiguousPacks(cwd, arg)
if len(packs) > 1 {
var selected string
opts := make([]huh.Option[string], 0, len(packs))
for _, p := range packs {
opts = append(opts, huh.NewOption(p, p))
}
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title(fmt.Sprintf("Multiple packs contain %s. Which one?", arg)).
Options(opts...).
Value(&selected),
),
)
if err := form.Run(); err != nil {
return err
}
arg = selected + "/" + arg
}
}
target, err := editor.ResolveEditTarget(cwd, arg)
if err != nil {
return err
}
schemasDir := findSchemasDir(cwd)
if schemasDir == "" {
return fmt.Errorf("cannot find content/schemas/ directory")
}
fmt.Fprintf(os.Stderr, "Editing: %s (%s) · %s\n", target.Filename, target.Layer, target.FilePath)
return editor.Run(target, schemasDir)
}
func containsSlash(s string) bool {
for _, c := range s {
if c == '/' {
return true
}
}
return false
}- [ ] Step 2: Verify compilation
Run: go build ./... Expected: Compiles successfully.
- [ ] Step 3: Commit
git add cmd/content_edit.go
git commit -m "feat(content): add content edit command with pack disambiguation"Task 14: Update CLAUDE.md CLI reference table
Files:
Modify:
CLAUDE.mdModify:
content/packs/base/context.md(if CLI reference table exists there)[ ] Step 1: Add
contentcommand to CLI commands table in CLAUDE.md
Add this row to the ### CLI Commands table:
| `content` | Manage content YAML files; `content edit/validate/list` with `--pack`/`--layer`/`--json` filtering |Insert alphabetically between context and discovery.
- [ ] Step 2: Verify the file renders correctly
Read the modified section to verify formatting.
- [ ] Step 3: Commit
git add CLAUDE.md
git commit -m "docs: add content command to CLI reference table"Task 15: Documentation — content authoring guide
Files:
Modify:
docs/content-authoring.md[ ] Step 1: Add content editor section to docs/content-authoring.md
Add a new top-level section covering:
- Content Editor — overview of
content edit,content validate,content list - Editing content in a checkout — contributor workflow for official/company repos
- Creating user overrides — how
content editauto-creates user-layer overrides - Creating project overrides — project-scoped customization in
.sap-devs/ - Schema-driven value help — how enums, formats, patterns drive the form UI
- Validation — how
content validateworks, CI integration with exit codes
Include examples of each command with expected output.
- [ ] Step 2: Commit
git add docs/content-authoring.md
git commit -m "docs: add content editing and validation guide"Task 16: Update TODO.md — mark Content Editing UI as in-progress
Files:
Modify:
TODO.md[ ] Step 1: Update the Content Editing UI section to reflect Phase 1 completion
Mark Phase 1 items (edit, validate, list, schema-driven forms, layer resolution) as done. Keep Phase 2 items (undo/redo, diff view, git integration, drag-and-drop, bulk edit, creation wizard) as future.
- [ ] Step 2: Commit
git add TODO.md
git commit -m "docs: update TODO with content editing Phase 1 progress"Task 17: Integration test — build and smoke test
Files:
No new files
[ ] Step 1: Verify full build
go build ./...
go vet ./...Expected: Both pass cleanly.
- [ ] Step 2: Test content list command
go run . content listExpected: Prints table of content files across detected layers.
- [ ] Step 3: Test content validate command
go run . content validateExpected: Validates all content files, prints results, exits 0 if all pass.
- [ ] Step 4: Test content validate with JSON output
go run . content validate --jsonExpected: Prints JSON array of validation results.
- [ ] Step 5: Test content edit (dry run)
go run . content edit cap/resources.yamlExpected: Opens TUI editor showing resources in list view. Press Esc to quit without saving.
- [ ] Step 6: Final commit if any fixes needed
git add -A
git commit -m "fix: address integration test findings"