Skip to content
Closed
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### 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.

## [0.1.2] - 2026-06-16

### Added
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)
}
}
70 changes: 70 additions & 0 deletions internal/api/research.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package api

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

// This file is the typed client for the public "what's new" read endpoint
// (GET /api/research). Unlike the soul/listing galleries — which reserve ?q=
// and filter client-side — the research endpoint filters server-side, so the
// query params travel on the wire. It is unauthenticated, read-only, and only
// ever returns published posts.

// ResearchQuery carries the optional filters for GET /api/research. The zero
// value (all fields empty, Limit 0) fetches the default feed: every published
// post, newest first, up to the server's default page size.
type ResearchQuery struct {
// Q is a free-text query over title/excerpt.
Q string
// Kind narrows to one of PostKinds (article, release, link).
Kind string
// Category narrows to one blog category.
Category string
// Tag narrows to posts carrying this tag.
Tag string
// Since is an ISO-8601 instant; only posts published strictly after it are
// returned (the delta since a previous poll).
Since string
// Limit caps the result count (server range 1–100). 0 omits the param and
// takes the server default.
Limit int
}

// ResearchResult is the GET /api/research response: the matched items plus
// Latest, the newest publishedAt within the same filter (kind/category/tag/q),
// ignoring Since. Feed Latest back as the next Since to poll only the delta;
// it is null when the filter matched nothing at all.
type ResearchResult struct {
Results []ResearchItem `json:"results"`
Latest *string `json:"latest"`
}

// Research fetches the "what's new" feed: GET /api/research. The server
// validates the params and answers 400 invalid_input (surfaced verbatim) on a
// bad kind, limit, or since value.
func (c *Client) Research(ctx context.Context, q ResearchQuery) (*ResearchResult, error) {
query := url.Values{}
for key, val := range map[string]string{
"q": q.Q,
"kind": q.Kind,
"category": q.Category,
"tag": q.Tag,
"since": q.Since,
} {
if val != "" {
query.Set(key, val)
}
}
if q.Limit > 0 {
query.Set("limit", strconv.Itoa(q.Limit))
}

var out ResearchResult
if err := c.do(ctx, http.MethodGet, "/api/research", query, nil, &out); err != nil {
return nil, err
}
return &out, nil
}
56 changes: 56 additions & 0 deletions internal/api/research_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package api

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

// Research maps its query fields to the documented wire params, omits empty
// ones (and a zero Limit), and decodes the {results, latest} envelope.
func TestResearchQueryParams(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/research" {
t.Errorf("path = %q, want /api/research", r.URL.Path)
}
_, _ = w.Write([]byte(`{"results":[{"slug":"hermes-v2-1-0","kind":"release"}],"latest":"2026-06-01T09:00:00.000Z"}`))
}))
defer srv.Close()

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

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

res, err := c.Research(context.Background(), ResearchQuery{
Q: "hermes", Kind: "release", Category: "Releases", Tag: "hermes",
Since: "2026-05-01T00:00:00.000Z", Limit: 5,
})
if err != nil {
t.Fatalf("Research(full): %v", err)
}
got := rec.last().URL.Query()
for key, want := range map[string]string{
"q": "hermes", "kind": "release", "category": "Releases",
"tag": "hermes", "since": "2026-05-01T00:00:00.000Z", "limit": "5",
} {
if got.Get(key) != want {
t.Errorf("query %q = %q, want %q", key, got.Get(key), want)
}
}

if res.Latest == nil || *res.Latest != "2026-06-01T09:00:00.000Z" {
t.Errorf("latest = %v, want the decoded high-water mark", res.Latest)
}
if len(res.Results) != 1 || res.Results[0].Slug != "hermes-v2-1-0" {
t.Errorf("results = %+v, want the decoded item", res.Results)
}
}
Loading