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 research`**: the agent-facing "what's new" feed over positronick.com's published blog posts, mirrored GitHub releases, and mirrored news links — newest first, so agents avoid stale knowledge. Filter with `--kind` (article/release/link), `--category`, `--tag`, or a free-text query, and poll just the delta with `--since <iso>`; the printed `latest` timestamp is the value to pass back next time. Read-only and unauthenticated, like the soul/listing reads. Backed by the public `GET /api/research` endpoint.
- **`positronick blog`**: read the positronick.com blog from the terminal — `blog list` (newest first, optional `--kind` article/release/link) and `blog show <slug>`, with `--raw` printing the markdown body verbatim and did-you-mean hints (exit 3) on a missing slug. Read-only and unauthenticated, mirroring the soul/listing reads. Backed by the public `GET /api/blog`, `/api/blog/{slug}`, and `/api/blog/{slug}.md` endpoints.
- **`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`.

Expand Down
57 changes: 57 additions & 0 deletions internal/api/blog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package api

import (
"context"
"net/http"
"net/url"
)

// This file is the typed client for the public blog read endpoints
// (GET /api/blog, /api/blog/{slug}, /api/blog/{slug}.md) — the soul/listing
// read twins for editorial posts, mirrored GitHub releases, and mirrored news
// links. All three are unauthenticated, read-only, and only ever return
// published posts. Like the soul gallery, the list reserves ?q= and filters
// client-side; only ?kind= travels on the wire.

// Posts fetches the published blog gallery, newest first: GET /api/blog. A
// non-empty kind narrows to one of PostKinds (article, release, link) via the
// server's ?kind= filter; "" fetches every kind.
func (c *Client) Posts(ctx context.Context, kind string) ([]PostCard, error) {
query := url.Values{}
if kind != "" {
query.Set("kind", kind)
}
var out struct {
Posts []PostCard `json:"posts"`
}
if err := c.do(ctx, http.MethodGet, "/api/blog", query, nil, &out); err != nil {
return nil, err
}
return out.Posts, nil
}

// Post fetches one post with its markdown body: GET /api/blog/{slug}. A renamed
// slug is followed via the server's 301; a missing one surfaces as an *APIError
// for which IsNotFound is true. This endpoint never bumps the post's viewCount.
func (c *Client) Post(ctx context.Context, slug string) (*Post, error) {
var out struct {
Post Post `json:"post"`
}
if err := c.do(ctx, http.MethodGet, "/api/blog/"+url.PathEscape(slug), nil, nil, &out); err != nil {
return nil, err
}
return &out.Post, nil
}

// PostMarkdown fetches the raw post markdown verbatim: GET /api/blog/{slug}.md.
// Unlike the soul .md endpoint (which bumps the install counter), the blog .md
// endpoint never bumps viewCount — counting stays exclusive to the HTML page
// view — so `blog show --raw` reads it directly. A renamed slug is followed via
// the server's 301; a missing one is an IsNotFound *APIError.
func (c *Client) PostMarkdown(ctx context.Context, slug string) (string, error) {
var body string
if err := c.do(ctx, http.MethodGet, "/api/blog/"+url.PathEscape(slug)+".md", nil, nil, &body); err != nil {
return "", err
}
return body, nil
}
113 changes: 113 additions & 0 deletions internal/api/blog_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package api

import (
"context"
"net/http"
"net/http/httptest"
"testing"
)

// Posts sends ?kind= only when set, and decodes the {posts} envelope into
// PostCards.
func TestPostsKindParam(t *testing.T) {
rec := &recorder{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec.add(r)
if r.URL.Path != "/api/blog" {
t.Errorf("path = %q, want /api/blog", r.URL.Path)
}
_, _ = w.Write([]byte(`{"posts":[{"slug":"intro","kind":"article"}]}`))
}))
defer srv.Close()

c, _ := newTestClient(t, srv.URL, Anonymous{})

// Empty kind sends no query string at all.
if _, err := c.Posts(context.Background(), ""); err != nil {
t.Fatalf("Posts(all): %v", err)
}
if q := rec.last().URL.RawQuery; q != "" {
t.Errorf("empty kind must send no query string, got %q", q)
}

posts, err := c.Posts(context.Background(), "article")
if err != nil {
t.Fatalf("Posts(article): %v", err)
}
if got := rec.last().URL.Query().Get("kind"); got != "article" {
t.Errorf("kind param = %q, want article", got)
}
if len(posts) != 1 || posts[0].Slug != "intro" {
t.Errorf("posts = %+v, want the decoded card", posts)
}
}

// Post decodes the {post} envelope (body included) and surfaces a 404 as an
// IsNotFound *APIError, never a panic.
func TestPostDetailAndNotFound(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/blog/intro", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"post":{"slug":"intro","kind":"article","content":"# Intro\n"}}`))
})
mux.HandleFunc("/api/blog/missing", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"post \"missing\" not found"}}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()

c, _ := newTestClient(t, srv.URL, Anonymous{})

