Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- **`positronick feed` (admin)**: manage the blog feed sources (GitHub release / RSS mirroring) the ingestor polls — `feed list`, `feed create --label <l> --feed-url <u> --kind github_release|rss --category <c>` (`--author`/`--listing` attribution, repeatable `--tag`, `--auto-publish`, `--enabled`), `feed update <id>` (`--enabled=false` pauses a feed — there is no delete verb), and `feed sync <id>` (ingest one feed now, surfacing the fetch summary; a fetch/parse failure maps the API's 502 to a clear error). Backed by the `/api/admin/feeds` API. Ingesting every feed on a schedule stays the cron's job — no CLI subcommand carries that admin key.
- **API type sync**: the wire types now mirror the latest `src/lib/types.ts` field-for-field, so `--json` no longer silently drops fields the server sends. Souls and listings gain `chargeCount` (the "energy boost" count); listings also gain `profileTier` (the author's seal), `hasAsset`/`assetVersion`/`assetContentHash` (hosted SKILL.md asset metadata — lets a client skip identical re-downloads), and `LoopData`/`SkillData` gain `bundles`. Human detail views surface `CHARGES`, a skill's `ASSET VERSION`, and loop/skill `BUNDLES`.

## [0.1.2] - 2026-06-16

Expand Down
72 changes: 57 additions & 15 deletions internal/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,15 @@ type SoulCard struct {
// ContentHash is the sha256 of the normalized SOUL.md body.
ContentHash string `json:"contentHash"`
// Status is draft | pending | published.
Status string `json:"status"`
DownloadCount int `json:"downloadCount"`
RatingAvg *float64 `json:"ratingAvg"`
RatingCount int `json:"ratingCount"`
ArenaRank *int `json:"arenaRank"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Status string `json:"status"`
DownloadCount int `json:"downloadCount"`
// ChargeCount is the running count of user "charges" (the energy boost).
ChargeCount int `json:"chargeCount"`
RatingAvg *float64 `json:"ratingAvg"`
RatingCount int `json:"ratingCount"`
ArenaRank *int `json:"arenaRank"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}

// Soul is a full soul, including the raw SOUL.md markdown body. Mirrors Soul
Expand All @@ -88,7 +90,9 @@ type Listing struct {
// ProfileHandle/ProfileName denormalize the authoring profile for cards.
ProfileHandle string `json:"profileHandle"`
ProfileName string `json:"profileName"`
Name string `json:"name"`
// ProfileTier is the author's seal — "official" | "verified" | null.
ProfileTier *string `json:"profileTier"`
Name string `json:"name"`
// Type is one of ListingTypes.
Type string `json:"type"`
// Tagline is the short one-liner shown on cards.
Expand All @@ -103,13 +107,23 @@ type Listing struct {
RepoURL *string `json:"repoUrl"`
// InstallCmd is the canonical official install/run command, if any.
InstallCmd *string `json:"installCmd"`
// Data holds type-specific extras (e.g. LoopData for loops); {} when none.
Data map[string]any `json:"data"`
Confidence string `json:"confidence"`
Status string `json:"status"`
DownloadCount int `json:"downloadCount"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
// Data holds type-specific extras (e.g. LoopData for loops, SkillData for
// skills); {} when none.
Data map[string]any `json:"data"`
// HasAsset is true when a hosted SKILL.md asset exists for this (skill) listing.
HasAsset bool `json:"hasAsset"`
// AssetVersion is the hosted asset's semver, or null when HasAsset is false.
AssetVersion *string `json:"assetVersion"`
// AssetContentHash is the sha256 of the hosted asset body, or null — lets
// clients skip identical re-downloads.
AssetContentHash *string `json:"assetContentHash"`
Confidence string `json:"confidence"`
Status string `json:"status"`
DownloadCount int `json:"downloadCount"`
// ChargeCount is the running count of user "charges" (the energy boost).
ChargeCount int `json:"chargeCount"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}

// LoopData decodes the untyped Data payload into LoopData. Empty or nil Data
Expand All @@ -129,6 +143,23 @@ func (l *Listing) LoopData() (LoopData, error) {
return ld, nil
}

// SkillData decodes the untyped Data payload into SkillData. Empty or nil Data
// yields the zero value; a wrong-typed field is an error, never a silent zero.
func (l *Listing) SkillData() (SkillData, error) {
var sd SkillData
if len(l.Data) == 0 {
return sd, nil
}
b, err := json.Marshal(l.Data)
if err != nil {
return sd, fmt.Errorf("encoding listing data: %w", err)
}
if err := json.Unmarshal(b, &sd); err != nil {
return sd, fmt.Errorf("decoding skill data for %q: %w", l.Slug, err)
}
return sd, nil
}

// Profile is a verified person or org that authors registry tooling. Mirrors
// Profile in src/lib/types.ts. Nullable TS fields (string | null) are pointers
// so null round-trips as null in --json output.
Expand Down Expand Up @@ -170,6 +201,17 @@ type LoopData struct {
CompatibleTools []string `json:"compatibleTools,omitempty"`
// Kickoff is the prompt a user copies to start the loop.
Kickoff string `json:"kickoff,omitempty"`
// Bundles are slugs of listings this loop depends on (a loop usually drives
// several skills).
Bundles []string `json:"bundles,omitempty"`
}

// SkillData is the type-specific extras for a `skill` listing, stored in
// Listing.Data. A skill with Bundles is a meta-skill: installing it pulls in
// every bundled listing. Mirrors SkillData in src/lib/types.ts.
type SkillData struct {
// Bundles are slugs of listings this skill bundles — its install-time deps.
Bundles []string `json:"bundles,omitempty"`
}

// FeedSource is a subscribed blog feed source the ingestor mirrors into posts
Expand Down
93 changes: 93 additions & 0 deletions internal/api/types_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package api

import (
"encoding/json"
"reflect"
"strings"
"testing"
)

Expand Down Expand Up @@ -74,3 +76,94 @@ func TestListingLoopData(t *testing.T) {
})
}
}

