Learning Journeys 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 sap-devs learning command to browse, search, show, and open SAP Learning Journeys from learning.sap.com, with pack/profile integration and context injection.
Architecture: Catalog-hybrid approach. Sync downloads the full catalog JSON from learning.sap.com/service/catalog-download/json, filters to learning journeys (~351 items), and caches the index locally. The search subcommand uses the live search API (getCards) for fuzzy matching with a local fallback. Pack integration via learning.yaml per pack follows the discovery pattern: profile filters + curated refs with featured flags, resolved through the three-tier algorithm (featured → pack-referenced → profile-filtered).
Tech Stack: Go, Cobra (CLI), text/tabwriter (table output), net/http (API calls), encoding/json (serialization), pkg/browser (open URLs), charmbracelet/glamour (markdown rendering)
Spec: docs/superpowers/specs/2026-04-18-learning-journeys-design.md
Windows note: go test fails locally due to Windows Defender. Use go build ./... and go vet ./... for local validation. CI (ubuntu-latest) is the authoritative test runner.
Task 1: Types & Constants (internal/learning/types.go)
Files:
Create:
internal/learning/types.go[ ] Step 1: Create the types file
package learning
import "time"
const (
CacheTTL = 7 * 24 * time.Hour // 7 days
SearchCacheTTL = 1 * time.Hour
CatalogURL = "https://learning.sap.com/service/catalog-download/json"
SearchURL = "https://learning.sap.com/service/learning/search/getCards"
BaseURL = "https://learning.sap.com/learning-journeys/"
)
// LearningJourney is the cached index entry for a single learning journey.
type LearningJourney struct {
ObjectID string `json:"objectId"`
Title string `json:"title"`
Slug string `json:"slug"`
Description string `json:"description"`
Level string `json:"level"`
DurationHours string `json:"durationHours"`
Roles []string `json:"roles"`
Product string `json:"product"`
ProductCategory string `json:"productCategory"`
ProductSubcat string `json:"productSubcat"`
Objectives string `json:"objectives"`
AvailableFrom string `json:"availableFrom"`
URL string `json:"url"`
}
// catalogItem is the raw JSON shape from the catalog download endpoint.
type catalogItem struct {
LearningType string `json:"Learning_type"`
LearningObjectID string `json:"Learning_object_ID"`
Title string `json:"Title"`
Description string `json:"Description"`
Level string `json:"Level"`
DurationInHours string `json:"Duration_in_hours"`
Role string `json:"Role"`
Product string `json:"LSC_product"`
ProductCategory string `json:"LSC_product_category"`
ProductSubcat string `json:"LSC_product_subcategory"`
Objectives string `json:"Learning_objectives"`
AvailableFrom string `json:"Content_available_from"`
DirectLink directLink `json:"Direct_link"`
}
type directLink struct {
Hyperlink string `json:"hyperlink"`
}
// searchResponse is the envelope from the getCards search API.
type searchResponse struct {
Value searchValue `json:"value"`
}
type searchValue struct {
Results []searchResult `json:"results"`
TotalCount int `json:"totalCount"`
NextPage *int `json:"nextPage"`
}
type searchResult struct {
Title string `json:"title"`
Slug string `json:"slug"`
Description string `json:"description"`
ExperienceLevel string `json:"experienceLevel"`
Duration float64 `json:"duration"`
Roles []string `json:"roles"`
ObjType string `json:"objType"`
}- [ ] Step 2: Verify it compiles
Run: go build ./internal/learning/... Expected: success (no output)
- [ ] Step 3: Commit
git add internal/learning/types.go
git commit -m "feat(learning): add types and constants"Task 2: Cache Layer (internal/learning/cache.go)
Files:
- Create:
internal/learning/cache.go
This reuses the same generic cache pattern as internal/discovery/cache.go but with learning/-scoped paths.
- [ ] Step 1: Create the cache file
package learning
import (
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
// SaveIndex writes the learning journey index to cache.
func SaveIndex(cacheDir string, journeys []LearningJourney) error {
p := indexPath(cacheDir)
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
return err
}
b, err := json.Marshal(journeys)
if err != nil {
return err
}
return os.WriteFile(p, b, 0644)
}
// LoadIndex reads the cached learning journey index.
// Returns nil, false if the cache is missing or older than ttl.
func LoadIndex(cacheDir string, ttl time.Duration) ([]LearningJourney, bool) {
p := indexPath(cacheDir)
info, err := os.Stat(p)
if err != nil {
return nil, false
}
if ttl > 0 && time.Since(info.ModTime()) > ttl {
return nil, false
}
data, err := os.ReadFile(p)
if err != nil {
return nil, false
}
var v []LearningJourney
if err := json.Unmarshal(data, &v); err != nil {
return nil, false
}
return v, true
}
// LoadIndexStale reads the cached index ignoring TTL (offline fallback).
func LoadIndexStale(cacheDir string) ([]LearningJourney, bool) {
return LoadIndex(cacheDir, 0)
}
// IndexCacheAge returns the age of the index cache, or -1 if missing.
func IndexCacheAge(cacheDir string) time.Duration {
info, err := os.Stat(indexPath(cacheDir))
if err != nil {
return -1
}
return time.Since(info.ModTime())
}
// SaveSearchCache writes search results with a deterministic key.
func SaveSearchCache(cacheDir, key string, results []LearningJourney) error {
p := filepath.Join(cacheDir, "learning", key+".json")
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
return err
}
b, err := json.Marshal(results)
if err != nil {
return err
}
return os.WriteFile(p, b, 0644)
}
// LoadSearchCache reads cached search results if fresh.
func LoadSearchCache(cacheDir, key string) ([]LearningJourney, bool) {
p := filepath.Join(cacheDir, "learning", key+".json")
info, err := os.Stat(p)
if err != nil {
return nil, false
}
if time.Since(info.ModTime()) > SearchCacheTTL {
return nil, false
}
data, err := os.ReadFile(p)
if err != nil {
return nil, false
}
var v []LearningJourney
if err := json.Unmarshal(data, &v); err != nil {
return nil, false
}
return v, true
}
// SearchCacheKey returns a deterministic cache name for a search query.
func SearchCacheKey(query string, level, role string) string {
raw := fmt.Sprintf("%s|%s|%s", query, level, role)
h := sha256.Sum256([]byte(raw))
return fmt.Sprintf("search-%x", h[:8])
}
func indexPath(cacheDir string) string {
return filepath.Join(cacheDir, "learning", "index.json")
}- [ ] Step 2: Verify it compiles
Run: go build ./internal/learning/... Expected: success
- [ ] Step 3: Commit
git add internal/learning/cache.go
git commit -m "feat(learning): add cache layer"Task 3: Catalog Fetcher (internal/learning/catalog.go)
Files:
- Create:
internal/learning/catalog.go
Downloads the full catalog JSON, filters to Learning_type == "Learning Journey", and converts to []LearningJourney.
- [ ] Step 1: Create the catalog file
package learning
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// FetchCatalog downloads the full catalog and returns only learning journeys.
func FetchCatalog() ([]LearningJourney, error) {
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(CatalogURL)
if err != nil {
return nil, fmt.Errorf("fetch catalog: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch catalog: HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read catalog: %w", err)
}
var items []catalogItem
if err := json.Unmarshal(body, &items); err != nil {
return nil, fmt.Errorf("parse catalog: %w", err)
}
var journeys []LearningJourney
for _, item := range items {
if item.LearningType != "Learning Journey" {
continue
}
j := convertCatalogItem(item)
if j.Slug == "" {
continue
}
journeys = append(journeys, j)
}
return journeys, nil
}
func convertCatalogItem(item catalogItem) LearningJourney {
slug := extractSlug(item.DirectLink.Hyperlink)
var roles []string
for _, r := range strings.Split(item.Role, ",") {
r = strings.TrimSpace(r)
if r != "" {
roles = append(roles, r)
}
}
return LearningJourney{
ObjectID: item.LearningObjectID,
Title: item.Title,
Slug: slug,
Description: item.Description,
Level: item.Level,
DurationHours: item.DurationInHours,
Roles: roles,
Product: item.Product,
ProductCategory: item.ProductCategory,
ProductSubcat: item.ProductSubcat,
Objectives: item.Objectives,
AvailableFrom: item.AvailableFrom,
URL: item.DirectLink.Hyperlink,
}
}
func extractSlug(url string) string {
const prefix = "https://learning.sap.com/learning-journeys/"
if strings.HasPrefix(url, prefix) {
return strings.TrimSuffix(strings.TrimPrefix(url, prefix), "/")
}
return ""
}- [ ] Step 2: Verify it compiles
Run: go build ./internal/learning/... Expected: success
- [ ] Step 3: Commit
git add internal/learning/catalog.go
git commit -m "feat(learning): add catalog fetcher"Task 4: Local Search & Filters (internal/learning/search.go)
Files:
Create:
internal/learning/search.go[ ] Step 1: Create the search file
package learning
import "strings"
// Search performs case-insensitive substring matching across title, description, slug, and product.
func Search(journeys []LearningJourney, query string) []LearningJourney {
q := strings.ToLower(query)
var out []LearningJourney
for _, j := range journeys {
if strings.Contains(strings.ToLower(j.Title), q) ||
strings.Contains(strings.ToLower(j.Description), q) ||
strings.Contains(strings.ToLower(j.Slug), q) ||
strings.Contains(strings.ToLower(j.Product), q) {
out = append(out, j)
}
}
return out
}
// FilterByLevel returns journeys matching the given level (case-insensitive exact match).
func FilterByLevel(journeys []LearningJourney, level string) []LearningJourney {
l := strings.ToUpper(level)
var out []LearningJourney
for _, j := range journeys {
if strings.EqualFold(j.Level, l) {
out = append(out, j)
}
}
return out
}
// FilterByRole returns journeys where at least one role matches (case-insensitive).
func FilterByRole(journeys []LearningJourney, role string) []LearningJourney {
r := strings.ToLower(role)
var out []LearningJourney
for _, j := range journeys {
for _, jr := range j.Roles {
if strings.EqualFold(jr, r) {
out = append(out, j)
break
}
}
}
return out
}
// FindBySlug returns the first journey with the given slug, or nil.
func FindBySlug(journeys []LearningJourney, slug string) *LearningJourney {
for i := range journeys {
if journeys[i].Slug == slug {
return &journeys[i]
}
}
return nil
}- [ ] Step 2: Verify it compiles
Run: go build ./internal/learning/... Expected: success
- [ ] Step 3: Commit
git add internal/learning/search.go
git commit -m "feat(learning): add local search and filters"Task 5: Search API Client (internal/learning/api.go)
Files:
- Create:
internal/learning/api.go
Calls the getCards endpoint for fuzzy server-side search. Falls back to local search.
- [ ] Step 1: Create the API file
package learning
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
)
// SearchAPI calls the learning.sap.com search endpoint and returns results.
func SearchAPI(query string, limit int) ([]LearningJourney, error) {
if limit <= 0 {
limit = 15
}
filters := fmt.Sprintf(`{"locale":"en-US","query":"%s"}`, query)
types := `["learning-journey"]`
u := fmt.Sprintf("%s(types='%s',filters='%s',sort='',limit=%d,page=1)",
SearchURL,
url.PathEscape(types),
url.PathEscape(filters),
limit,
)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Get(u)
if err != nil {
return nil, fmt.Errorf("search API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("search API: HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read search response: %w", err)
}
var sr searchResponse
if err := json.Unmarshal(body, &sr); err != nil {
return nil, fmt.Errorf("parse search response: %w", err)
}
var results []LearningJourney
for _, r := range sr.Value.Results {
results = append(results, LearningJourney{
Title: r.Title,
Slug: r.Slug,
Description: r.Description,
Level: r.ExperienceLevel,
DurationHours: strconv.FormatFloat(r.Duration, 'f', 2, 64),
URL: BaseURL + r.Slug,
})
}
return results, nil
}- [ ] Step 2: Verify it compiles
Run: go build ./internal/learning/... Expected: success
- [ ] Step 3: Commit
git add internal/learning/api.go
git commit -m "feat(learning): add search API client"Task 6: Pack Integration Types & Loading
Files:
Modify:
internal/content/pack.go(add types + loading)Modify:
internal/content/merge.go(add learning refs merge)Create:
internal/content/learning.go[ ] Step 1: Add types to
pack.go
After the existing DiscoveryProfileFilters struct (around line 200), add the learning types:
// LearningYAML is the intermediate struct for unmarshaling learning.yaml.
type LearningYAML struct {
ProfileFilters *LearningProfileFilters `yaml:"profile_filters,omitempty"`
Journeys []LearningRef `yaml:"journeys,omitempty"`
}
// LearningRef is a curated learning journey reference in learning.yaml.
type LearningRef struct {
Slug string `yaml:"slug"`
Featured bool `yaml:"featured,omitempty"`
PackID string // set at load time
}
// LearningProfileFilters maps a pack to learning.sap.com filter values.
type LearningProfileFilters struct {
Products []string `yaml:"products,omitempty"`
ProductCategories []string `yaml:"product_categories,omitempty"`
Roles []string `yaml:"roles,omitempty"`
}Add fields to the Pack struct (after DiscoveryFilters):
LearningRefs []LearningRef
LearningFilters *LearningProfileFilters- [ ] Step 2: Add loading in
loadPack()
After the discovery.yaml loading block (around line 380), add:
if data, err := os.ReadFile(filepath.Join(packDir, "learning.yaml")); err == nil {
var learn LearningYAML
_ = yaml.Unmarshal(data, &learn)
pack.LearningRefs = learn.Journeys
for i := range pack.LearningRefs {
pack.LearningRefs[i].PackID = pack.ID
}
pack.LearningFilters = learn.ProfileFilters
}- [ ] Step 3: Add merge logic in
merge.go
In internal/content/merge.go, after the merged.DiscoveryFilters block (around line 59), add:
merged.LearningRefs = mergeLearningRefs(base.LearningRefs, a.LearningRefs, base.ID)
if a.LearningFilters != nil {
merged.LearningFilters = a.LearningFilters
}Then add the merge function at the end of the file (after mergeDiscoveryGuidance):
// mergeLearningRefs builds a fresh []LearningRef: starts with base entries,
// replaces any entry whose Slug matches an additive entry, appends unmatched additive entries.
// PackID is re-stamped to packID on every entry in the result.
func mergeLearningRefs(base, additive []LearningRef, packID string) []LearningRef {
result := make([]LearningRef, len(base))
copy(result, base)
for _, a := range additive {
replaced := false
for i, b := range result {
if b.Slug == a.Slug {
result[i] = a
replaced = true
break
}
}
if !replaced {
result = append(result, a)
}
}
for i := range result {
result[i].PackID = packID
}
return result
}- [ ] Step 4: Create
internal/content/learning.go
package content
import "strings"
// FlattenLearningRefs collects all curated learning refs from all packs.
func FlattenLearningRefs(packs []*Pack) []LearningRef {
var out []LearningRef
for _, p := range packs {
out = append(out, p.LearningRefs...)
}
return out
}
// CollectLearningFilters unions all LearningProfileFilters across active packs.
func CollectLearningFilters(packs []*Pack) LearningProfileFilters {
products := make(map[string]bool)
categories := make(map[string]bool)
roles := make(map[string]bool)
for _, p := range packs {
if p.LearningFilters == nil {
continue
}
for _, v := range p.LearningFilters.Products {
products[v] = true
}
for _, v := range p.LearningFilters.ProductCategories {
categories[v] = true
}
for _, v := range p.LearningFilters.Roles {
roles[v] = true
}
}
return LearningProfileFilters{
Products: setToSlice(products),
ProductCategories: setToSlice(categories),
Roles: setToSlice(roles),
}
}
// MatchesLearningFilters checks if a journey matches the profile filters.
func MatchesLearningFilters(product, productCategory string, roles []string, f LearningProfileFilters) bool {
if len(f.Products) == 0 && len(f.ProductCategories) == 0 && len(f.Roles) == 0 {
return true
}
for _, fp := range f.Products {
if strings.Contains(product, fp) {
return true
}
}
for _, fc := range f.ProductCategories {
if strings.Contains(productCategory, fc) {
return true
}
}
for _, fr := range f.Roles {
for _, r := range roles {
if strings.EqualFold(r, fr) {
return true
}
}
}
return false
}Note: setToSlice is already defined in internal/content/discovery.go (same package) — no import needed.
- [ ] Step 5: Verify it compiles
Run: go build ./internal/content/... Expected: success
- [ ] Step 6: Commit
git add internal/content/pack.go internal/content/merge.go internal/content/learning.go
git commit -m "feat(learning): add pack integration types, merge logic, and content helpers"Task 7: Config TTL (internal/config/config.go)
Files:
Modify:
internal/config/config.go[ ] Step 1: Add Learning field to SyncConfig
Add Learning time.Duration \yaml:"learning"`after theTutorialsfield in theSyncConfig` struct.
- [ ] Step 2: Add default value
In the Default() function, add Learning: 168 * time.Hour, // 7 days after the Tutorials default.
- [ ] Step 3: Verify it compiles
Run: go build ./internal/config/... Expected: success
- [ ] Step 4: Commit
git add internal/config/config.go
git commit -m "feat(learning): add sync TTL config field"Task 8: Sync Integration (cmd/sync.go)
Files:
Modify:
cmd/sync.go[ ] Step 1: Register "learning" in
allCategories()
Add "learning" to the returned slice:
func allCategories() []string {
return []string{"tips", "tools", "resources", "context", "mcp", "advocates", "events", "youtube", "discovery", "tutorials", "learning"}
}- [ ] Step 2: Add "learning" to the ttls map and independentCats
In the ttls map definition (around line 71), add:
"learning": cfg.Sync.Learning,In the independentCats slice (line 81), add "learning":
independentCats := []string{"events", "youtube", "discovery", "tutorials", "learning"}- [ ] Step 3: Add sync phase after tutorials
After the tutorials sync phase (around line 168), add:
// Phase 7: Learning journeys catalog fetch
if containsString(activeIndependent, "learning") && (force || engine.IsStale("learning")) {
if err := runLearningFetch(paths.CacheDir, force); err != nil {
fmt.Fprintf(os.Stderr, "sap-devs: learning sync: %v\n", err)
}
_ = engine.MarkSynced("learning")
}- [ ] Step 4: Add
runLearningFetch()function
Add at the end of the file (before any existing helper functions):
func runLearningFetch(cacheDir string, force bool) error {
if !force {
if age := learning.IndexCacheAge(cacheDir); age >= 0 && age <= learning.CacheTTL {
return nil
}
}
journeys, err := learning.FetchCatalog()
if err != nil {
if stale, ok := learning.LoadIndexStale(cacheDir); ok {
_ = learning.SaveIndex(cacheDir, stale)
return nil
}
return err
}
return learning.SaveIndex(cacheDir, journeys)
}Add the import "github.com/SAP-samples/sap-devs-cli/internal/learning" to the imports block.
- [ ] Step 5: Verify it compiles
Run: go build ./cmd/... Expected: success
- [ ] Step 6: Commit
git add cmd/sync.go
git commit -m "feat(learning): add sync integration for catalog fetch"Task 9: i18n Keys (internal/i18n/)
Must be completed before Task 10 (CLI Commands) — the CLI commands reference these i18n keys.
Files:
Modify:
internal/i18n/catalog_en.go(andcatalog_de.goif it exists)[ ] Step 1: Add English translations
Find the translations map in catalog_en.go and add:
"learning.short": "Browse SAP Learning Journeys",
"learning.long": "Browse, search, and open learning journeys from learning.sap.com",
"learning.list.short": "List learning journeys from your active profile",
"learning.search.short": "Search learning journeys",
"learning.show.short": "Show learning journey details",
"learning.open.short": "Open a learning journey in the browser",- [ ] Step 2: Add German translations (same as English for now)
Copy the same keys to catalog_de.go with English values as placeholders.
- [ ] Step 3: Verify it compiles
Run: go build ./... Expected: success
- [ ] Step 4: Commit
git add internal/i18n/
git commit -m "feat(learning): add i18n translation keys"Task 10: CLI Commands (cmd/learning.go)
Files:
Create:
cmd/learning.go[ ] Step 1: Create the command file
package cmd
import (
"fmt"
"html"
"regexp"
"strings"
"text/tabwriter"
"github.com/charmbracelet/glamour"
"github.com/pkg/browser"
"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/i18n"
"github.com/SAP-samples/sap-devs-cli/internal/learning"
"github.com/SAP-samples/sap-devs-cli/internal/xdg"
)
var learningCmd = &cobra.Command{
Use: "learning",
Short: i18n.T(i18n.ActiveLang, "learning.short"),
Long: i18n.T(i18n.ActiveLang, "learning.long"),
RunE: func(cmd *cobra.Command, args []string) error {
return learningListCmd.RunE(cmd, args)
},
}
var learningListCmd = &cobra.Command{
Use: "list",
Short: i18n.T(i18n.ActiveLang, "learning.list.short"),
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
}
var packs []*content.Pack
if !learningAll {
profileCfg, err := config.LoadProfile(paths.ConfigDir)
if err == nil && profileCfg.ID != "" {
if p, _ := loader.FindProfile(profileCfg.ID); p != nil {
packs, err = loader.LoadPacks(p, i18n.ActiveLang)
}
}
}
if packs == nil {
packs, err = loader.LoadPacks(nil, i18n.ActiveLang)
if err != nil {
return err
}
}
index, ok := learning.LoadIndex(paths.CacheDir, learning.CacheTTL)
if !ok {
return fmt.Errorf("learning index not cached — run 'sap-devs sync' first")
}
refs := content.FlattenLearningRefs(packs)
filters := content.LearningProfileFilters{}
if !learningAll {
filters = content.CollectLearningFilters(packs)
}
journeys := resolveLearningJourneys(index, refs, filters, learningAll)
if learningPack != "" {
journeys = filterLearningByPack(journeys, refs, learningPack)
}
if learningLevel != "" {
journeys = learning.FilterByLevel(journeys, learningLevel)
}
if learningRole != "" {
journeys = learning.FilterByRole(journeys, learningRole)
}
if len(journeys) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "No learning journeys found.")
return nil
}
n := learningCount
if n <= 0 || n > len(journeys) {
n = len(journeys)
}
journeys = journeys[:n]
featuredSlugs := make(map[string]bool)
for _, ref := range refs {
if ref.Featured {
featuredSlugs[ref.Slug] = true
}
}
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", "FEATURED", "TITLE", "LEVEL", "DURATION")
for _, j := range journeys {
featured := ""
if featuredSlugs[j.Slug] {
featured = "★"
}
level := formatLevel(j.Level)
duration := formatDuration(j.DurationHours)
title := j.Title
if len(title) > 55 {
title = title[:52] + "..."
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", featured, title, level, duration)
}
w.Flush()
return nil
},
}
var learningSearchCmd = &cobra.Command{
Use: "search <query>",
Short: i18n.T(i18n.ActiveLang, "learning.search.short"),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
paths, err := xdg.New()
if err != nil {
return err
}
cacheKey := learning.SearchCacheKey(args[0], learningLevel, learningRole)
var results []learning.LearningJourney
results, ok := learning.LoadSearchCache(paths.CacheDir, cacheKey)
if !ok {
results, err = learning.SearchAPI(args[0], learningCount)
if err != nil {
// Fallback to local search
index, indexOK := learning.LoadIndex(paths.CacheDir, learning.CacheTTL)
if !indexOK {
return fmt.Errorf("search API failed and no cached index: %w", err)
}
results = learning.Search(index, args[0])
} else {
_ = learning.SaveSearchCache(paths.CacheDir, cacheKey, results)
}
}
if learningLevel != "" {
results = learning.FilterByLevel(results, learningLevel)
}
if learningRole != "" {
results = learning.FilterByRole(results, learningRole)
}
if len(results) == 0 {
fmt.Fprintf(cmd.OutOrStdout(), "No learning journeys found for %q.\n", args[0])
return nil
}
n := learningCount
if n <= 0 || n > len(results) {
n = len(results)
}
results = results[:n]
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", "#", "TITLE", "LEVEL", "DURATION")
for i, j := range results {
level := formatLevel(j.Level)
duration := formatDuration(j.DurationHours)
title := j.Title
if len(title) > 55 {
title = title[:52] + "..."
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", i+1, title, level, duration)
}
w.Flush()
return nil
},
}
var learningShowCmd = &cobra.Command{
Use: "show <slug>",
Short: i18n.T(i18n.ActiveLang, "learning.show.short"),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
paths, err := xdg.New()
if err != nil {
return err
}
index, ok := learning.LoadIndex(paths.CacheDir, learning.CacheTTL)
if !ok {
return fmt.Errorf("learning index not cached — run 'sap-devs sync' first")
}
j := learning.FindBySlug(index, args[0])
if j == nil {
return fmt.Errorf("learning journey %q not found", args[0])
}
var b strings.Builder
b.WriteString(fmt.Sprintf("# %s\n\n", j.Title))
b.WriteString(fmt.Sprintf("**Level:** %s | **Duration:** %s | **Product:** %s\n\n",
formatLevel(j.Level), formatDuration(j.DurationHours), j.Product))
if len(j.Roles) > 0 {
b.WriteString(fmt.Sprintf("**Roles:** %s\n\n", strings.Join(j.Roles, ", ")))
}
if j.Description != "" {
b.WriteString("## Description\n\n")
b.WriteString(j.Description + "\n\n")
}
if j.Objectives != "" {
b.WriteString("## Learning Objectives\n\n")
b.WriteString(htmlToMarkdown(j.Objectives) + "\n\n")
}
b.WriteString(fmt.Sprintf("**URL:** %s\n", j.URL))
renderer, err := glamour.NewTermRenderer(glamour.WithAutoStyle(), glamour.WithWordWrap(80))
if err != nil {
fmt.Fprint(cmd.OutOrStdout(), b.String())
return nil
}
rendered, err := renderer.Render(b.String())
if err != nil {
fmt.Fprint(cmd.OutOrStdout(), b.String())
return nil
}
fmt.Fprint(cmd.OutOrStdout(), rendered)
return nil
},
}
var learningOpenCmd = &cobra.Command{
Use: "open <slug>",
Short: i18n.T(i18n.ActiveLang, "learning.open.short"),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
url := learning.BaseURL + args[0]
if err := browser.OpenURL(url); err != nil {
fmt.Fprintf(cmd.OutOrStdout(), "Could not open browser. Visit: %s\n", url)
return nil
}
fmt.Fprintf(cmd.OutOrStdout(), "Opening %s\n", url)
return nil
},
}
// Flags
var (
learningAll bool
learningPack string
learningLevel string
learningRole string
learningCount int
)
func init() {
learningCmd.PersistentFlags().BoolVar(&learningAll, "all", false, "bypass profile filtering")
learningCmd.PersistentFlags().StringVar(&learningLevel, "level", "", "filter by level (beginner, intermediate, advanced)")
learningCmd.PersistentFlags().StringVar(&learningRole, "role", "", "filter by role")
learningCmd.PersistentFlags().IntVarP(&learningCount, "count", "n", 20, "limit results")
learningListCmd.Flags().StringVar(&learningPack, "pack", "", "filter to a specific pack's curated journeys")
learningCmd.AddCommand(learningListCmd, learningSearchCmd, learningShowCmd, learningOpenCmd)
rootCmd.AddCommand(learningCmd)
}
// resolveLearningJourneys implements the three-tier resolution algorithm.
func resolveLearningJourneys(
index []learning.LearningJourney,
refs []content.LearningRef,
filters content.LearningProfileFilters,
all bool,
) []learning.LearningJourney {
bySlug := make(map[string]learning.LearningJourney, len(index))
for _, j := range index {
bySlug[j.Slug] = j
}
var result []learning.LearningJourney
seen := make(map[string]bool)
// Tier 1: featured refs
for _, ref := range refs {
if !ref.Featured {
continue
}
if j, ok := bySlug[ref.Slug]; ok && !seen[ref.Slug] {
result = append(result, j)
seen[ref.Slug] = true
}
}
// Tier 2: non-featured pack refs
for _, ref := range refs {
if ref.Featured || seen[ref.Slug] {
continue
}
if j, ok := bySlug[ref.Slug]; ok {
result = append(result, j)
seen[ref.Slug] = true
}
}
// Tier 3: profile-filtered (or all if --all)
for _, j := range index {
if seen[j.Slug] {
continue
}
if all || content.MatchesLearningFilters(j.Product, j.ProductCategory, j.Roles, filters) {
result = append(result, j)
seen[j.Slug] = true
}
}
return result
}
func filterLearningByPack(journeys []learning.LearningJourney, refs []content.LearningRef, packID string) []learning.LearningJourney {
slugs := make(map[string]bool)
for _, ref := range refs {
if ref.PackID == packID {
slugs[ref.Slug] = true
}
}
var out []learning.LearningJourney
for _, j := range journeys {
if slugs[j.Slug] {
out = append(out, j)
}
}
return out
}
func formatLevel(level string) string {
switch strings.ToUpper(level) {
case "BEGINNER":
return "Beginner"
case "INTERMEDIATE":
return "Intermediate"
case "ADVANCED":
return "Advanced"
default:
return level
}
}
func formatDuration(hours string) string {
if hours == "" {
return ""
}
return hours + " hr"
}
var reHTMLTag = regexp.MustCompile(`<[^>]+>`)
func htmlToMarkdown(s string) string {
s = strings.ReplaceAll(s, "<li>", "- ")
s = strings.ReplaceAll(s, "</li>", "\n")
s = strings.ReplaceAll(s, "<br>", "\n")
s = strings.ReplaceAll(s, "<br/>", "\n")
s = strings.ReplaceAll(s, "<br />", "\n")
s = strings.ReplaceAll(s, " ", " ")
s = reHTMLTag.ReplaceAllString(s, "")
s = html.UnescapeString(s)
return strings.TrimSpace(s)
}- [ ] Step 2: Verify it compiles
Run: go build ./... Expected: success (may need i18n keys — see Task 11)
- [ ] Step 3: Commit
git add cmd/learning.go
git commit -m "feat(learning): add CLI commands (list, search, show, open)"Task 11: Context Injection (internal/content/render.go)
Files:
- Modify:
internal/content/pack.go(add injection struct + field) - Modify:
internal/content/render.go(render featured journeys) - Modify:
cmd/inject.go(populate injection data from index)
The RenderContext function only has access to Pack data, but LearningRef only carries slug/featured/packID — no title, URL, level, or duration. The approach: add a LearningJourneyInjection struct to Pack that's populated from the cached index at inject time (in cmd/inject.go), before RenderContext is called.
- [ ] Step 1: Add injection type to
pack.go
After the LearningProfileFilters struct, add:
// LearningJourneyInjection is a pre-resolved learning journey for context injection.
type LearningJourneyInjection struct {
Title string
URL string
Level string
Duration string
}Add field to Pack struct (after LearningFilters):
LearningForInject []LearningJourneyInjection // populated at inject time- [ ] Step 2: Add rendering in
render.go
In RenderContext(), after the samples/canonical patterns section (around line 73), before the final return:
var learningRows []string
for _, p := range packs {
for _, lj := range p.LearningForInject {
learningRows = append(learningRows, fmt.Sprintf("| [%s](%s) | %s | %s |",
lj.Title, lj.URL, lj.Level, lj.Duration))
}
}
if len(learningRows) > 0 {
b.WriteString("## Recommended Learning Journeys\n\n")
b.WriteString("| Journey | Level | Duration |\n")
b.WriteString("|---------|-------|----------|\n")
for _, row := range learningRows {
b.WriteString(row + "\n")
}
b.WriteString("\n")
}- [ ] Step 3: Populate
LearningForInjectincmd/inject.go
In cmd/inject.go, after packs are loaded at line 175 (packs, err := loader.LoadPacks(activeProfile, i18n.ActiveLang)) and also after the reload at line 190, but before the dynCtx := dynamic.GatherDynamic(...) call at line 218, insert:
// Resolve featured learning journeys for injection
learningIndex, _ := learning.LoadIndex(paths.CacheDir, learning.CacheTTL)
if learningIndex != nil {
bySlug := make(map[string]learning.LearningJourney, len(learningIndex))
for _, j := range learningIndex {
bySlug[j.Slug] = j
}
for _, p := range packs {
for _, ref := range p.LearningRefs {
if ref.Featured {
if j, ok := bySlug[ref.Slug]; ok {
p.LearningForInject = append(p.LearningForInject, content.LearningJourneyInjection{
Title: j.Title,
URL: j.URL,
Level: j.Level,
Duration: j.DurationHours + " hr",
})
}
}
}
}
}Add the import "github.com/SAP-samples/sap-devs-cli/internal/learning" to cmd/inject.go.
- [ ] Step 4: Verify it compiles
Run: go build ./... Expected: success
- [ ] Step 5: Commit
git add internal/content/pack.go internal/content/render.go cmd/inject.go
git commit -m "feat(learning): add context injection of featured learning journeys"Task 12: Pack Content Files
Files:
Create:
content/packs/cap/learning.yamlCreate:
content/packs/btp-core/learning.yamlCreate:
content/packs/abap/learning.yamlCreate:
content/schemas/learning.schema.jsonModify:
.vscode/settings.json[ ] Step 1: Create
content/packs/cap/learning.yaml
profile_filters:
products: ["SAP Business Technology Platform"]
product_categories: ["Business Technology Platform"]
roles: ["developer", "architect"]
journeys:
- slug: developing-with-sap-cloud-application-programming-model
featured: true
- slug: becoming-an-sap-btp-solution-architect
featured: true
- slug: modernizing-integration-with-sap-integration-suiteNote: Verify these slugs exist on learning.sap.com. The first two are confirmed from our research; adjust if needed after running sync.
- [ ] Step 2: Create
content/packs/btp-core/learning.yaml
profile_filters:
products: ["SAP Business Technology Platform"]
product_categories: ["Business Technology Platform"]
journeys:
- slug: becoming-an-sap-btp-solution-architect
featured: true- [ ] Step 3: Create
content/packs/abap/learning.yaml
profile_filters:
product_categories: ["Application Development and Automation"]
roles: ["developer", "consultant"]
journeys: []Note: Populate with actual ABAP learning journey slugs after reviewing the catalog.
- [ ] Step 4: Create
content/schemas/learning.schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Learning Journey References",
"description": "Schema for sap-devs learning.yaml files",
"type": "object",
"additionalProperties": false,
"properties": {
"profile_filters": {
"type": "object",
"additionalProperties": false,
"properties": {
"products": {
"type": "array",
"items": { "type": "string" }
},
"product_categories": {
"type": "array",
"items": { "type": "string" }
},
"roles": {
"type": "array",
"items": { "type": "string" }
}
}
},
"journeys": {
"type": "array",
"items": {
"type": "object",
"required": ["slug"],
"additionalProperties": false,
"properties": {
"slug": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*[a-z0-9]$",
"description": "URL slug from learning.sap.com/learning-journeys/{slug}"
},
"featured": {
"type": "boolean",
"default": false,
"description": "If true, highlighted in list output and injected into AI context"
}
}
}
}
}
}- [ ] Step 5: Wire schema in
.vscode/settings.json
Add this line to the yaml.schemas object:
"./content/schemas/learning.schema.json": "**/packs/*/learning.yaml"- [ ] Step 6: Commit
git add content/packs/cap/learning.yaml content/packs/btp-core/learning.yaml content/packs/abap/learning.yaml content/schemas/learning.schema.json .vscode/settings.json
git commit -m "feat(learning): add pack content files and JSON schema"Task 13: Update CLAUDE.md
Files:
Modify:
CLAUDE.md[ ] Step 1: Add
learningto the CLI Commands table
In the CLI Commands table, add a row after discovery:
| `learning` | Browse SAP Learning Journeys; `learning list/search/show/open` |- [ ] Step 2: Commit
git add CLAUDE.md
git commit -m "docs: add learning command to CLI reference"Task 14: Build Verification & Smoke Test
Files: None (verification only)
- [ ] Step 1: Full build check
Run: go build ./... Expected: success
- [ ] Step 2: Static analysis
Run: go vet ./... Expected: success
- [ ] Step 3: Smoke test — sync learning
Run: go run . sync --category learning --force Expected: catalog is fetched, filtered, and cached. No errors on stderr.
- [ ] Step 4: Smoke test — learning list
Run: go run . learning list --all -n 5 Expected: table output with 5 learning journeys showing FEATURED, TITLE, LEVEL, DURATION columns.
- [ ] Step 5: Smoke test — learning search
Run: go run . learning search "btp" -n 3 Expected: table output with search results.
- [ ] Step 6: Smoke test — learning show
Run: go run . learning show becoming-an-sap-btp-solution-architect Expected: rendered detail view with title, level, duration, description, learning objectives.
- [ ] Step 7: Smoke test — learning open
Run: go run . learning open becoming-an-sap-btp-solution-architect Expected: browser opens to https://learning.sap.com/learning-journeys/becoming-an-sap-btp-solution-architect.
- [ ] Step 8: Smoke test — profile-filtered list
Run: go run . learning list -n 5 (with cap profile active) Expected: table shows CAP-relevant journeys, featured ones marked with ★.
- [ ] Step 9: Smoke test — inject includes learning
Run: SAP_DEVS_DEV=1 go run . inject --dry-run Expected: output contains "## Recommended Learning Journeys" section with a markdown table of featured journeys.