post, err := c.Post(context.Background(), "intro")
if err != nil {
t.Fatalf("Post(intro): %v", err)
}
if post.Slug != "intro" || post.Content != "# Intro\n" {
t.Errorf("post = %+v, want the decoded post with its body", post)
}

if _, err := c.Post(context.Background(), "missing"); !IsNotFound(err) {
t.Errorf("Post(missing) err = %v, want IsNotFound", err)
}
}

// The .md endpoint is the raw-markdown contract: the body comes back verbatim,
// byte for byte, from /api/blog/{slug}.md, and a historical slug's 301 is
// followed silently.
func TestPostMarkdownVerbatimAndRedirect(t *testing.T) {
const body = "---\ntitle: Intro\n---\n\n# Intro\n\n indented\n\ttabbed\n\ntrailing newline kept\n"
rec := &recorder{}
mux := http.NewServeMux()
mux.HandleFunc("/api/blog/old-name.md", func(w http.ResponseWriter, r *http.Request) {
rec.add(r)
http.Redirect(w, r, "/api/blog/intro.md", http.StatusMovedPermanently)
})
mux.HandleFunc("/api/blog/intro.md", func(w http.ResponseWriter, r *http.Request) {
rec.add(r)
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
_, _ = w.Write([]byte(body))
})
srv := httptest.NewServer(mux)
defer srv.Close()

c, _ := newTestClient(t, srv.URL, Anonymous{})

got, err := c.PostMarkdown(context.Background(), "intro")
if err != nil {
t.Fatalf("PostMarkdown: %v", err)
}
if got != body {
t.Errorf("PostMarkdown = %q, want verbatim body %q", got, body)
}
if p := rec.last().URL.Path; p != "/api/blog/intro.md" {
t.Errorf("path = %q, want /api/blog/intro.md", p)
}

got, err = c.PostMarkdown(context.Background(), "old-name")
if err != nil {
t.Fatalf("PostMarkdown via 301: %v", err)
}
if got != body {
t.Errorf("PostMarkdown via 301 = %q, want the redirect target's body", got)
}
}
56 changes: 56 additions & 0 deletions internal/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,62 @@ type FeedSyncSummary struct {
// POST_KINDS in src/lib/types.ts.
var PostKinds = []string{"article", "release", "link"}

// PostCard is the lightweight list/gallery view of a blog post — the scalar
// metadata needed to render a card or detail header, deliberately excluding the
// heavy markdown body. Mirrors PostCard (= PostMeta) in src/lib/types.ts.
//
// Nullable fields in the TS contract (string | null, Date | null) are pointers
// so that null round-trips as null in --json output. Dates stay ISO-8601
// strings, like every other date in this file.
type PostCard struct {
// ID is the stable, immutable id (ULID).
ID string `json:"id"`
// Slug is the human-facing url segment: /blog/[slug].
Slug string `json:"slug"`
// SlugHistory holds previous slugs; the server 301s them to the current slug.
SlugHistory []string `json:"slugHistory"`
// Kind is one of PostKinds: article | release | link.
Kind string `json:"kind"`
Title string `json:"title"`
// Excerpt is the short summary shown on cards and in the RSS feed.
Excerpt string `json:"excerpt"`
// Description is an optional longer SEO description.
Description *string `json:"description"`
// ContentHash is the sha256 of the normalized markdown body — the citation/dedup anchor.
ContentHash string `json:"contentHash"`
// Version is semver.
Version string `json:"version"`
Category string `json:"category"`
Tags []string `json:"tags"`
// AuthorHandle/AuthorName denormalize the authoring profile for cards; null when authorless.
AuthorHandle *string `json:"authorHandle"`
AuthorName *string `json:"authorName"`
// AuthorAvatar is the author's avatar URL; null falls back to the brand mark.
AuthorAvatar *string `json:"authorAvatar"`
// AuthorTier is the author's seal — "official" | "verified" | null.
AuthorTier *string `json:"authorTier"`
// ListingSlug/ListingName link a post about a registry tool to that listing; null otherwise.
ListingSlug *string `json:"listingSlug"`
ListingName *string `json:"listingName"`
// CanonicalURL backlinks to the original GitHub release / RSS item; null for native posts.
CanonicalURL *string `json:"canonicalUrl"`
// Status is draft | pending | published.
Status string `json:"status"`
// ViewCount is the running page-view count.
ViewCount int `json:"viewCount"`
// PublishedAt is the canonical publish instant; null while unpublished.
PublishedAt *string `json:"publishedAt"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}

// Post is a full blog post, including the raw markdown body. Mirrors Post in
// src/lib/types.ts (PostMeta + content).
type Post struct {
PostCard
Content string `json:"content"`
}

// ResearchItem is one compact "what's new" record returned by GET /api/research
// — the payload the CLI surfaces (positronick research) so agents avoid stale
// knowledge. Mirrors ResearchItem in src/lib/types.ts. ContentHash lets a caller
Expand Down
Loading