// A meta-skill carries its bundled listing slugs in Listing.Data; the typed
// decode must round-trip them, and a wrong-typed field must fail loud rather
// than silently drop the dependency graph.
func TestListingSkillData(t *testing.T) {
tests := []struct {
name string
data map[string]any
want SkillData
wantErr bool
}{
{
name: "bundles decode field for field",
data: map[string]any{"bundles": []any{"pr-to-green", "debugging"}},
want: SkillData{Bundles: []string{"pr-to-green", "debugging"}},
},
{"empty data yields zero value", map[string]any{}, SkillData{}, false},
{"nil data yields zero value", nil, SkillData{}, false},
{"unknown keys ignored", map[string]any{"futureField": 1}, SkillData{}, false},
{"wrong-typed bundles fails loud", map[string]any{"bundles": "pr-to-green"}, SkillData{}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
l := Listing{Data: tt.data}
got, err := l.SkillData()
if tt.wantErr {
if err == nil {
t.Fatal("SkillData should error on a wrong-typed field")
}
return
}
if err != nil {
t.Fatalf("SkillData: %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("SkillData = %+v, want %+v", got, tt.want)
}
})
}
}

// The CLI mirrors src/lib/types.ts field-for-field so an agent reading --json
// sees everything the server sends. These fields were added to the API after
// the CLI's first cut; this pins that they survive a decode→encode round-trip
// (i.e. are never silently dropped), including null for the nullable asset
// fields.
func TestNewWireFieldsRoundTrip(t *testing.T) {
const listingJSON = `{"id":"01X","slug":"superpowers","profileHandle":"obra","profileName":"Jesse Vincent","profileTier":"verified","name":"Superpowers","type":"skill","tagline":"t","description":null,"category":"AI/ML","tags":[],"official":true,"sourceUrl":"https://e.x/s","repoUrl":null,"installCmd":null,"data":{},"hasAsset":true,"assetVersion":"1.0.0","assetContentHash":"abc","confidence":"official","status":"published","downloadCount":18,"chargeCount":6,"createdAt":"2026-04-01T09:00:00.000Z","updatedAt":"2026-04-02T09:00:00.000Z"}`
var l Listing
if err := json.Unmarshal([]byte(listingJSON), &l); err != nil {
t.Fatalf("decode listing: %v", err)
}
if l.ProfileTier == nil || *l.ProfileTier != "verified" {
t.Errorf("profileTier = %v, want verified", l.ProfileTier)
}
if !l.HasAsset || l.AssetVersion == nil || *l.AssetVersion != "1.0.0" {
t.Errorf("asset fields lost: hasAsset=%v version=%v", l.HasAsset, l.AssetVersion)
}
if l.ChargeCount != 6 {
t.Errorf("chargeCount = %d, want 6", l.ChargeCount)
}
out, err := json.Marshal(l)
if err != nil {
t.Fatalf("encode listing: %v", err)
}
for _, want := range []string{`"profileTier":"verified"`, `"hasAsset":true`,
`"assetVersion":"1.0.0"`, `"assetContentHash":"abc"`, `"chargeCount":6`} {
if !strings.Contains(string(out), want) {
t.Errorf("re-encoded listing dropped %s\ngot: %s", want, out)
}
}

// A null asset (non-skill listing) must round-trip as JSON null, not "".
var bare Listing
if err := json.Unmarshal([]byte(`{"assetVersion":null,"chargeCount":0}`), &bare); err != nil {
t.Fatalf("decode bare: %v", err)
}
out, _ = json.Marshal(bare)
if !strings.Contains(string(out), `"assetVersion":null`) {
t.Errorf("nullable assetVersion must serialize as null, got: %s", out)
}

// SoulCard gained chargeCount alongside downloadCount.
var s SoulCard
if err := json.Unmarshal([]byte(`{"downloadCount":42,"chargeCount":5}`), &s); err != nil {
t.Fatalf("decode soul: %v", err)
}
if s.ChargeCount != 5 {
t.Errorf("SoulCard.ChargeCount = %d, want 5", s.ChargeCount)
}
}
11 changes: 11 additions & 0 deletions internal/cli/listing.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,11 @@ func renderListingDetail(p *output.Printer, l *api.Listing) error {
"SOURCE", l.SourceURL,
"REPO", deref(l.RepoURL),
"INSTALL", deref(l.InstallCmd),
"ASSET VERSION", deref(l.AssetVersion),
"CONFIDENCE", l.Confidence,
"STATUS", l.Status,
"DOWNLOADS", strconv.Itoa(l.DownloadCount),
"CHARGES", strconv.Itoa(l.ChargeCount),
"CREATED", l.CreatedAt,
"UPDATED", l.UpdatedAt,
)
Expand All @@ -250,9 +252,18 @@ func renderListingDetail(p *output.Printer, l *api.Listing) error {
"EXIT CONDITION", loop.ExitCondition,
"MAX ITERATIONS", maxIterations,
"COMPATIBLE TOOLS", strings.Join(loop.CompatibleTools, ", "),
"BUNDLES", strings.Join(loop.Bundles, ", "),
)...)
}

