sap-devs CLI — Plan 1: Foundation
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: Deliver a working Go CLI binary with init, sync, profile, tip, and config commands backed by a layered content system and per-category TTL-based auto-refresh.
Architecture: A thin Go binary built with Cobra reads content from a layered cache (official → company → user → project). On every invocation, stale content categories are refreshed in the background via HTTP archive download. The tip command is the canonical shell-profile hook; sync and profile give developers manual control.
Tech Stack: Go 1.22+, Cobra (CLI), Viper (config), glamour (Markdown rendering in terminal), testify (test assertions), gopkg.in/yaml.v3
Spec: docs/superpowers/specs/2026-04-13-sap-devs-cli-design.md
File Map
sap-devs-cli/
main.go Entry point — wires cobra root, calls Execute()
go.mod
go.sum
cmd/
root.go Root cobra command; global flags; triggers background staleness check
init.go sap-devs init — first-time setup wizard
sync.go sap-devs sync [--force] [--category <name>]
profile.go sap-devs profile {set,show,list}
tip.go sap-devs tip
config.go sap-devs config {show,set,company}
internal/
xdg/
xdg.go Platform-native config/cache/data paths (wraps os.UserConfigDir etc.)
xdg_test.go
config/
config.go Load/save ~/.config/sap-devs/config.yaml and profile.yaml
config_test.go
content/
pack.go Pack struct; parse pack.yaml + context.md + resources.yaml + tips.md + tools.yaml
pack_test.go
profile.go Profile struct; parse profiles/*.yaml; apply weight ordering to packs
profile_test.go
loader.go ContentLoader: merge official/company/user/project layers; return ordered packs
loader_test.go
tip.go Select a profile-relevant tip from merged pack pool (daily seed for consistency)
tip_test.go
sync/
fetcher.go Download a tagged zip archive from a GitHub/GitLab HTTPS URL; extract to dir
fetcher_test.go
engine.go TTL staleness check per category; trigger fetcher; update last-sync timestamps
engine_test.go
content/ Shipped with the repo — the official knowledge base
packs/
cap/
pack.yaml
context.md
tips.md
resources.yaml
tools.yaml
mcp.yaml
abap/
pack.yaml
context.md
tips.md
resources.yaml
tools.yaml
mcp.yaml
btp-core/
pack.yaml
context.md
tips.md
resources.yaml
tools.yaml
mcp.yaml
profiles/
cap-developer.yaml
abap-developer.yaml
btp-developer.yaml
adapters/ Placeholder stubs only in Plan 1 (implemented in Plan 2)
claude-code.yaml
cursor.yaml
.github/
workflows/
ci.yml go test ./... + go build on push/PR
.gitignore .superpowers/Task 1: Project Bootstrap
Files:
Create:
go.modCreate:
main.goCreate:
cmd/root.goCreate:
.github/workflows/ci.ymlCreate:
.gitignore[ ] Step 1.1: Initialise Go module
cd d:/projects/sap-devs-cli
go mod init github.com/SAP-samples/sap-devs-cli- [ ] Step 1.2: Add dependencies
go get github.com/spf13/cobra@latest
go get github.com/spf13/viper@latest
go get github.com/charmbracelet/glamour@latest
go get github.com/stretchr/testify@latest
go get gopkg.in/yaml.v3@latest
go get github.com/blang/semver/v4@latest- [ ] Step 1.3: Write
main.go
package main
import "github.com/SAP-samples/sap-devs-cli/cmd"
func main() {
cmd.Execute()
}- [ ] Step 1.4: Write
cmd/root.go
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "sap-devs",
Short: "AI-first SAP developer toolkit",
Long: `sap-devs injects up-to-date SAP developer knowledge into your AI tools.`,
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}- [ ] Step 1.5: Write
.github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- run: go test ./...
- run: go build ./...- [ ] Step 1.6: Write
.gitignore
.superpowers/
sap-devs
sap-devs.exe- [ ] Step 1.7: Verify it builds
go build ./...Expected: no output (success).
- [ ] Step 1.8: Commit
git add go.mod go.sum main.go cmd/root.go .github/workflows/ci.yml .gitignore
git commit -m "feat: bootstrap Go CLI project with cobra"Task 2: XDG Path Resolution
Files:
Create:
internal/xdg/xdg.goCreate:
internal/xdg/xdg_test.go[ ] Step 2.1: Write the failing test
// internal/xdg/xdg_test.go
package xdg_test
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SAP-samples/sap-devs-cli/internal/xdg"
)
func TestNew_ReturnsNonEmptyPaths(t *testing.T) {
paths, err := xdg.New()
require.NoError(t, err)
assert.NotEmpty(t, paths.ConfigDir)
assert.NotEmpty(t, paths.CacheDir)
assert.NotEmpty(t, paths.DataDir)
}
func TestNew_PathsContainAppName(t *testing.T) {
paths, err := xdg.New()
require.NoError(t, err)
assert.Contains(t, paths.ConfigDir, "sap-devs")
assert.Contains(t, paths.CacheDir, "sap-devs")
assert.Contains(t, paths.DataDir, "sap-devs")
}
func TestNew_XDGEnvOverridesOnLinux(t *testing.T) {
if os.Getenv("GOOS") == "windows" {
t.Skip("XDG env vars not honoured on Windows")
}
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
paths, err := xdg.New()
require.NoError(t, err)
assert.Contains(t, paths.ConfigDir, "sap-devs")
}- [ ] Step 2.2: Run test to verify it fails
go test ./internal/xdg/... -vExpected: compile error — package doesn't exist yet.
- [ ] Step 2.3: Write
internal/xdg/xdg.go
package xdg
import (
"os"
"path/filepath"
"runtime"
)
const appName = "sap-devs"
// Paths holds platform-native directories for this application.
type Paths struct {
ConfigDir string // user config: ~/.config/sap-devs (Linux), ~/Library/Application Support/sap-devs (macOS), %APPDATA%/sap-devs (Windows)
CacheDir string // cache: ~/.cache/sap-devs (Linux), ~/Library/Caches/sap-devs (macOS), %LOCALAPPDATA%/sap-devs/cache (Windows)
DataDir string // data: ~/.local/share/sap-devs (Linux), ~/Library/Application Support/sap-devs/data (macOS), %LOCALAPPDATA%/sap-devs/data (Windows)
}
// New returns platform-appropriate paths for the application.
// On Linux, XDG_CONFIG_HOME, XDG_CACHE_HOME, and XDG_DATA_HOME are honoured.
func New() (*Paths, error) {
configDir, err := configDir()
if err != nil {
return nil, err
}
cacheDir, err := cacheDir()
if err != nil {
return nil, err
}
dataDir, err := dataDir(configDir)
if err != nil {
return nil, err
}
return &Paths{
ConfigDir: configDir,
CacheDir: cacheDir,
DataDir: dataDir,
}, nil
}
func configDir() (string, error) {
// On Linux, honour XDG_CONFIG_HOME
if runtime.GOOS == "linux" {
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, appName), nil
}
}
base, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(base, appName), nil
}
func cacheDir() (string, error) {
if runtime.GOOS == "linux" {
if xdg := os.Getenv("XDG_CACHE_HOME"); xdg != "" {
return filepath.Join(xdg, appName), nil
}
}
base, err := os.UserCacheDir()
if err != nil {
return "", err
}
// On Windows, UserCacheDir returns %LocalAppData%; add /cache sub-dir for clarity
if runtime.GOOS == "windows" {
return filepath.Join(base, appName, "cache"), nil
}
return filepath.Join(base, appName), nil
}
func dataDir(configDir string) (string, error) {
if runtime.GOOS == "linux" {
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
return filepath.Join(xdg, appName), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".local", "share", appName), nil
}
if runtime.GOOS == "windows" {
base, err := os.UserCacheDir() // %LOCALAPPDATA%
if err != nil {
return "", err
}
return filepath.Join(base, appName, "data"), nil
}
// macOS: sibling of config dir
return filepath.Join(configDir, "data"), nil
}- [ ] Step 2.4: Run tests to verify they pass
go test ./internal/xdg/... -vExpected: all tests PASS.
- [ ] Step 2.5: Commit
git add internal/xdg/
git commit -m "feat: add XDG-compliant platform path resolution"Task 3: Config Management
Files:
Create:
internal/config/config.goCreate:
internal/config/config_test.go[ ] Step 3.1: Write the failing tests
// internal/config/config_test.go
package config_test
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SAP-samples/sap-devs-cli/internal/config"
)
func TestLoad_DefaultsWhenNoFile(t *testing.T) {
dir := t.TempDir()
cfg, err := config.Load(dir)
require.NoError(t, err)
assert.Equal(t, 24*time.Hour, cfg.Sync.Tips)
assert.Equal(t, 168*time.Hour, cfg.Sync.Resources)
assert.False(t, cfg.Sync.Disabled)
}
func TestLoad_ReadsExistingFile(t *testing.T) {
dir := t.TempDir()
yaml := `company_repo: "https://github.com/myco/sap-content"`
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yaml), 0600))
cfg, err := config.Load(dir)
require.NoError(t, err)
assert.Equal(t, "https://github.com/myco/sap-content", cfg.CompanyRepo)
}
func TestSaveAndLoad_RoundTrip(t *testing.T) {
dir := t.TempDir()
cfg := config.Default()
cfg.CompanyRepo = "https://example.com/repo"
require.NoError(t, cfg.Save(dir))
loaded, err := config.Load(dir)
require.NoError(t, err)
assert.Equal(t, cfg.CompanyRepo, loaded.CompanyRepo)
}
func TestLoadProfile_DefaultsWhenNoFile(t *testing.T) {
dir := t.TempDir()
p, err := config.LoadProfile(dir)
require.NoError(t, err)
assert.Empty(t, p.ID)
}
func TestSaveAndLoadProfile_RoundTrip(t *testing.T) {
dir := t.TempDir()
p := &config.Profile{ID: "cap-developer"}
require.NoError(t, config.SaveProfile(dir, p))
loaded, err := config.LoadProfile(dir)
require.NoError(t, err)
assert.Equal(t, "cap-developer", loaded.ID)
}- [ ] Step 3.2: Run tests to verify they fail
go test ./internal/config/... -vExpected: compile error.
- [ ] Step 3.3: Write
internal/config/config.go
package config
import (
"os"
"path/filepath"
"time"
"gopkg.in/yaml.v3"
)
// Config holds user-level tool configuration from ~/.config/sap-devs/config.yaml.
type Config struct {
CompanyRepo string `yaml:"company_repo,omitempty"`
Sync SyncConfig `yaml:"sync"`
}
// SyncConfig controls per-category TTLs for background content refresh.
type SyncConfig struct {
Tips time.Duration `yaml:"tips"`
Tools time.Duration `yaml:"tools"`
Advocates time.Duration `yaml:"advocates"`
Resources time.Duration `yaml:"resources"`
Context time.Duration `yaml:"context"`
MCP time.Duration `yaml:"mcp"`
Disabled bool `yaml:"disabled"`
}
// Profile holds the user's active developer persona.
type Profile struct {
ID string `yaml:"id"`
}
// Default returns a Config with factory defaults applied.
func Default() *Config {
return &Config{
Sync: SyncConfig{
Tips: 24 * time.Hour,
Tools: 24 * time.Hour,
Advocates: 72 * time.Hour,
Resources: 168 * time.Hour,
Context: 168 * time.Hour,
MCP: 168 * time.Hour,
},
}
}
// Load reads config.yaml from configDir. Returns defaults if the file does not exist.
func Load(configDir string) (*Config, error) {
cfg := Default()
path := filepath.Join(configDir, "config.yaml")
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return cfg, nil
}
if err != nil {
return nil, err
}
return cfg, yaml.Unmarshal(data, cfg)
}
// Save writes the config to configDir/config.yaml, creating the directory if needed.
func (c *Config) Save(configDir string) error {
if err := os.MkdirAll(configDir, 0755); err != nil {
return err
}
data, err := yaml.Marshal(c)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(configDir, "config.yaml"), data, 0600)
}
// LoadProfile reads profile.yaml from configDir. Returns empty profile if file does not exist.
func LoadProfile(configDir string) (*Profile, error) {
p := &Profile{}
path := filepath.Join(configDir, "profile.yaml")
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return p, nil
}
if err != nil {
return nil, err
}
return p, yaml.Unmarshal(data, p)
}
// SaveProfile writes the profile to configDir/profile.yaml.
func SaveProfile(configDir string, p *Profile) error {
if err := os.MkdirAll(configDir, 0755); err != nil {
return err
}
data, err := yaml.Marshal(p)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(configDir, "profile.yaml"), data, 0600)
}- [ ] Step 3.4: Run tests to verify they pass
go test ./internal/config/... -vExpected: all PASS.
- [ ] Step 3.5: Commit
git add internal/config/
git commit -m "feat: add config and profile load/save"Task 4: Content Pack Loader
Files:
Create:
internal/content/pack.goCreate:
internal/content/pack_test.goCreate:
internal/content/profile.goCreate:
internal/content/profile_test.goCreate:
internal/content/loader.goCreate:
internal/content/loader_test.go[ ] Step 4.1: Write failing tests for Pack
// internal/content/pack_test.go
package content_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SAP-samples/sap-devs-cli/internal/content"
)
func TestLoadPack_ParsesAllFiles(t *testing.T) {
dir := makeTempPack(t, "cap", `
id: cap
name: SAP CAP
description: Cloud Application Programming Model
tags: [cloud, node, java]
profiles: [cap-developer]
weight: 100
`, "# CAP Context\nUse CDS for data modelling.", `
- id: cap/docs
title: CAP Docs
url: https://cap.cloud.sap
type: official-docs
`, "## Tip One\nTags: cap,nodejs\nUse cds watch for local development.")
pack, err := content.LoadPack(dir)
require.NoError(t, err)
assert.Equal(t, "cap", pack.ID)
assert.Equal(t, "SAP CAP", pack.Name)
assert.Contains(t, pack.ContextMD, "CDS")
assert.Len(t, pack.Resources, 1)
assert.Equal(t, "cap/docs", pack.Resources[0].ID)
assert.Len(t, pack.Tips, 1)
assert.Contains(t, pack.Tips[0].Content, "cds watch")
}
func TestLoadPack_MissingOptionalFilesOK(t *testing.T) {
dir := t.TempDir()
yaml := "id: abap\nname: ABAP\ndescription: ABAP Cloud\ntags: []\nprofiles: []\nweight: 90\n"
require.NoError(t, os.WriteFile(filepath.Join(dir, "pack.yaml"), []byte(yaml), 0644))
pack, err := content.LoadPack(dir)
require.NoError(t, err)
assert.Equal(t, "abap", pack.ID)
assert.Empty(t, pack.ContextMD)
assert.Empty(t, pack.Tips)
}
// makeTempPack creates a temporary pack directory with the given file contents.
func makeTempPack(t *testing.T, id, packYAML, contextMD, resourcesYAML, tipsMD string) string {
t.Helper()
dir := t.TempDir()
files := map[string]string{
"pack.yaml": packYAML,
"context.md": contextMD,
"resources.yaml": resourcesYAML,
"tips.md": tipsMD,
}
for name, content := range files {
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0644))
}
return dir
}- [ ] Step 4.2: Run to verify failure
go test ./internal/content/... -v -run TestLoadPackExpected: compile error.
- [ ] Step 4.3: Write
internal/content/pack.go
package content
import (
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// Pack is a named bundle of SAP knowledge for a specific domain.
type Pack struct {
ID string
Name string
Description string
Tags []string
Profiles []string
Weight int
ContextMD string
Resources []Resource
Tools []ToolDef
MCPServers []MCPServer
Tips []Tip
}
// Resource is a curated link within a pack.
type Resource struct {
ID string `yaml:"id"`
Title string `yaml:"title"`
URL string `yaml:"url"`
Type string `yaml:"type"`
Tags []string `yaml:"tags"`
Advocate string `yaml:"advocate,omitempty"`
}
// ToolDef is an entry in the tool catalog with version detection rules.
type ToolDef struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Required string `yaml:"required"`
Detect ToolDetect `yaml:"detect"`
Install map[string]string `yaml:"install"`
Docs string `yaml:"docs"`
}
// ToolDetect holds the command and regex to detect an installed tool's version.
type ToolDetect struct {
Command string `yaml:"command"`
Pattern string `yaml:"pattern"`
}
// MCPServer defines an installable MCP server for this pack's domain.
type MCPServer struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Description string `yaml:"description"`
Install MCPInstall `yaml:"install"`
Hosts []string `yaml:"hosts"`
}
// MCPInstall defines how to run the MCP server.
type MCPInstall struct {
Command string `yaml:"command"`
Args []string `yaml:"args"`
}
// Tip is a single actionable tip with profile tags.
type Tip struct {
Title string
Content string
Tags []string
}
type packMeta struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Description string `yaml:"description"`
Tags []string `yaml:"tags"`
Profiles []string `yaml:"profiles"`
Weight int `yaml:"weight"`
}
// LoadPack reads all files from packDir and returns a populated Pack.
func LoadPack(packDir string) (*Pack, error) {
metaData, err := os.ReadFile(filepath.Join(packDir, "pack.yaml"))
if err != nil {
return nil, err
}
var meta packMeta
if err := yaml.Unmarshal(metaData, &meta); err != nil {
return nil, err
}
pack := &Pack{
ID: meta.ID,
Name: meta.Name,
Description: meta.Description,
Tags: meta.Tags,
Profiles: meta.Profiles,
Weight: meta.Weight,
}
if data, err := os.ReadFile(filepath.Join(packDir, "context.md")); err == nil {
pack.ContextMD = string(data)
}
if data, err := os.ReadFile(filepath.Join(packDir, "resources.yaml")); err == nil {
_ = yaml.Unmarshal(data, &pack.Resources)
}
if data, err := os.ReadFile(filepath.Join(packDir, "tools.yaml")); err == nil {
_ = yaml.Unmarshal(data, &pack.Tools)
}
if data, err := os.ReadFile(filepath.Join(packDir, "mcp.yaml")); err == nil {
_ = yaml.Unmarshal(data, &pack.MCPServers)
}
if data, err := os.ReadFile(filepath.Join(packDir, "tips.md")); err == nil {
pack.Tips = parseTips(string(data))
}
return pack, nil
}
// parseTips splits a Markdown file on H2 headings into individual Tip entries.
// Each tip optionally has a "Tags: a,b,c" line immediately after the heading.
func parseTips(md string) []Tip {
var tips []Tip
sections := strings.Split(md, "\n## ")
for i, section := range sections {
if i == 0 && !strings.HasPrefix(section, "## ") {
section = strings.TrimPrefix(section, "## ")
if strings.TrimSpace(section) == "" {
continue
}
}
lines := strings.SplitN(strings.TrimSpace(section), "\n", 3)
if len(lines) == 0 {
continue
}
tip := Tip{Title: strings.TrimPrefix(lines[0], "## ")}
rest := ""
if len(lines) >= 2 {
if strings.HasPrefix(lines[1], "Tags:") {
tagStr := strings.TrimPrefix(lines[1], "Tags:")
for _, t := range strings.Split(tagStr, ",") {
tip.Tags = append(tip.Tags, strings.TrimSpace(t))
}
if len(lines) >= 3 {
rest = lines[2]
}
} else {
rest = strings.Join(lines[1:], "\n")
}
}
tip.Content = strings.TrimSpace(rest)
if tip.Title != "" {
tips = append(tips, tip)
}
}
return tips
}- [ ] Step 4.4: Write
internal/content/profile.go
package content
import (
"os"
"path/filepath"
"sort"
"gopkg.in/yaml.v3"
)
// Profile is a developer persona that weights packs by relevance.
type Profile struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Description string `yaml:"description"`
Packs []PackWeight `yaml:"packs"`
TipTags []string `yaml:"tip_tags"`
}
// PackWeight pairs a pack ID with a priority weight.
type PackWeight struct {
ID string `yaml:"id"`
Weight int `yaml:"weight"`
}
// LoadProfile reads a profile YAML file.
func LoadProfile(path string) (*Profile, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var p Profile
return &p, yaml.Unmarshal(data, &p)
}
// LoadProfiles reads all *.yaml files from profilesDir.
func LoadProfiles(profilesDir string) ([]*Profile, error) {
entries, err := os.ReadDir(profilesDir)
if err != nil {
return nil, err
}
var profiles []*Profile
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" {
continue
}
p, err := LoadProfile(filepath.Join(profilesDir, e.Name()))
if err != nil {
return nil, err
}
profiles = append(profiles, p)
}
return profiles, nil
}
// ApplyWeights returns packs sorted by the profile's weight declarations.
// Packs not mentioned by the profile retain their base weight.
func ApplyWeights(packs []*Pack, profile *Profile) []*Pack {
if profile == nil {
return packs
}
weightMap := make(map[string]int)
for _, pw := range profile.Packs {
weightMap[pw.ID] = pw.Weight
}
result := make([]*Pack, len(packs))
copy(result, packs)
sort.SliceStable(result, func(i, j int) bool {
wi := weightMap[result[i].ID]
if wi == 0 {
wi = result[i].Weight
}
wj := weightMap[result[j].ID]
if wj == 0 {
wj = result[j].Weight
}
return wi > wj
})
return result
}- [ ] Step 4.5: Write failing tests for Profile
// internal/content/profile_test.go
package content_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SAP-samples/sap-devs-cli/internal/content"
)
func TestApplyWeights_OrdersPacksByProfileWeight(t *testing.T) {
packs := []*content.Pack{
{ID: "abap", Weight: 90},
{ID: "cap", Weight: 100},
{ID: "fiori", Weight: 70},
}
profile := &content.Profile{
Packs: []content.PackWeight{
{ID: "fiori", Weight: 200},
{ID: "cap", Weight: 50},
},
}
ordered := content.ApplyWeights(packs, profile)
assert.Equal(t, "fiori", ordered[0].ID)
assert.Equal(t, "abap", ordered[1].ID)
assert.Equal(t, "cap", ordered[2].ID)
}
func TestApplyWeights_NilProfileReturnsUnchanged(t *testing.T) {
packs := []*content.Pack{{ID: "cap"}, {ID: "abap"}}
result := content.ApplyWeights(packs, nil)
assert.Equal(t, "cap", result[0].ID)
}
func TestLoadProfiles_ReadsAllYAML(t *testing.T) {
dir := t.TempDir()
yaml1 := "id: cap-developer\nname: CAP Developer\npacks:\n - id: cap\n weight: 100\n"
yaml2 := "id: abap-developer\nname: ABAP Developer\npacks:\n - id: abap\n weight: 100\n"
writeFile(t, filepath.Join(dir, "cap-developer.yaml"), yaml1)
writeFile(t, filepath.Join(dir, "abap-developer.yaml"), yaml2)
profiles, err := content.LoadProfiles(dir)
require.NoError(t, err)
assert.Len(t, profiles, 2)
}
func writeFile(t *testing.T, path, data string) {
t.Helper()
require.NoError(t, os.WriteFile(path, []byte(data), 0644))
}- [ ] Step 4.6: Write
internal/content/loader.go
package content
import (
"os"
"path/filepath"
)
// ContentLoader merges packs from multiple layers: official → company → user → project.
type ContentLoader struct {
OfficialDir string
CompanyDir string // empty if not configured
UserDir string
ProjectDir string // empty if not in a project
}
// LoadPacks loads and merges packs from all configured layers,
// then orders them by the given profile. Later layers override earlier ones by pack ID.
func (cl *ContentLoader) LoadPacks(profile *Profile) ([]*Pack, error) {
packMap := make(map[string]*Pack)
for _, dir := range cl.activeDirs() {
packsDir := filepath.Join(dir, "packs")
entries, err := os.ReadDir(packsDir)
if os.IsNotExist(err) {
continue
}
if err != nil {
return nil, err
}
for _, e := range entries {
if !e.IsDir() {
continue
}
pack, err := LoadPack(filepath.Join(packsDir, e.Name()))
if err != nil {
return nil, err
}
packMap[pack.ID] = pack // later layers override
}
}
packs := make([]*Pack, 0, len(packMap))
for _, p := range packMap {
packs = append(packs, p)
}
return ApplyWeights(packs, profile), nil
}
// LoadProfiles loads profiles from all configured layers (later layers override).
func (cl *ContentLoader) LoadProfiles() ([]*Profile, error) {
profileMap := make(map[string]*Profile)
for _, dir := range cl.activeDirs() {
profilesDir := filepath.Join(dir, "profiles")
profiles, err := LoadProfiles(profilesDir)
if os.IsNotExist(err) {
continue
}
if err != nil {
return nil, err
}
for _, p := range profiles {
profileMap[p.ID] = p
}
}
result := make([]*Profile, 0, len(profileMap))
for _, p := range profileMap {
result = append(result, p)
}
return result, nil
}
// FindProfile returns a profile by ID from all layers, or nil if not found.
func (cl *ContentLoader) FindProfile(id string) (*Profile, error) {
profiles, err := cl.LoadProfiles()
if err != nil {
return nil, err
}
for _, p := range profiles {
if p.ID == id {
return p, nil
}
}
return nil, nil
}
func (cl *ContentLoader) activeDirs() []string {
dirs := []string{cl.OfficialDir}
if cl.CompanyDir != "" {
dirs = append(dirs, cl.CompanyDir)
}
if cl.UserDir != "" {
dirs = append(dirs, cl.UserDir)
}
if cl.ProjectDir != "" {
dirs = append(dirs, cl.ProjectDir)
}
return dirs
}- [ ] Step 4.7: Write loader tests
// internal/content/loader_test.go
package content_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SAP-samples/sap-devs-cli/internal/content"
)
func TestContentLoader_LoadPacks_MergesLayers(t *testing.T) {
official := makeTempPacksDir(t, map[string]string{
"cap": "id: cap\nname: CAP Official\nweight: 100\n",
"abap": "id: abap\nname: ABAP\nweight: 90\n",
})
company := makeTempPacksDir(t, map[string]string{
"cap": "id: cap\nname: CAP Company Override\nweight: 100\n",
})
loader := &content.ContentLoader{
OfficialDir: official,
CompanyDir: company,
}
packs, err := loader.LoadPacks(nil)
require.NoError(t, err)
assert.Len(t, packs, 2)
capPack := findPack(packs, "cap")
require.NotNil(t, capPack)
assert.Equal(t, "CAP Company Override", capPack.Name)
}
func findPack(packs []*content.Pack, id string) *content.Pack {
for _, p := range packs {
if p.ID == id {
return p
}
}
return nil
}
func makeTempPacksDir(t *testing.T, packs map[string]string) string {
t.Helper()
root := t.TempDir()
packsDir := filepath.Join(root, "packs")
require.NoError(t, os.MkdirAll(packsDir, 0755))
for id, yaml := range packs {
packDir := filepath.Join(packsDir, id)
require.NoError(t, os.MkdirAll(packDir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(packDir, "pack.yaml"), []byte(yaml), 0644))
}
return root
}- [ ] Step 4.8: Run all content tests
go test ./internal/content/... -vExpected: all PASS.
- [ ] Step 4.9: Commit
git add internal/content/
git commit -m "feat: add content pack/profile loader with layer merging"Task 5: Tip Selection
Files:
Create:
internal/content/tip.goCreate:
internal/content/tip_test.go[ ] Step 5.1: Write the failing tests
// internal/content/tip_test.go
package content_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SAP-samples/sap-devs-cli/internal/content"
)
func TestSelectTip_ReturnsATip(t *testing.T) {
packs := []*content.Pack{
{ID: "cap", Tips: []content.Tip{
{Title: "CAP tip 1", Content: "Use cds watch", Tags: []string{"cap"}},
{Title: "CAP tip 2", Content: "Use CQL", Tags: []string{"cap", "nodejs"}},
}},
}
tip, err := content.SelectTip(packs, []string{"cap"}, 0)
require.NoError(t, err)
assert.NotEmpty(t, tip.Title)
}
func TestSelectTip_FiltersByProfileTags(t *testing.T) {
packs := []*content.Pack{
{ID: "cap", Tips: []content.Tip{
{Title: "CAP tip", Content: "For CAP", Tags: []string{"cap"}},
}},
{ID: "abap", Tips: []content.Tip{
{Title: "ABAP tip", Content: "For ABAP", Tags: []string{"abap"}},
}},
}
// Only request cap-tagged tips
for i := 0; i < 20; i++ {
tip, err := content.SelectTip(packs, []string{"cap"}, int64(i))
require.NoError(t, err)
assert.Contains(t, tip.Tags, "cap")
assert.Equal(t, "CAP tip", tip.Title)
}
}
func TestSelectTip_EmptyPoolReturnsError(t *testing.T) {
_, err := content.SelectTip(nil, []string{"cap"}, 0)
assert.Error(t, err)
}
func TestSelectTip_SameSeedReturnsSameTip(t *testing.T) {
packs := []*content.Pack{
{ID: "cap", Tips: []content.Tip{
{Title: "tip A", Tags: []string{"cap"}},
{Title: "tip B", Tags: []string{"cap"}},
}},
}
tip1, _ := content.SelectTip(packs, []string{"cap"}, 42)
tip2, _ := content.SelectTip(packs, []string{"cap"}, 42)
assert.Equal(t, tip1.Title, tip2.Title)
}- [ ] Step 5.2: Run to verify failure
go test ./internal/content/... -v -run TestSelectTipExpected: compile error — SelectTip not defined.
- [ ] Step 5.3: Write
internal/content/tip.go
package content
import (
"errors"
"math/rand"
)
// SelectTip picks a tip from the filtered pool using the given seed.
// profileTags narrows the pool to tips that share at least one tag with the profile.
// seed 0 means "today" (use time.Now().YearDay() * year as seed for daily consistency).
func SelectTip(packs []*Pack, profileTags []string, seed int64) (*Tip, error) {
tagSet := make(map[string]bool, len(profileTags))
for _, t := range profileTags {
tagSet[t] = true
}
var pool []Tip
for _, pack := range packs {
for _, tip := range pack.Tips {
if len(profileTags) == 0 {
pool = append(pool, tip)
continue
}
for _, tag := range tip.Tags {
if tagSet[tag] {
pool = append(pool, tip)
break
}
}
}
}
if len(pool) == 0 {
return nil, errors.New("no tips available for the current profile tags")
}
r := rand.New(rand.NewSource(seed)) //nolint:gosec // non-cryptographic selection
idx := r.Intn(len(pool))
return &pool[idx], nil
}- [ ] Step 5.4: Run tests to verify they pass
go test ./internal/content/... -vExpected: all PASS.
- [ ] Step 5.5: Commit
git add internal/content/tip.go internal/content/tip_test.go
git commit -m "feat: add profile-aware tip selection"Task 6: Sync Engine
Files:
Create:
internal/sync/fetcher.goCreate:
internal/sync/fetcher_test.goCreate:
internal/sync/engine.goCreate:
internal/sync/engine_test.go[ ] Step 6.1: Write failing fetcher test
// internal/sync/fetcher_test.go
package sync_test
import (
"archive/zip"
"bytes"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
sapSync "github.com/SAP-samples/sap-devs-cli/internal/sync"
)
func TestFetcher_DownloadsAndExtractsZip(t *testing.T) {
// Create an in-memory zip with one file
buf := new(bytes.Buffer)
w := zip.NewWriter(buf)
f, _ := w.Create("packs/cap/pack.yaml")
f.Write([]byte("id: cap\nname: CAP\n"))
w.Close()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/zip")
w.Write(buf.Bytes())
}))
defer srv.Close()
dest := t.TempDir()
err := sapSync.FetchArchive(srv.URL, dest)
require.NoError(t, err)
data, err := os.ReadFile(filepath.Join(dest, "packs", "cap", "pack.yaml"))
require.NoError(t, err)
assert.Contains(t, string(data), "id: cap")
}- [ ] Step 6.2: Write
internal/sync/fetcher.go
package sync
import (
"archive/zip"
"bytes"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
// FetchArchive downloads a zip archive from url and extracts it to destDir.
// Existing files are overwritten; directories are created as needed.
func FetchArchive(url, destDir string) error {
resp, err := http.Get(url) //nolint:gosec // URL comes from user config, not untrusted input
if err != nil {
return fmt.Errorf("fetch %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("fetch %s: HTTP %d", url, resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return fmt.Errorf("open zip: %w", err)
}
// Strip one leading path component (GitHub archives include repo-name-sha/ prefix)
strip := zipStripPrefix(zr)
for _, f := range zr.File {
name := strings.TrimPrefix(f.Name, strip)
if name == "" || strings.HasSuffix(name, "/") {
continue
}
dest := filepath.Join(destDir, filepath.FromSlash(name))
if err := extractFile(f, dest); err != nil {
return err
}
}
return nil
}
func zipStripPrefix(zr *zip.Reader) string {
for _, f := range zr.File {
parts := strings.SplitN(f.Name, "/", 2)
if len(parts) == 2 {
return parts[0] + "/"
}
}
return ""
}
func extractFile(f *zip.File, dest string) error {
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, rc)
return err
}- [ ] Step 6.3: Write failing engine tests
// internal/sync/engine_test.go
package sync_test
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
sapSync "github.com/SAP-samples/sap-devs-cli/internal/sync"
)
func TestEngine_IsStale_TrueWhenNeverSynced(t *testing.T) {
dir := t.TempDir()
eng := sapSync.NewEngine(dir, 24*time.Hour, nil)
assert.True(t, eng.IsStale("tips"))
}
func TestEngine_IsStale_FalseWhenRecentlySynced(t *testing.T) {
dir := t.TempDir()
eng := sapSync.NewEngine(dir, 24*time.Hour, nil)
require.NoError(t, eng.MarkSynced("tips"))
assert.False(t, eng.IsStale("tips"))
}
func TestEngine_IsStale_TrueWhenExpired(t *testing.T) {
dir := t.TempDir()
// Write a timestamp 2 days ago
ts := map[string]time.Time{"tips": time.Now().Add(-48 * time.Hour)}
data, _ := json.Marshal(ts)
os.WriteFile(filepath.Join(dir, "sync-state.json"), data, 0600)
eng := sapSync.NewEngine(dir, 24*time.Hour, nil)
assert.True(t, eng.IsStale("tips"))
}
func TestEngine_IsStale_HonoursPerCategoryTTL(t *testing.T) {
dir := t.TempDir()
// resources was synced 2 days ago
ts := map[string]time.Time{"resources": time.Now().Add(-48 * time.Hour)}
data, _ := json.Marshal(ts)
os.WriteFile(filepath.Join(dir, "sync-state.json"), data, 0600)
// 168h TTL for resources — 2 days is not stale
eng := sapSync.NewEngine(dir, 24*time.Hour, map[string]time.Duration{"resources": 168 * time.Hour})
assert.False(t, eng.IsStale("resources"))
// But tips with default 24h TTL and no sync record is stale
assert.True(t, eng.IsStale("tips"))
}- [ ] Step 6.4: Write
internal/sync/engine.go
package sync
import (
"encoding/json"
"os"
"path/filepath"
"time"
)
// Engine tracks per-category sync timestamps and determines staleness.
type Engine struct {
stateDir string
ttls map[string]time.Duration
defaultTTL time.Duration
}
// NewEngine creates an Engine that stores state in stateDir.
// ttls maps category name → TTL; categories not in the map use defaultTTL.
func NewEngine(stateDir string, defaultTTL time.Duration, ttls map[string]time.Duration) *Engine {
return &Engine{stateDir: stateDir, defaultTTL: defaultTTL, ttls: ttls}
}
// IsStale reports whether the given category needs a refresh.
func (e *Engine) IsStale(category string) bool {
ttl := e.defaultTTL
if t, ok := e.ttls[category]; ok && t > 0 {
ttl = t
}
state := e.loadState()
last, ok := state[category]
if !ok {
return true
}
return time.Since(last) > ttl
}
// MarkSynced records the current time as the last sync time for category.
func (e *Engine) MarkSynced(category string) error {
state := e.loadState()
state[category] = time.Now()
return e.saveState(state)
}
func (e *Engine) loadState() map[string]time.Time {
state := make(map[string]time.Time)
data, err := os.ReadFile(filepath.Join(e.stateDir, "sync-state.json"))
if err != nil {
return state
}
_ = json.Unmarshal(data, &state)
return state
}
func (e *Engine) saveState(state map[string]time.Time) error {
if err := os.MkdirAll(e.stateDir, 0755); err != nil {
return err
}
data, err := json.Marshal(state)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(e.stateDir, "sync-state.json"), data, 0600)
}- [ ] Step 6.5: Run all sync tests
go test ./internal/sync/... -vExpected: all PASS.
- [ ] Step 6.6: Commit
git add internal/sync/
git commit -m "feat: add HTTP archive fetcher and TTL-based sync engine"Task 7: Initial Content
Files: Create the official knowledge base under content/. This is real content, not stub files.
- [ ] Step 7.1: Create
content/packs/cap/pack.yaml
id: cap
name: SAP Cloud Application Programming Model
description: Node.js and Java framework for building cloud-native business applications on BTP
tags: [cloud, btp, nodejs, java, odata, cds]
profiles: [cap-developer, btp-developer]
weight: 100- [ ] Step 7.2: Create
content/packs/cap/context.md
## SAP CAP (Cloud Application Programming Model)
CAP is SAP's primary framework for building cloud-native business applications on SAP BTP.
It uses CDS (Core Data Services) for data and service definitions, Node.js or Java for service logic.
### Key Tools
- `@sap/cds-dk` — CAP development kit (CLI: `cds`)
- `cds watch` — local dev server with live reload
- `cds deploy` — deploy to database / cloud
### CDS Data Modelling (entity-relationship)
```cds
entity Books : managed {
key ID : Integer;
title : localized String(111);
author : Association to Authors;
}Service Definition
service CatalogService @(path:'/browse') {
@readonly entity Books as SELECT from my.Books;
}Best Practices
- Define entities in
db/schema.cds, services insrv/*.cds - Use
cds.qlfor type-safe CQL queries - Leverage built-in authentication via
@requiresannotations - Always run
cds lintbefore committing
- [ ] **Step 7.3: Create `content/packs/cap/tips.md`**
```markdown
## Use cds watch for local development
Tags: cap,nodejs
Run `cds watch` instead of `node server.js` — it reloads on every file change and logs all requests.
## Define managed entities for audit fields
Tags: cap,cds
Add `: managed` to your entities to get `createdAt`, `createdBy`, `modifiedAt`, `modifiedBy` for free.
## Use @readonly in service layer
Tags: cap,odata,security
Expose `@readonly` in the service layer rather than restricting at DB level — keeps schema flexible.
## Check CAP version compatibility
Tags: cap,versions
Run `cds version` to see your full CAP stack versions. Mismatched `@sap/cds` and `@sap/cds-dk` cause subtle errors.- [ ] Step 7.4: Create
content/packs/cap/resources.yaml
- id: cap/docs-official
title: CAP Documentation
url: https://cap.cloud.sap/docs
type: official-docs
tags: [reference, getting-started]
- id: cap/samples-github
title: CAP Samples on GitHub
url: https://github.com/SAP-samples/cloud-cap-samples
type: sample
tags: [examples, reference]
- id: cap/community-forum
title: SAP Community — CAP
url: https://community.sap.com/t5/technology-q-a/questions-related-to-tag/ta-p/9850/tag-id/73555000100800000895
type: community
tags: [q&a, help]- [ ] Step 7.5: Create
content/packs/cap/tools.yaml
- id: nodejs
name: Node.js
required: ">=18.0.0"
detect:
command: "node --version"
pattern: "v(\\d+\\.\\d+\\.\\d+)"
install:
windows: "winget install OpenJS.NodeJS.LTS"
macos: "brew install node@20"
linux: "nvm install 20"
docs: "https://nodejs.org"
- id: cds-dk
name: SAP CDS CLI
required: ">=7.0.0"
detect:
command: "cds --version"
pattern: "@sap/cds: (\\d+\\.\\d+\\.\\d+)"
install:
all: "npm install -g @sap/cds-dk"
docs: "https://cap.cloud.sap"- [ ] Step 7.6: Create
content/packs/cap/mcp.yaml(placeholder — implemented in Plan 2)
# MCP server definitions for CAP — implemented in Plan 2- [ ] Step 7.7: Create minimal content for
abapandbtp-corepacks following the same pattern as cap (pack.yaml, context.md, tips.md, resources.yaml, tools.yaml). Content can be brief in Plan 1 — these packs will be fleshed out in Plan 4.
Minimum for each pack:
pack.yaml— id, name, description, tags, profiles, weightcontext.md— 200–400 word overview of the domain with key conceptstips.md— at least 3 tips with Tags linesresources.yaml— at least 2 official linkstools.yaml— key tools for that domain[ ] Step 7.8: Create
content/profiles/cap-developer.yaml
id: cap-developer
name: CAP Developer
description: Building cloud-native apps with SAP CAP on BTP
packs:
- id: cap
weight: 100
- id: btp-core
weight: 80
- id: fiori
weight: 60
- id: ai-joule
weight: 40
- id: integration
weight: 20
- id: abap
weight: 10
tip_tags: [cap, nodejs, odata, cds, btp][ ] Step 7.9: Create
content/profiles/abap-developer.yamlandcontent/profiles/btp-developer.yamlwith appropriate pack weights.[ ] Step 7.10: Create adapter placeholder stubs
content/adapters/claude-code.yaml:
# Adapter definition — implemented in Plan 2
id: claude-code
name: Claude Code
type: file-injectcontent/adapters/cursor.yaml:
# Adapter definition — implemented in Plan 2
id: cursor
name: Cursor
type: file-inject- [ ] Step 7.11: Commit
git add content/
git commit -m "content: add initial CAP, ABAP, BTP-core packs and developer profiles"Task 8: Config Command
Files:
Create:
cmd/config.go[ ] Step 8.1: Write
cmd/config.go
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/SAP-samples/sap-devs-cli/internal/config"
"github.com/SAP-samples/sap-devs-cli/internal/xdg"
)
var configCmd = &cobra.Command{
Use: "config",
Short: "Manage sap-devs configuration",
}
var configShowCmd = &cobra.Command{
Use: "show",
Short: "Display current configuration",
RunE: func(cmd *cobra.Command, args []string) error {
paths, err := xdg.New()
if err != nil {
return err
}
cfg, err := config.Load(paths.ConfigDir)
if err != nil {
return err
}
fmt.Printf("company_repo: %s\n", cfg.CompanyRepo)
fmt.Printf("sync.tips: %s\n", cfg.Sync.Tips)
fmt.Printf("sync.tools: %s\n", cfg.Sync.Tools)
fmt.Printf("sync.disabled: %v\n", cfg.Sync.Disabled)
return nil
},
}
var configSetCmd = &cobra.Command{
Use: "set <key> <value>",
Short: "Set a configuration value",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
paths, err := xdg.New()
if err != nil {
return err
}
cfg, err := config.Load(paths.ConfigDir)
if err != nil {
return err
}
switch args[0] {
case "company_repo":
cfg.CompanyRepo = args[1]
default:
return fmt.Errorf("unknown config key: %s", args[0])
}
if err := cfg.Save(paths.ConfigDir); err != nil {
return err
}
fmt.Printf("Set %s = %s\n", args[0], args[1])
return nil
},
}
var configCompanyCmd = &cobra.Command{
Use: "company <git-url>",
Short: "Configure the company content repo URL",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
paths, err := xdg.New()
if err != nil {
return err
}
cfg, err := config.Load(paths.ConfigDir)
if err != nil {
return err
}
cfg.CompanyRepo = args[0]
if err := cfg.Save(paths.ConfigDir); err != nil {
return err
}
fmt.Printf("Company repo set to: %s\n", args[0])
return nil
},
}
func init() {
configCmd.AddCommand(configShowCmd, configSetCmd, configCompanyCmd)
rootCmd.AddCommand(configCmd)
}- [ ] Step 8.2: Verify it builds and runs
go build ./... && ./sap-devs config showExpected: prints default config values.
- [ ] Step 8.3: Commit
git add cmd/config.go
git commit -m "feat: add config show/set/company commands"Task 9: Profile Command
Files:
Create:
cmd/profile.go[ ] Step 9.1: Write
cmd/profile.go
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/SAP-samples/sap-devs-cli/internal/config"
"github.com/SAP-samples/sap-devs-cli/internal/content"
"github.com/SAP-samples/sap-devs-cli/internal/xdg"
)
var profileCmd = &cobra.Command{
Use: "profile",
Short: "Manage your developer profile",
}
var profileListCmd = &cobra.Command{
Use: "list",
Short: "List available developer profiles",
RunE: func(cmd *cobra.Command, args []string) error {
loader, err := newContentLoader()
if err != nil {
return err
}
profiles, err := loader.LoadProfiles()
if err != nil {
return err
}
for _, p := range profiles {
fmt.Printf(" %-25s %s\n", p.ID, p.Description)
}
return nil
},
}
var profileSetCmd = &cobra.Command{
Use: "set <profile-id>",
Short: "Set your active developer profile",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
paths, err := xdg.New()
if err != nil {
return err
}
loader, err := newContentLoader()
if err != nil {
return err
}
p, err := loader.FindProfile(args[0])
if err != nil {
return err
}
if p == nil {
return fmt.Errorf("profile %q not found — run 'sap-devs profile list' to see options", args[0])
}
if err := config.SaveProfile(paths.ConfigDir, &config.Profile{ID: p.ID}); err != nil {
return err
}
fmt.Printf("Profile set to: %s (%s)\n", p.ID, p.Name)
return nil
},
}
var profileShowCmd = &cobra.Command{
Use: "show",
Short: "Show your current profile and pack weights",
RunE: func(cmd *cobra.Command, args []string) error {
paths, err := xdg.New()
if err != nil {
return err
}
saved, err := config.LoadProfile(paths.ConfigDir)
if err != nil {
return err
}
if saved.ID == "" {
fmt.Println("No profile set. Run 'sap-devs profile list' to see options.")
return nil
}
loader, err := newContentLoader()
if err != nil {
return err
}
p, err := loader.FindProfile(saved.ID)
if err != nil {
return err
}
if p == nil {
fmt.Printf("Profile: %s (definition not found in content)\n", saved.ID)
return nil
}
fmt.Printf("Profile: %s — %s\n\n", p.Name, p.Description)
fmt.Println("Pack weights:")
for _, pw := range p.Packs {
fmt.Printf(" %-20s %d\n", pw.ID, pw.Weight)
}
return nil
},
}
func init() {
profileCmd.AddCommand(profileListCmd, profileSetCmd, profileShowCmd)
rootCmd.AddCommand(profileCmd)
}- [ ] Step 9.2: Add
newContentLoader()helper tocmd/root.go
This helper is shared across commands. Add to the bottom of cmd/root.go:
// newContentLoader constructs a ContentLoader using the platform paths and config.
func newContentLoader() (*content.ContentLoader, error) {
paths, err := xdg.New()
if err != nil {
return nil, err
}
cfg, err := config.Load(paths.ConfigDir)
if err != nil {
return nil, err
}
loader := &content.ContentLoader{
OfficialDir: filepath.Join(paths.CacheDir, "official"),
UserDir: paths.DataDir,
}
if cfg.CompanyRepo != "" {
loader.CompanyDir = filepath.Join(paths.CacheDir, "company")
}
// Check for per-project .sap-devs dir
if _, err := os.Stat(".sap-devs"); err == nil {
loader.ProjectDir = ".sap-devs"
}
return loader, nil
}Also add the necessary imports: "os", "path/filepath", plus the internal packages.
Note: Until sap-devs sync has been run, the cache will be empty. During development, seed it by copying the content/ directory to the cache:
mkdir -p ~/.cache/sap-devs/official
cp -r content/* ~/.cache/sap-devs/official/(On Windows: %LOCALAPPDATA%\sap-devs\cache\official)
- [ ] Step 9.3: Build and manually test
go build -o sap-devs ./...
./sap-devs profile list
./sap-devs profile set cap-developer
./sap-devs profile showExpected: lists profiles, sets profile, shows pack weights.
- [ ] Step 9.4: Commit
git add cmd/profile.go cmd/root.go
git commit -m "feat: add profile list/set/show commands"Task 10: Sync Command
Files:
Create:
cmd/sync.go[ ] Step 10.1: Write
cmd/sync.go
The sync command fetches the official content repo (and company repo if configured). The official repo archive URL follows GitHub Releases convention: https://<host>/<owner>/<repo>/archive/refs/heads/main.zip.
package cmd
import (
"fmt"
"path/filepath"
"time"
"github.com/spf13/cobra"
"github.com/SAP-samples/sap-devs-cli/internal/config"
sapSync "github.com/SAP-samples/sap-devs-cli/internal/sync"
"github.com/SAP-samples/sap-devs-cli/internal/xdg"
)
const officialRepoArchive = "https://github.com/SAP-samples/sap-devs-cli/archive/refs/heads/main.zip"
var syncForce bool
var syncCategory string
var syncCmd = &cobra.Command{
Use: "sync",
Short: "Pull latest SAP developer content",
Long: `Syncs content from the official repo (and company repo if configured). Respects per-category TTLs unless --force is set.`,
RunE: func(cmd *cobra.Command, args []string) error {
paths, err := xdg.New()
if err != nil {
return err
}
cfg, err := config.Load(paths.ConfigDir)
if err != nil {
return err
}
if cfg.Sync.Disabled {
fmt.Println("Sync is disabled in config.")
return nil
}
categories := allCategories()
if syncCategory != "" {
categories = []string{syncCategory}
}
officialCache := filepath.Join(paths.CacheDir, "official")
ttls := map[string]time.Duration{
"tips": cfg.Sync.Tips,
"tools": cfg.Sync.Tools,
"advocates": cfg.Sync.Advocates,
"resources": cfg.Sync.Resources,
"context": cfg.Sync.Context,
"mcp": cfg.Sync.MCP,
}
engine := sapSync.NewEngine(paths.CacheDir, 24*time.Hour, ttls)
updated := []string{}
for _, cat := range categories {
if !syncForce && !engine.IsStale(cat) {
continue
}
fmt.Printf("Syncing %s...\n", cat)
if err := sapSync.FetchArchive(officialRepoArchive, officialCache); err != nil {
return fmt.Errorf("sync %s: %w", cat, err)
}
if err := engine.MarkSynced(cat); err != nil {
return err
}
updated = append(updated, cat)
}
if len(updated) == 0 {
fmt.Println("All content is up to date.")
} else {
fmt.Printf("Updated: %v\n", updated)
}
// Sync company repo if configured
if cfg.CompanyRepo != "" {
companyCache := filepath.Join(paths.CacheDir, "company")
companyArchive := cfg.CompanyRepo + "/archive/refs/heads/main.zip"
fmt.Println("Syncing company repo...")
if err := sapSync.FetchArchive(companyArchive, companyCache); err != nil {
fmt.Printf("Warning: company repo sync failed: %v\n", err)
}
}
return nil
},
}
func allCategories() []string {
return []string{"tips", "tools", "resources", "context", "mcp", "advocates"}
}
func init() {
syncCmd.Flags().BoolVar(&syncForce, "force", false, "Re-sync all categories regardless of TTL")
syncCmd.Flags().StringVar(&syncCategory, "category", "", "Sync a single category only")
rootCmd.AddCommand(syncCmd)
}- [ ] Step 10.2: Build and test manually
go build -o sap-devs ./...
./sap-devs sync --forceExpected: downloads content to cache, prints "Updated: [tips tools resources context mcp advocates]".
- [ ] Step 10.3: Commit
git add cmd/sync.go
git commit -m "feat: add sync command with TTL-aware category refresh"Task 11: Tip Command
Files:
Create:
cmd/tip.go[ ] Step 11.1: Write
cmd/tip.go
package cmd
import (
"fmt"
"time"
"github.com/charmbracelet/glamour"
"github.com/spf13/cobra"
"github.com/SAP-samples/sap-devs-cli/internal/config"
"github.com/SAP-samples/sap-devs-cli/internal/content"
"github.com/SAP-samples/sap-devs-cli/internal/xdg"
)
var tipCmd = &cobra.Command{
Use: "tip",
Short: "Print a SAP developer tip (add to your shell profile)",
RunE: func(cmd *cobra.Command, args []string) error {
paths, err := xdg.New()
if err != nil {
return err
}
profileCfg, err := config.LoadProfile(paths.ConfigDir)
if err != nil {
return err
}
loader, err := newContentLoader()
if err != nil {
return err
}
var activeProfile *content.Profile
if profileCfg.ID != "" {
activeProfile, err = loader.FindProfile(profileCfg.ID)
if err != nil {
return err
}
}
packs, err := loader.LoadPacks(activeProfile)
if err != nil {
return err
}
var tipTags []string
if activeProfile != nil {
tipTags = activeProfile.TipTags
}
// Use year*day as seed for daily consistency
now := time.Now()
seed := int64(now.Year()*1000 + now.YearDay())
tip, err := content.SelectTip(packs, tipTags, seed)
if err != nil {
return err
}
md := fmt.Sprintf("## 💡 %s\n\n%s\n", tip.Title, tip.Content)
rendered, err := glamour.Render(md, "dark")
if err != nil {
// Fallback to plain output if glamour fails
fmt.Printf("💡 %s\n\n%s\n", tip.Title, tip.Content)
return nil
}
fmt.Print(rendered)
return nil
},
}
func init() {
rootCmd.AddCommand(tipCmd)
}- [ ] Step 11.2: Build and run
go build -o sap-devs ./...
./sap-devs tipExpected: a rendered Markdown tip printed to terminal.
- [ ] Step 11.3: Print shell profile instructions
Verify the output looks good in a real terminal, then document the shell hook:
# Add to ~/.zshrc or ~/.bashrc:
sap-devs tip
# PowerShell $PROFILE:
sap-devs tip- [ ] Step 11.4: Commit
git add cmd/tip.go
git commit -m "feat: add tip command with glamour rendering and daily seed"Task 12: Init Command
Files:
Create:
cmd/init.go[ ] Step 12.1: Write
cmd/init.go
init is an interactive wizard. It must: sync content, prompt for profile, save profile, offer to add sap-devs tip to shell profile.
package cmd
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/SAP-samples/sap-devs-cli/internal/config"
"github.com/SAP-samples/sap-devs-cli/internal/content"
"github.com/SAP-samples/sap-devs-cli/internal/xdg"
)
var initCmd = &cobra.Command{
Use: "init",
Short: "First-time setup wizard",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Welcome to sap-devs — your AI-first SAP developer toolkit.")
fmt.Println()
// Step 1: Sync content
fmt.Println("Step 1/3: Downloading SAP developer content...")
if err := runSync(true); err != nil {
fmt.Printf("Warning: content sync failed (%v). Continuing with any cached content.\n", err)
}
paths, err := xdg.New()
if err != nil {
return err
}
// Step 2: Choose profile
fmt.Println("\nStep 2/3: What kind of SAP developer are you?")
loader, err := newContentLoader()
if err != nil {
return err
}
profiles, err := loader.LoadProfiles()
if err != nil {
return err
}
for i, p := range profiles {
fmt.Printf(" [%d] %-25s %s\n", i+1, p.ID, p.Description)
}
fmt.Print("\nEnter number (or press Enter to skip): ")
choice := readLine()
if choice != "" {
idx := 0
fmt.Sscanf(choice, "%d", &idx)
if idx >= 1 && idx <= len(profiles) {
chosen := profiles[idx-1]
if err := config.SaveProfile(paths.ConfigDir, &config.Profile{ID: chosen.ID}); err != nil {
return err
}
fmt.Printf("Profile set to: %s\n", chosen.Name)
}
}
// Step 3: Shell profile hook
fmt.Println("\nStep 3/3: Add SAP tip to your terminal startup?")
fmt.Println(" This adds 'sap-devs tip' to your shell profile so you see a tip each time you open a terminal.")
fmt.Print(" Add it? [y/N]: ")
if strings.ToLower(strings.TrimSpace(readLine())) == "y" {
if err := addShellHook(); err != nil {
fmt.Printf(" Could not auto-add hook: %v\n Add 'sap-devs tip' to your shell profile manually.\n", err)
} else {
fmt.Println(" Added. Restart your terminal to see your first tip.")
}
}
fmt.Println("\nSetup complete! Run 'sap-devs --help' to explore all commands.")
fmt.Println("Next: run 'sap-devs inject' (Plan 2) to inject SAP context into your AI tools.")
return nil
},
}
func runSync(force bool) error {
syncForce = force
return syncCmd.RunE(syncCmd, nil)
}
func readLine() string {
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan()
return strings.TrimSpace(scanner.Text())
}
func addShellHook() error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
// Try common shell rc files
candidates := []string{".zshrc", ".bashrc", ".bash_profile"}
for _, rc := range candidates {
path := home + "/" + rc
if _, err := os.Stat(path); err == nil {
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString("\n# SAP developer tips\nsap-devs tip\n")
return err
}
}
return fmt.Errorf("no shell rc file found")
}
// detectInstalledAITools returns a list of AI tool IDs that appear to be installed.
// Used in Plan 2 to know which tools to inject context into during init.
func detectInstalledAITools() []string {
var found []string
checks := map[string]string{
"claude-code": os.Getenv("HOME") + "/.claude",
"cursor": os.Getenv("HOME") + "/.cursor",
}
for id, path := range checks {
if _, err := os.Stat(path); err == nil {
found = append(found, id)
}
}
return found
}
func init() {
rootCmd.AddCommand(initCmd)
}- [ ] Step 12.2: Build and run in a test environment
go build -o sap-devs ./...
./sap-devs initWalk through the wizard manually. Verify: content downloads, profile saves, shell hook offer appears.
- [ ] Step 12.3: Final build and test sweep
go test ./...
go build ./...
./sap-devs --help
./sap-devs profile list
./sap-devs tipExpected: all tests pass, binary works end-to-end.
- [ ] Step 12.4: Final commit
git add cmd/init.go
git commit -m "feat: add init wizard with sync, profile selection, and shell hook"Verification
End-to-end smoke test for Plan 1:
# Build
go build -o sap-devs ./...
# Sync content
./sap-devs sync --force
# Set profile
./sap-devs profile set cap-developer
# Confirm profile
./sap-devs profile show
# Get a tip
./sap-devs tip
# Config
./sap-devs config show
./sap-devs config company https://github.com/myco/sap-content
# Run tests
go test ./...All commands should complete without errors. sap-devs tip should render a styled Markdown tip in the terminal.
What's Next
- Plan 2 — AI Injection: Adapter engine,
injectcommand, file-inject/clipboard-export/mcp-wire for all known AI tools - Plan 3 — Developer Tools:
doctor,resources,mcp install,update - Plan 4 — Content Packs: Full knowledge content for all 8 domains + advocates