if l.Type == "skill" {
skill, err := l.SkillData()
if err != nil {
return err
}
rows = append(rows, fieldRows("BUNDLES", strings.Join(skill.Bundles, ", "))...)
}

output.RenderFields(p.Out, rows)
if loop.Kickoff != "" {
p.Human("\nKICKOFF\n%s\n", loop.Kickoff)
Expand Down
1 change: 1 addition & 0 deletions internal/cli/soul.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ func renderSoulDetail(p *output.Printer, s *api.Soul) {
"LICENSE", s.License,
"REPO", deref(s.RepoURL),
"DOWNLOADS", strconv.Itoa(s.DownloadCount),
"CHARGES", strconv.Itoa(s.ChargeCount),
"STATUS", s.Status,
"CREATED", s.CreatedAt,
"UPDATED", s.UpdatedAt,
Expand Down
5 changes: 5 additions & 0 deletions internal/cli/testdata/golden/cli-search.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"slug": "github-cli",
"profileHandle": "github",
"profileName": "GitHub",
"profileTier": "official",
"name": "GitHub CLI",
"type": "cli",
"tagline": "GitHub from the command line",
Expand All @@ -21,9 +22,13 @@
"repoUrl": "https://example.com/cli/cli",
"installCmd": "brew install gh",
"data": {},
"hasAsset": false,
"assetVersion": null,
"assetContentHash": null,
"confidence": "official",
"status": "published",
"downloadCount": 64,
"chargeCount": 4,
"createdAt": "2026-02-01T09:00:00.000Z",
"updatedAt": "2026-02-03T09:00:00.000Z"
}
Expand Down
5 changes: 5 additions & 0 deletions internal/cli/testdata/golden/harness-list.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"slug": "claude-code",
"profileHandle": "anthropic",
"profileName": "Anthropic",
"profileTier": "official",
"name": "Claude Code",
"type": "harness",
"tagline": "Agentic coding in your terminal",
Expand All @@ -20,9 +21,13 @@
"repoUrl": "https://example.com/anthropics/claude-code",
"installCmd": "npm install -g @anthropic-ai/claude-code",
"data": {},
"hasAsset": false,
"assetVersion": null,
"assetContentHash": null,
"confidence": "official",
"status": "published",
"downloadCount": 120,
"chargeCount": 9,
"createdAt": "2026-01-10T09:00:00.000Z",
"updatedAt": "2026-01-12T09:00:00.000Z"
}
Expand Down
5 changes: 5 additions & 0 deletions internal/cli/testdata/golden/listing-create.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"slug": "sentry-mcp",
"profileHandle": "nsollazzo",
"profileName": "Nicholas Sollazzo",
"profileTier": null,
"name": "Sentry MCP",
"type": "mcp",
"tagline": "Errors, issues and traces as agent tools",
Expand All @@ -18,9 +19,13 @@
"repoUrl": "https://example.com/getsentry/sentry-mcp",
"installCmd": "npx @sentry/mcp-server",
"data": {},
"hasAsset": false,
"assetVersion": null,
"assetContentHash": null,
"confidence": "high",
"status": "published",
"downloadCount": 0,
"chargeCount": 0,
"createdAt": "2026-06-09T09:00:00.000Z",
"updatedAt": "2026-06-09T09:00:00.000Z",
"source": "api"
Expand Down
8 changes: 8 additions & 0 deletions internal/cli/testdata/golden/listing-unpublish.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"slug": "pr-to-green",
"profileHandle": "nsollazzo",
"profileName": "Nicholas Sollazzo",
"profileTier": "official",
"name": "PR to Green",
"type": "loop",
"tagline": "Drive a pull request until CI is green and review approves",
Expand All @@ -18,6 +19,9 @@
"repoUrl": null,
"installCmd": null,
"data": {
"bundles": [
"superpowers"
],
"checkCommand": "gh pr checks --json state",
"compatibleTools": [
"claude-code",
Expand All @@ -28,9 +32,13 @@
"kickoff": "Run the pr-to-green loop on the open PR:\n1. Read every review finding.\n2. Fix the valid ones, push back on the invalid.\n3. Push and re-check until green.",
"maxIterations": 20
},
"hasAsset": false,
"assetVersion": null,
"assetContentHash": null,
"confidence": "official",
"status": "draft",
"downloadCount": 12,
"chargeCount": 3,
"createdAt": "2026-05-01T09:00:00.000Z",
"updatedAt": "2026-06-09T10:00:00.000Z",
"source": "api"
Expand Down
5 changes: 5 additions & 0 deletions internal/cli/testdata/golden/loop-create.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"slug": "docs-to-green",
"profileHandle": "nsollazzo",
"profileName": "Nicholas Sollazzo",
"profileTier": null,
"name": "Docs to Green",
"type": "loop",
"tagline": "Every doc page builds without warnings",
Expand All @@ -21,9 +22,13 @@
"kickoff": "Run the docs-to-green loop until the build is clean.",
"maxIterations": 20
},
"hasAsset": false,
"assetVersion": null,
"assetContentHash": null,
"confidence": "high",
"status": "published",
"downloadCount": 0,
"chargeCount": 0,
"createdAt": "2026-06-09T09:00:00.000Z",
"updatedAt": "2026-06-09T09:00:00.000Z",
"source": "api"
Expand Down
Loading