From 7a8acd133a28d6357c835232be7a14b0c53b1fa5 Mon Sep 17 00:00:00 2001 From: Nicholas Sollazzo Date: Mon, 29 Jun 2026 18:35:28 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20positronick=20research=20=E2=80=94?= =?UTF-8?q?=20the=20agent=20"what's=20new"=20feed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a public, read-only `research` command over GET /api/research: 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; poll just the delta with --since . The printed `latest` timestamp is the value to pass back next time (high-water mark within the same filter, ignoring --since, so polling converges). - Unauthenticated, like the soul/listing reads. Flags validated before any network call; --json and exit codes follow the existing contract. - ResearchItem mirrors src/lib/types.ts field-for-field; mockapi serves /api/research with the documented filter/latest semantics so goldens pin it. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 + internal/api/research.go | 70 ++++++++++ internal/api/research_test.go | 56 ++++++++ internal/api/types.go | 29 ++++ internal/cli/agentdocs.go | 5 +- internal/cli/golden_test.go | 4 + internal/cli/research.go | 127 ++++++++++++++++++ internal/cli/research_test.go | 126 +++++++++++++++++ internal/cli/root.go | 1 + internal/cli/testdata/golden/agent-docs.json | 37 +++++ internal/cli/testdata/golden/agent-docs.txt | 16 ++- .../cli/testdata/golden/research-kind.json | 21 +++ .../cli/testdata/golden/research-since.json | 22 +++ internal/cli/testdata/golden/research.json | 53 ++++++++ internal/cli/testdata/golden/research.txt | 4 + internal/mockapi/mockapi.go | 127 +++++++++++++++++- 16 files changed, 697 insertions(+), 5 deletions(-) create mode 100644 internal/api/research.go create mode 100644 internal/api/research_test.go create mode 100644 internal/cli/research.go create mode 100644 internal/cli/research_test.go create mode 100644 internal/cli/testdata/golden/research-kind.json create mode 100644 internal/cli/testdata/golden/research-since.json create mode 100644 internal/cli/testdata/golden/research.json create mode 100644 internal/cli/testdata/golden/research.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index f9a0513..88eed01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ 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 `; 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. + ## [0.1.2] - 2026-06-16 ### Added diff --git a/internal/api/research.go b/internal/api/research.go new file mode 100644 index 0000000..fe641f0 --- /dev/null +++ b/internal/api/research.go @@ -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 +} diff --git a/internal/api/research_test.go b/internal/api/research_test.go new file mode 100644 index 0000000..a917847 --- /dev/null +++ b/internal/api/research_test.go @@ -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) + } +} diff --git a/internal/api/types.go b/internal/api/types.go index 372a591..af9c075 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -163,3 +163,32 @@ type LoopData struct { // Kickoff is the prompt a user copies to start the loop. Kickoff string `json:"kickoff,omitempty"` } + +// PostKinds are the kinds of blog post the registry serves: an editorial +// article, a mirrored GitHub release, or a mirrored RSS/news link. Mirrors +// POST_KINDS in src/lib/types.ts. +var PostKinds = []string{"article", "release", "link"} + +// 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 +// dedup and detect silent edits; CanonicalURL traces a mirrored release/link +// back to its source; MdURL points at the raw markdown. +// +// PublishedAt and CanonicalURL are nullable in the TS contract and so are +// pointers, so null round-trips as null in --json output. +type ResearchItem struct { + Slug string `json:"slug"` + Title string `json:"title"` + Excerpt string `json:"excerpt"` + Kind string `json:"kind"` + Category string `json:"category"` + Tags []string `json:"tags"` + // URL is the absolute permalink: /blog/[slug]. + URL string `json:"url"` + // MdURL is the absolute raw-markdown endpoint: /api/blog/[slug].md. + MdURL string `json:"mdUrl"` + CanonicalURL *string `json:"canonicalUrl"` + ContentHash string `json:"contentHash"` + PublishedAt *string `json:"publishedAt"` +} diff --git a/internal/cli/agentdocs.go b/internal/cli/agentdocs.go index 1cc4df7..2049c65 100644 --- a/internal/cli/agentdocs.go +++ b/internal/cli/agentdocs.go @@ -42,8 +42,9 @@ var exitCodeMeanings = map[string]string{ } const agentDocsIntro = "`positronick` is the command-line client for positronick.com. It discovers agent " + - "capabilities: souls (installable SOUL.md personality files) and a registry of official " + - "tooling (harnesses, CLIs, MCP servers, agents, skills, plugins, loops). It is built to be " + + "capabilities: souls (installable SOUL.md personality files), a registry of official " + + "tooling (harnesses, CLIs, MCP servers, agents, skills, plugins, loops), and a `research` " + + "feed of what's new (articles, releases, links) so agents avoid stale knowledge. It is built to be " + "driven by coding agents — pass `--json` to any command for stable machine-readable JSON on " + "stdout, read progress and errors from stderr, and branch on the exit code. Read commands " + "never prompt. Hidden admin commands (create/update for souls and listings, plus " + diff --git a/internal/cli/golden_test.go b/internal/cli/golden_test.go index cd0ad08..457f7c5 100644 --- a/internal/cli/golden_test.go +++ b/internal/cli/golden_test.go @@ -37,11 +37,15 @@ var goldenCases = []struct { {"agent-search.json", []string{"agent", "search", "zzz", "--json"}}, {"loop-list.json", []string{"loop", "list", "--json"}}, {"loop-show.json", []string{"loop", "show", "pr-to-green", "--json"}}, + {"research.json", []string{"research", "--json"}}, + {"research-kind.json", []string{"research", "--kind", "release", "--json"}}, + {"research-since.json", []string{"research", "--since", "2026-06-01T09:00:00.000Z", "--json"}}, {"agent-docs.json", []string{"agent-docs", "--json"}}, // human layout {"soul-list.txt", []string{"soul", "list"}}, {"soul-show.txt", []string{"soul", "show", "sherlock"}}, {"loop-show.txt", []string{"loop", "show", "pr-to-green"}}, + {"research.txt", []string{"research"}}, {"agent-docs.txt", []string{"agent-docs"}}, } diff --git a/internal/cli/research.go b/internal/cli/research.go new file mode 100644 index 0000000..ed95782 --- /dev/null +++ b/internal/cli/research.go @@ -0,0 +1,127 @@ +package cli + +import ( + "slices" + "strings" + + "github.com/positronick/cli/internal/api" + "github.com/positronick/cli/internal/output" + "github.com/spf13/cobra" +) + +// This file owns the public `research` command — the agent-facing "what's new" +// feed over positronick.com's published blog posts (articles, mirrored +// releases, mirrored links). It is read-only and unauthenticated, mirroring the +// soul/listing read commands. Unlike those, the server does the filtering: +// --since/--kind/--category/--tag/--limit/query all travel on the wire, and the +// response carries a `latest` high-water mark to poll only the delta next time. + +const ( + // researchTitleWidth is the rune budget for the TITLE column, keeping + // PUBLISHED + KIND + TITLE + SLUG inside a 100-column terminal. + researchTitleWidth = 50 + // maxResearchLimit is the server's documented upper bound on --limit. + maxResearchLimit = 100 +) + +// researchResult is the `research --json` contract: +// {"count":N,"latest":""|null,"results":[ResearchItem...]}. +type researchResult struct { + Count int `json:"count"` + Latest *string `json:"latest"` + Results []api.ResearchItem `json:"results"` +} + +func newResearchCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "research [query]", + Short: "List what's new on positronick.com (articles, releases, links)", + Long: "Fetch the agent-facing \"what's new\" feed: published blog posts, mirrored GitHub " + + "releases, and mirrored news links, newest first. Filter with --kind/--category/--tag " + + "or a free-text query, and poll just the delta with --since — the printed " + + "`latest` timestamp is the value to pass next time. Read-only; never prompts.", + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + q, err := parseResearchQuery(cmd, args) + if err != nil { + return err + } + p, err := printerFor(cmd) + if err != nil { + return err + } + client, err := clientFor(cmd) + if err != nil { + return err + } + + res, err := client.Research(cmd.Context(), q) + if err != nil { + return err + } + if p.Mode.JSON { + return p.EmitJSON(researchResult{Count: len(res.Results), Latest: res.Latest, Results: res.Results}) + } + renderResearchTable(p, res) + return nil + }, + } + f := cmd.Flags() + f.String("kind", "", "only this post kind: article, release or link") + f.String("category", "", "only posts in this category") + f.String("tag", "", "only posts carrying this tag") + f.String("since", "", "only posts published after this ISO-8601 instant (poll the delta)") + f.Int("limit", defaultLimit, "maximum number of results (1–100)") + return cmd +} + +// parseResearchQuery reads and validates the research flags + positional query, +// failing loud — before any network call — on an out-of-range limit or an +// unknown kind, with the valid set named. +func parseResearchQuery(cmd *cobra.Command, args []string) (api.ResearchQuery, error) { + var q api.ResearchQuery + var err error + q.Q = strings.TrimSpace(strings.Join(args, " ")) + if q.Kind, err = cmd.Flags().GetString("kind"); err != nil { + return q, err + } + if q.Category, err = cmd.Flags().GetString("category"); err != nil { + return q, err + } + if q.Tag, err = cmd.Flags().GetString("tag"); err != nil { + return q, err + } + if q.Since, err = cmd.Flags().GetString("since"); err != nil { + return q, err + } + if q.Limit, err = cmd.Flags().GetInt("limit"); err != nil { + return q, err + } + if q.Limit < 1 || q.Limit > maxResearchLimit { + return q, output.Errorf("--limit must be between 1 and %d, got %d", maxResearchLimit, q.Limit) + } + if q.Kind != "" && !slices.Contains(api.PostKinds, q.Kind) { + return q, output.Errorf("invalid --kind %q (valid: %s)", q.Kind, strings.Join(api.PostKinds, ", ")) + } + return q, nil +} + +func renderResearchTable(p *output.Printer, res *api.ResearchResult) { + rows := make([][]string, len(res.Results)) + for i, it := range res.Results { + rows[i] = []string{researchDate(it.PublishedAt), it.Kind, truncateCell(it.Title, researchTitleWidth), it.Slug} + } + output.RenderTable(p.Out, []string{"PUBLISHED", "KIND", "TITLE", "SLUG"}, rows) + if res.Latest != nil { + p.Status("latest: %s — poll the delta next time with --since %s\n", *res.Latest, *res.Latest) + } +} + +// researchDate renders a post's publish instant as its calendar date (the +// leading YYYY-MM-DD of the ISO-8601 string), or "" when unset. +func researchDate(publishedAt *string) string { + if publishedAt == nil || len(*publishedAt) < 10 { + return deref(publishedAt) + } + return (*publishedAt)[:10] +} diff --git a/internal/cli/research_test.go b/internal/cli/research_test.go new file mode 100644 index 0000000..ce06988 --- /dev/null +++ b/internal/cli/research_test.go @@ -0,0 +1,126 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/positronick/cli/internal/output" +) + +// The default feed returns every published post newest-first and reports the +// newest publishedAt as `latest` — the value an agent feeds back as --since. +func TestResearchDefaultFeed(t *testing.T) { + srv := newMockServer(t) + stdout, _, code := executeAgainst(t, srv.URL, "research", "--json") + if code != 0 { + t.Fatalf("exit code = %d, want 0", code) + } + if !strings.Contains(stdout, `"count": 3`) { + t.Errorf("stdout = %q, want count 3", stdout) + } + if !strings.Contains(stdout, `"latest": "2026-06-10T08:00:00.000Z"`) { + t.Errorf("stdout = %q, want latest = newest publishedAt", stdout) + } + // Newest-first: openclaw-launch (06-10) precedes hermes (06-01) precedes the article (05-20). + oc := strings.Index(stdout, `"slug": "openclaw-launch"`) + hm := strings.Index(stdout, `"slug": "hermes-v2-1-0"`) + ar := strings.Index(stdout, `"slug": "shipping-the-cli"`) + if oc == -1 || hm == -1 || ar == -1 || oc >= hm || hm >= ar { + t.Errorf("not newest-first: openclaw@%d hermes@%d article@%d in %q", oc, hm, ar, stdout) + } +} + +// --since returns only the strictly-newer delta, but `latest` stays the feed's +// high-water mark (ignoring --since) so polling converges instead of looping. +func TestResearchSinceReturnsDelta(t *testing.T) { + srv := newMockServer(t) + stdout, _, code := executeAgainst(t, srv.URL, + "research", "--since", "2026-06-01T09:00:00.000Z", "--json") + if code != 0 { + t.Fatalf("exit code = %d, want 0", code) + } + if !strings.Contains(stdout, `"count": 1`) || !strings.Contains(stdout, `"slug": "openclaw-launch"`) { + t.Errorf("stdout = %q, want only the post newer than --since", stdout) + } + if strings.Contains(stdout, `"slug": "hermes-v2-1-0"`) { + t.Errorf("stdout = %q, the --since boundary post must be excluded (strictly-after)", stdout) + } + if !strings.Contains(stdout, `"latest": "2026-06-10T08:00:00.000Z"`) { + t.Errorf("stdout = %q, latest must be the feed high-water mark, not the delta's", stdout) + } +} + +// --kind narrows server-side; an empty filter still reports a `latest` of null, +// never a crash. +func TestResearchKindFilter(t *testing.T) { + srv := newMockServer(t) + + stdout, _, code := executeAgainst(t, srv.URL, "research", "--kind", "release", "--json") + if code != 0 { + t.Fatalf("exit code = %d, want 0", code) + } + if !strings.Contains(stdout, `"count": 1`) || !strings.Contains(stdout, `"slug": "hermes-v2-1-0"`) { + t.Errorf("stdout = %q, want only the release post", stdout) + } + + stdout, _, code = executeAgainst(t, srv.URL, "research", "--category", "nope", "--json") + if code != 0 { + t.Fatalf("empty-match exit code = %d, want 0", code) + } + if !strings.Contains(stdout, `"count": 0`) || !strings.Contains(stdout, `"latest": null`) { + t.Errorf("stdout = %q, want count 0 and latest null on an empty match", stdout) + } +} + +// Flags are validated before any network call, with the valid set named. +func TestResearchInvalidFlags(t *testing.T) { + tests := []struct { + name string + args []string + wantMessage string + }{ + { + "unknown kind", + []string{"research", "--kind", "essay"}, + `invalid --kind "essay" (valid: article, release, link)`, + }, + { + "limit too high", + []string{"research", "--limit", "500"}, + "--limit must be between 1 and 100, got 500", + }, + { + "zero limit", + []string{"research", "--limit", "0"}, + "--limit must be between 1 and 100, got 0", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Point at a dead port: validation must reject before any request. + _, stderr, code := executeAgainst(t, "http://127.0.0.1:1", tt.args...) + if code != output.ExitError { + t.Errorf("exit code = %d, want %d", code, output.ExitError) + } + if !strings.Contains(stderr, tt.wantMessage) { + t.Errorf("stderr = %q, want %q", stderr, tt.wantMessage) + } + }) + } +} + +// research is a read command: it must never require auth, and the human view +// keeps stdout pure data while the `latest` poll hint rides stderr. +func TestResearchHumanHintOnStderr(t *testing.T) { + srv := newMockServer(t) + stdout, stderr, code := executeAgainst(t, srv.URL, "research") + if code != 0 { + t.Fatalf("exit code = %d, want 0", code) + } + if !strings.Contains(stderr, "--since") || !strings.Contains(stderr, "latest:") { + t.Errorf("stderr = %q, want the latest/--since poll hint", stderr) + } + if strings.Contains(stdout, "latest:") { + t.Errorf("stdout = %q, must stay pure tabular data", stdout) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 29f947c..2a8979d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -37,6 +37,7 @@ func NewRootCmd() *cobra.Command { root.AddCommand(newListingNounCmd(listingType)) } root.AddCommand(newLoginCmd(), newLogoutCmd(), newAuthCmd()) + root.AddCommand(newResearchCmd()) root.AddCommand(newAgentDocsCmd()) attachInstallCommands(root) registerAdminCommands(root) // hidden write commands; revealed for cached admins diff --git a/internal/cli/testdata/golden/agent-docs.json b/internal/cli/testdata/golden/agent-docs.json index f95e646..2b924bb 100644 --- a/internal/cli/testdata/golden/agent-docs.json +++ b/internal/cli/testdata/golden/agent-docs.json @@ -632,6 +632,43 @@ "description": "Show one plugin listing in full", "flags": [] }, + { + "path": "positronick research", + "use": "positronick research [query] [flags]", + "description": "List what's new on positronick.com (articles, releases, links)", + "flags": [ + { + "name": "category", + "shorthand": "", + "usage": "only posts in this category", + "default": "" + }, + { + "name": "kind", + "shorthand": "", + "usage": "only this post kind: article, release or link", + "default": "" + }, + { + "name": "limit", + "shorthand": "", + "usage": "maximum number of results (1–100)", + "default": "20" + }, + { + "name": "since", + "shorthand": "", + "usage": "only posts published after this ISO-8601 instant (poll the delta)", + "default": "" + }, + { + "name": "tag", + "shorthand": "", + "usage": "only posts carrying this tag", + "default": "" + } + ] + }, { "path": "positronick self", "use": "positronick self [flags]", diff --git a/internal/cli/testdata/golden/agent-docs.txt b/internal/cli/testdata/golden/agent-docs.txt index 3b52572..fc9a5ee 100644 --- a/internal/cli/testdata/golden/agent-docs.txt +++ b/internal/cli/testdata/golden/agent-docs.txt @@ -1,6 +1,6 @@ # positronick — agent manual -`positronick` is the command-line client for positronick.com. It discovers agent capabilities: souls (installable SOUL.md personality files) and a registry of official tooling (harnesses, CLIs, MCP servers, agents, skills, plugins, loops). It is built to be driven by coding agents — pass `--json` to any command for stable machine-readable JSON on stdout, read progress and errors from stderr, and branch on the exit code. Read commands never prompt. Hidden admin commands (create/update for souls and listings, plus create/list for profiles) exist and appear in help and in these docs after logging in with an admin account. +`positronick` is the command-line client for positronick.com. It discovers agent capabilities: souls (installable SOUL.md personality files), a registry of official tooling (harnesses, CLIs, MCP servers, agents, skills, plugins, loops), and a `research` feed of what's new (articles, releases, links) so agents avoid stale knowledge. It is built to be driven by coding agents — pass `--json` to any command for stable machine-readable JSON on stdout, read progress and errors from stderr, and branch on the exit code. Read commands never prompt. Hidden admin commands (create/update for souls and listings, plus create/list for profiles) exist and appear in help and in these docs after logging in with an admin account. Exit codes: @@ -424,6 +424,20 @@ Usage: `positronick plugin show [flags]` Show one plugin listing in full +## positronick research + +Usage: `positronick research [query] [flags]` + +List what's new on positronick.com (articles, releases, links) + +Flags: + +- `--category` (default ``): only posts in this category +- `--kind` (default ``): only this post kind: article, release or link +- `--limit` (default `20`): maximum number of results (1–100) +- `--since` (default ``): only posts published after this ISO-8601 instant (poll the delta) +- `--tag` (default ``): only posts carrying this tag + ## positronick self Usage: `positronick self [flags]` diff --git a/internal/cli/testdata/golden/research-kind.json b/internal/cli/testdata/golden/research-kind.json new file mode 100644 index 0000000..e172804 --- /dev/null +++ b/internal/cli/testdata/golden/research-kind.json @@ -0,0 +1,21 @@ +{ + "count": 1, + "latest": "2026-06-01T09:00:00.000Z", + "results": [ + { + "slug": "hermes-v2-1-0", + "title": "Hermes v2.1.0", + "excerpt": "Tool-calling fixes and a faster device flow.", + "kind": "release", + "category": "Releases", + "tags": [ + "hermes" + ], + "url": "https://positronick.com/blog/hermes-v2-1-0", + "mdUrl": "https://positronick.com/api/blog/hermes-v2-1-0.md", + "canonicalUrl": "https://github.com/NousResearch/hermes/releases/tag/v2.1.0", + "contentHash": "eeee5555eeee5555eeee5555eeee5555eeee5555eeee5555eeee5555eeee5555", + "publishedAt": "2026-06-01T09:00:00.000Z" + } + ] +} diff --git a/internal/cli/testdata/golden/research-since.json b/internal/cli/testdata/golden/research-since.json new file mode 100644 index 0000000..db737a3 --- /dev/null +++ b/internal/cli/testdata/golden/research-since.json @@ -0,0 +1,22 @@ +{ + "count": 1, + "latest": "2026-06-10T08:00:00.000Z", + "results": [ + { + "slug": "openclaw-launch", + "title": "OpenClaw launches its agent harness", + "excerpt": "A new open-source harness joins the registry.", + "kind": "link", + "category": "Community", + "tags": [ + "openclaw", + "news" + ], + "url": "https://positronick.com/blog/openclaw-launch", + "mdUrl": "https://positronick.com/api/blog/openclaw-launch.md", + "canonicalUrl": "https://example.com/openclaw-launch", + "contentHash": "ffff6666ffff6666ffff6666ffff6666ffff6666ffff6666ffff6666ffff6666", + "publishedAt": "2026-06-10T08:00:00.000Z" + } + ] +} diff --git a/internal/cli/testdata/golden/research.json b/internal/cli/testdata/golden/research.json new file mode 100644 index 0000000..6fed464 --- /dev/null +++ b/internal/cli/testdata/golden/research.json @@ -0,0 +1,53 @@ +{ + "count": 3, + "latest": "2026-06-10T08:00:00.000Z", + "results": [ + { + "slug": "openclaw-launch", + "title": "OpenClaw launches its agent harness", + "excerpt": "A new open-source harness joins the registry.", + "kind": "link", + "category": "Community", + "tags": [ + "openclaw", + "news" + ], + "url": "https://positronick.com/blog/openclaw-launch", + "mdUrl": "https://positronick.com/api/blog/openclaw-launch.md", + "canonicalUrl": "https://example.com/openclaw-launch", + "contentHash": "ffff6666ffff6666ffff6666ffff6666ffff6666ffff6666ffff6666ffff6666", + "publishedAt": "2026-06-10T08:00:00.000Z" + }, + { + "slug": "hermes-v2-1-0", + "title": "Hermes v2.1.0", + "excerpt": "Tool-calling fixes and a faster device flow.", + "kind": "release", + "category": "Releases", + "tags": [ + "hermes" + ], + "url": "https://positronick.com/blog/hermes-v2-1-0", + "mdUrl": "https://positronick.com/api/blog/hermes-v2-1-0.md", + "canonicalUrl": "https://github.com/NousResearch/hermes/releases/tag/v2.1.0", + "contentHash": "eeee5555eeee5555eeee5555eeee5555eeee5555eeee5555eeee5555eeee5555", + "publishedAt": "2026-06-01T09:00:00.000Z" + }, + { + "slug": "shipping-the-cli", + "title": "Shipping the Positronick CLI", + "excerpt": "Install souls and browse the registry from your terminal.", + "kind": "article", + "category": "Engineering", + "tags": [ + "cli", + "launch" + ], + "url": "https://positronick.com/blog/shipping-the-cli", + "mdUrl": "https://positronick.com/api/blog/shipping-the-cli.md", + "canonicalUrl": null, + "contentHash": "dddd4444dddd4444dddd4444dddd4444dddd4444dddd4444dddd4444dddd4444", + "publishedAt": "2026-05-20T12:00:00.000Z" + } + ] +} diff --git a/internal/cli/testdata/golden/research.txt b/internal/cli/testdata/golden/research.txt new file mode 100644 index 0000000..030a53b --- /dev/null +++ b/internal/cli/testdata/golden/research.txt @@ -0,0 +1,4 @@ +PUBLISHED KIND TITLE SLUG +2026-06-10 link OpenClaw launches its agent harness openclaw-launch +2026-06-01 release Hermes v2.1.0 hermes-v2-1-0 +2026-05-20 article Shipping the Positronick CLI shipping-the-cli diff --git a/internal/mockapi/mockapi.go b/internal/mockapi/mockapi.go index 77730f1..88e67c9 100644 --- a/internal/mockapi/mockapi.go +++ b/internal/mockapi/mockapi.go @@ -1,8 +1,8 @@ // Package mockapi serves a fixed positronick.com API fixture over // net/http/httptest-compatible handlers, implementing the read contract // (GET /api/souls, /api/souls/{slug}, /api/listings(?type=), -// /api/listings/{slug}) and the auth contract (device flow, /api/me, -// api-key/create) for the CLI's golden and e2e tests. The dataset is +// /api/listings/{slug}, /api/research) and the auth contract (device flow, +// /api/me, api-key/create) for the CLI's golden and e2e tests. The dataset is // deliberately frozen: golden files pin command output byte-for-byte against // it, so changing a fixture value is a contract-test change. package mockapi @@ -11,6 +11,9 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" + "sort" + "strconv" "strings" "sync" @@ -263,6 +266,53 @@ var Listings = []api.Listing{ }, } +// ResearchPosts is the fixture "what's new" feed: one of each post kind +// (article, release, link) with deliberately different publish dates, +// categories and tags so --since/--kind/--category/--tag and the `latest` +// high-water mark each have something to disagree about. Ordered oldest-first; +// the handler sorts newest-first like the server. +var ResearchPosts = []api.ResearchItem{ + { + Slug: "shipping-the-cli", + Title: "Shipping the Positronick CLI", + Excerpt: "Install souls and browse the registry from your terminal.", + Kind: "article", + Category: "Engineering", + Tags: []string{"cli", "launch"}, + URL: "https://positronick.com/blog/shipping-the-cli", + MdURL: "https://positronick.com/api/blog/shipping-the-cli.md", + CanonicalURL: nil, + ContentHash: "dddd4444dddd4444dddd4444dddd4444dddd4444dddd4444dddd4444dddd4444", + PublishedAt: ptr("2026-05-20T12:00:00.000Z"), + }, + { + Slug: "hermes-v2-1-0", + Title: "Hermes v2.1.0", + Excerpt: "Tool-calling fixes and a faster device flow.", + Kind: "release", + Category: "Releases", + Tags: []string{"hermes"}, + URL: "https://positronick.com/blog/hermes-v2-1-0", + MdURL: "https://positronick.com/api/blog/hermes-v2-1-0.md", + CanonicalURL: ptr("https://github.com/NousResearch/hermes/releases/tag/v2.1.0"), + ContentHash: "eeee5555eeee5555eeee5555eeee5555eeee5555eeee5555eeee5555eeee5555", + PublishedAt: ptr("2026-06-01T09:00:00.000Z"), + }, + { + Slug: "openclaw-launch", + Title: "OpenClaw launches its agent harness", + Excerpt: "A new open-source harness joins the registry.", + Kind: "link", + Category: "Community", + Tags: []string{"openclaw", "news"}, + URL: "https://positronick.com/blog/openclaw-launch", + MdURL: "https://positronick.com/api/blog/openclaw-launch.md", + CanonicalURL: ptr("https://example.com/openclaw-launch"), + ContentHash: "ffff6666ffff6666ffff6666ffff6666ffff6666ffff6666ffff6666ffff6666", + PublishedAt: ptr("2026-06-10T08:00:00.000Z"), + }, +} + // Handler returns an http.Handler implementing the read API over the fixture // data, including the server's JSON error envelope on 404 and on an unknown // ?type=. Requests to /api/souls/{slug}.md are answered with 418 — the .md @@ -323,6 +373,10 @@ func Handler() http.Handler { writeError(w, http.StatusNotFound, "not_found", "Listing not found") }) + mux.HandleFunc("GET /api/research", func(w http.ResponseWriter, r *http.Request) { + serveResearch(w, r.URL.Query()) + }) + mux.HandleFunc("POST /api/auth/device/code", func(w http.ResponseWriter, r *http.Request) { var body struct { ClientID string `json:"client_id"` @@ -438,6 +492,75 @@ func InstallHandler() http.Handler { }) } +// serveResearch implements GET /api/research over ResearchPosts: it filters by +// kind/category/tag/q, computes `latest` (the newest publishedAt within that +// filter, before `since`), then applies `since` and `limit` to the newest-first +// results — the same semantics the server documents, so golden output pins them. +func serveResearch(w http.ResponseWriter, sp url.Values) { + kind, category, tag := sp.Get("kind"), sp.Get("category"), sp.Get("tag") + q := strings.ToLower(sp.Get("q")) + + filtered := make([]api.ResearchItem, 0, len(ResearchPosts)) + for _, it := range ResearchPosts { + if kind != "" && !strings.EqualFold(it.Kind, kind) { + continue + } + if category != "" && !strings.EqualFold(it.Category, category) { + continue + } + if tag != "" && !containsFoldStr(it.Tags, tag) { + continue + } + if q != "" && !strings.Contains(strings.ToLower(it.Title), q) && + !strings.Contains(strings.ToLower(it.Excerpt), q) { + continue + } + filtered = append(filtered, it) + } + + var latest *string + for _, it := range filtered { + if it.PublishedAt != nil && (latest == nil || *it.PublishedAt > *latest) { + latest = it.PublishedAt + } + } + + since := sp.Get("since") + results := make([]api.ResearchItem, 0, len(filtered)) + for _, it := range filtered { + if since != "" && (it.PublishedAt == nil || *it.PublishedAt <= since) { + continue + } + results = append(results, it) + } + sort.SliceStable(results, func(i, j int) bool { + return derefStr(results[i].PublishedAt) > derefStr(results[j].PublishedAt) + }) + if n, err := strconv.Atoi(sp.Get("limit")); err == nil && n >= 0 && n < len(results) { + results = results[:n] + } + + writeJSON(w, http.StatusOK, map[string]any{"results": results, "latest": latest}) +} + +// containsFoldStr reports whether vals contains want, case-insensitively. +func containsFoldStr(vals []string, want string) bool { + for _, v := range vals { + if strings.EqualFold(v, want) { + return true + } + } + return false +} + +// derefStr maps a nil *string to "" for ordering. +func derefStr(s *string) string { + if s == nil { + return "" + } + return *s +} + // authorized reports whether the request carries the fixture session token or // the fixture API key. func authorized(r *http.Request) bool { From 591aedeff85f0efdae6006bb092210a12babefcf Mon Sep 17 00:00:00 2001 From: Nicholas Sollazzo Date: Mon, 29 Jun 2026 21:32:31 +0200 Subject: [PATCH 2/2] feat: positronick blog read commands (list/show) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public, read-only blog reader over the product blog endpoints, mirroring the soul/listing reads and the `research` feed: - api: PostCard/Post wire types (mirror PostMeta/Post in src/lib/types.ts); Posts(kind)/Post(slug)/PostMarkdown(slug) over GET /api/blog, /api/blog/{slug}, and /api/blog/{slug}.md. - cli: `blog list` (--kind) and `blog show ` (--raw), with did-you-mean hints (exit 3) on a missing slug. --raw reads the .md endpoint, which — unlike soul .md — never bumps a counter. - mockapi: serves the three endpoints over a frozen Posts fixture. - goldens: blog-list/blog-show (.json/.txt) + regenerated agent-docs. Reuses PostKinds from the research branch (#24); stacked on it. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + internal/api/blog.go | 57 ++++++ internal/api/blog_test.go | 113 +++++++++++ internal/api/types.go | 56 ++++++ internal/cli/blog.go | 190 +++++++++++++++++++ internal/cli/blog_test.go | 134 +++++++++++++ internal/cli/golden_test.go | 3 + internal/cli/root.go | 1 + internal/cli/testdata/golden/agent-docs.json | 32 ++++ internal/cli/testdata/golden/agent-docs.txt | 26 +++ internal/cli/testdata/golden/blog-list.json | 89 +++++++++ internal/cli/testdata/golden/blog-show.json | 31 +++ internal/cli/testdata/golden/blog-show.txt | 15 ++ internal/mockapi/mockapi.go | 155 ++++++++++++++- 14 files changed, 901 insertions(+), 2 deletions(-) create mode 100644 internal/api/blog.go create mode 100644 internal/api/blog_test.go create mode 100644 internal/cli/blog.go create mode 100644 internal/cli/blog_test.go create mode 100644 internal/cli/testdata/golden/blog-list.json create mode 100644 internal/cli/testdata/golden/blog-show.json create mode 100644 internal/cli/testdata/golden/blog-show.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 88eed01..0721280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `; 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 `, 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 diff --git a/internal/api/blog.go b/internal/api/blog.go new file mode 100644 index 0000000..c247f45 --- /dev/null +++ b/internal/api/blog.go @@ -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 +} diff --git a/internal/api/blog_test.go b/internal/api/blog_test.go new file mode 100644 index 0000000..4c809ab --- /dev/null +++ b/internal/api/blog_test.go @@ -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) + } +} diff --git a/internal/api/types.go b/internal/api/types.go index af9c075..b94ef19 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -169,6 +169,62 @@ type LoopData 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 diff --git a/internal/cli/blog.go b/internal/cli/blog.go new file mode 100644 index 0000000..81772cc --- /dev/null +++ b/internal/cli/blog.go @@ -0,0 +1,190 @@ +package cli + +import ( + "context" + "fmt" + "slices" + "strconv" + "strings" + + "github.com/positronick/cli/internal/api" + "github.com/positronick/cli/internal/output" + "github.com/spf13/cobra" +) + +// This file owns the public `blog` command — read-only, unauthenticated access +// to positronick.com's published blog: editorial articles, mirrored GitHub +// releases, and mirrored news links. It mirrors the soul/listing reads: +// `blog list` (optionally filtered by --kind) and `blog show ` with a +// --raw flag that prints the markdown body verbatim. Like the souls gallery, +// the server returns posts newest-first. + +// blogListResult is the `blog list --json` contract: {"count":N,"posts":[...]}. +type blogListResult struct { + Count int `json:"count"` + Posts []api.PostCard `json:"posts"` +} + +// blogDetail is the `blog show --json` contract: {"post":{...}}. +type blogDetail struct { + Post api.Post `json:"post"` +} + +func newBlogCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "blog", + Short: "Read the positronick.com blog (articles, releases, links)", + } + cmd.AddCommand(newBlogListCmd(), newBlogShowCmd()) + return cmd +} + +func newBlogListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List published blog posts, newest first", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + kind, err := cmd.Flags().GetString("kind") + if err != nil { + return err + } + // Validate before any network call, naming the valid set (the server + // would 400, but failing loud locally is the agent-friendlier path). + if kind != "" && !slices.Contains(api.PostKinds, kind) { + return output.Errorf("invalid --kind %q (valid: %s)", kind, strings.Join(api.PostKinds, ", ")) + } + p, err := printerFor(cmd) + if err != nil { + return err + } + client, err := clientFor(cmd) + if err != nil { + return err + } + posts, err := client.Posts(cmd.Context(), kind) + if err != nil { + return err + } + if p.Mode.JSON { + return p.EmitJSON(blogListResult{Count: len(posts), Posts: posts}) + } + renderBlogTable(p, posts) + return nil + }, + } + cmd.Flags().String("kind", "", "only this post kind: article, release or link") + return cmd +} + +func renderBlogTable(p *output.Printer, posts []api.PostCard) { + rows := make([][]string, len(posts)) + for i, post := range posts { + rows[i] = []string{researchDate(post.PublishedAt), post.Kind, truncateCell(post.Title, researchTitleWidth), post.Slug} + } + output.RenderTable(p.Out, []string{"PUBLISHED", "KIND", "TITLE", "SLUG"}, rows) +} + +func newBlogShowCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "show ", + Short: "Show one blog post in full", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + raw, err := cmd.Flags().GetBool("raw") + if err != nil { + return err + } + p, err := printerFor(cmd) + if err != nil { + return err + } + client, err := clientFor(cmd) + if err != nil { + return err + } + + slug := args[0] + // --raw reads the .md endpoint: for blog posts it never bumps a + // counter (unlike soul .md, which is why soul --raw uses the JSON + // body), so it is the natural source of the verbatim markdown. + if raw { + md, err := client.PostMarkdown(cmd.Context(), slug) + if api.IsNotFound(err) { + return blogNotFound(cmd.Context(), client, slug) + } + if err != nil { + return err + } + p.Human("%s", md) + return nil + } + + post, err := client.Post(cmd.Context(), slug) + if api.IsNotFound(err) { + return blogNotFound(cmd.Context(), client, slug) + } + if err != nil { + return err + } + if p.Mode.JSON { + return p.EmitJSON(blogDetail{Post: *post}) + } + renderBlogDetail(p, post) + return nil + }, + } + cmd.Flags().Bool("raw", false, "print only the markdown body verbatim (overrides --json)") + return cmd +} + +// blogNotFound builds the exit-3 error for a missing slug, with a did-you-mean +// hint when the gallery has a plausible neighbor. A failing suggestion fetch +// never masks the original not-found. Mirrors soulNotFound. +func blogNotFound(ctx context.Context, client *api.Client, slug string) error { + hint := "Run: positronick blog list" + if posts, err := client.Posts(ctx, ""); err == nil { + slugs := make([]string, len(posts)) + for i, post := range posts { + slugs[i] = post.Slug + } + if match := closestSlug(slug, slugs); match == slug { + // The list knows the slug but the detail fetch 404'd: an older server, + // not a typo — say so rather than suggesting the input back. + hint = "the server lists this post but could not return its details — positronick.com may be running an older API" + } else if match != "" { + hint = fmt.Sprintf("did you mean %q? %s", match, hint) + } + } + return output.NotFoundError(fmt.Sprintf("post %q not found", slug), hint) +} + +func renderBlogDetail(p *output.Printer, post *api.Post) { + author := "" + if post.AuthorHandle != nil { + author = handleLabel(*post.AuthorHandle, deref(post.AuthorName)) + } + listing := deref(post.ListingSlug) + if post.ListingName != nil && listing != "" { + listing = fmt.Sprintf("%s (%s)", *post.ListingName, listing) + } + output.RenderFields(p.Out, fieldRows( + "TITLE", post.Title, + "SLUG", post.Slug, + "KIND", post.Kind, + "AUTHOR", author, + "EXCERPT", post.Excerpt, + "DESCRIPTION", deref(post.Description), + "CATEGORY", post.Category, + "TAGS", strings.Join(post.Tags, ", "), + "VERSION", post.Version, + "LISTING", listing, + "CANONICAL", deref(post.CanonicalURL), + "STATUS", post.Status, + "VIEWS", strconv.Itoa(post.ViewCount), + "PUBLISHED", deref(post.PublishedAt), + "CREATED", post.CreatedAt, + "UPDATED", post.UpdatedAt, + )) + p.Status("hint: run `positronick blog show %s --raw` to print the markdown body\n", post.Slug) +} diff --git a/internal/cli/blog_test.go b/internal/cli/blog_test.go new file mode 100644 index 0000000..389e440 --- /dev/null +++ b/internal/cli/blog_test.go @@ -0,0 +1,134 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/positronick/cli/internal/mockapi" + "github.com/positronick/cli/internal/output" +) + +// blog list returns every published post newest-first; --json pins the +// {count,posts} contract. +func TestBlogListNewestFirst(t *testing.T) { + srv := newMockServer(t) + stdout, _, code := executeAgainst(t, srv.URL, "blog", "list", "--json") + if code != 0 { + t.Fatalf("exit code = %d, want 0", code) + } + if !strings.Contains(stdout, `"count": 3`) { + t.Errorf("stdout = %q, want count 3", stdout) + } + // Newest-first: openclaw (06-10) precedes the cli release (06-01) precedes the intro article (05-20). + oc := strings.Index(stdout, `"slug": "openclaw-joins-the-registry"`) + cli := strings.Index(stdout, `"slug": "positronick-cli-v0-1-0"`) + intro := strings.Index(stdout, `"slug": "introducing-positronick"`) + if oc == -1 || cli == -1 || intro == -1 || oc >= cli || cli >= intro { + t.Errorf("not newest-first: openclaw@%d cli@%d intro@%d in %q", oc, cli, intro, stdout) + } +} + +// --kind narrows server-side to one post kind; an unknown kind is rejected +// before any network call, with the valid set named. +func TestBlogListKindFilter(t *testing.T) { + srv := newMockServer(t) + stdout, _, code := executeAgainst(t, srv.URL, "blog", "list", "--kind", "release", "--json") + if code != 0 { + t.Fatalf("exit code = %d, want 0", code) + } + if !strings.Contains(stdout, `"count": 1`) || !strings.Contains(stdout, `"slug": "positronick-cli-v0-1-0"`) { + t.Errorf("stdout = %q, want only the release post", stdout) + } + + // Point at a dead port: validation must reject before any request. + _, stderr, code := executeAgainst(t, "http://127.0.0.1:1", "blog", "list", "--kind", "essay") + if code != output.ExitError { + t.Errorf("exit code = %d, want %d", code, output.ExitError) + } + if want := `invalid --kind "essay" (valid: article, release, link)`; !strings.Contains(stderr, want) { + t.Errorf("stderr = %q, want %q", stderr, want) + } +} + +// --raw prints the markdown body verbatim and nothing else — even when --json +// is also set — and it comes from the .md endpoint (the frontmatter the JSON +// `content` lacks proves the source). +func TestBlogShowRaw(t *testing.T) { + srv := newMockServer(t) + + var want string + for _, p := range mockapi.Posts { + if p.Slug == "positronick-cli-v0-1-0" { + want = mockapi.PostMarkdown(p) + } + } + if !strings.HasPrefix(want, "---\n") { + t.Fatalf("fixture markdown should carry frontmatter, got %q", want) + } + + for _, args := range [][]string{ + {"blog", "show", "positronick-cli-v0-1-0", "--raw"}, + {"blog", "show", "positronick-cli-v0-1-0", "--raw", "--json"}, + } { + stdout, _, code := executeAgainst(t, srv.URL, args...) + if code != 0 { + t.Fatalf("%v exit code = %d, want 0", args, code) + } + if stdout != want { + t.Errorf("%v stdout = %q, want the verbatim .md body %q", args, stdout, want) + } + } +} + +// A show on a missing slug is exit 3 with the exact did-you-mean envelope — the +// load-bearing agent contract for typo recovery. +func TestBlogShowNotFoundDidYouMean(t *testing.T) { + srv := newMockServer(t) + + t.Run("typo gets a suggestion", func(t *testing.T) { + stdout, stderr, code := executeAgainst(t, srv.URL, "blog", "show", "positronik-cli-v0-1-0", "--json") + if code != output.ExitNotFound { + t.Fatalf("exit code = %d, want %d", code, output.ExitNotFound) + } + if stdout != "" { + t.Errorf("stdout must stay clean on error, got %q", stdout) + } + want := `{"error":{"code":"not_found","message":"post \"positronik-cli-v0-1-0\" not found","hint":"did you mean \"positronick-cli-v0-1-0\"? Run: positronick blog list"}}` + "\n" + if stderr != want { + t.Errorf("stderr = %q, want %q", stderr, want) + } + }) + + t.Run("no plausible suggestion still points at list", func(t *testing.T) { + _, stderr, code := executeAgainst(t, srv.URL, "blog", "show", "zzzzzzzzz", "--json") + if code != output.ExitNotFound { + t.Fatalf("exit code = %d, want %d", code, output.ExitNotFound) + } + want := `{"error":{"code":"not_found","message":"post \"zzzzzzzzz\" not found","hint":"Run: positronick blog list"}}` + "\n" + if stderr != want { + t.Errorf("stderr = %q, want %q", stderr, want) + } + }) +} + +// Human show keeps stdout pure data: the --raw hint goes to stderr, and --quiet +// removes it. +func TestBlogShowHintOnStderr(t *testing.T) { + srv := newMockServer(t) + + stdout, stderr, code := executeAgainst(t, srv.URL, "blog", "show", "positronick-cli-v0-1-0") + if code != 0 { + t.Fatalf("exit code = %d, want 0", code) + } + if !strings.Contains(stderr, "--raw") { + t.Errorf("stderr = %q, want the --raw hint", stderr) + } + if strings.Contains(stdout, "--raw") { + t.Errorf("stdout = %q, must not carry the hint", stdout) + } + + _, stderr, _ = executeAgainst(t, srv.URL, "blog", "show", "positronick-cli-v0-1-0", "--quiet") + if stderr != "" { + t.Errorf("--quiet stderr = %q, want empty", stderr) + } +} diff --git a/internal/cli/golden_test.go b/internal/cli/golden_test.go index 457f7c5..18d3a48 100644 --- a/internal/cli/golden_test.go +++ b/internal/cli/golden_test.go @@ -40,12 +40,15 @@ var goldenCases = []struct { {"research.json", []string{"research", "--json"}}, {"research-kind.json", []string{"research", "--kind", "release", "--json"}}, {"research-since.json", []string{"research", "--since", "2026-06-01T09:00:00.000Z", "--json"}}, + {"blog-list.json", []string{"blog", "list", "--json"}}, + {"blog-show.json", []string{"blog", "show", "positronick-cli-v0-1-0", "--json"}}, {"agent-docs.json", []string{"agent-docs", "--json"}}, // human layout {"soul-list.txt", []string{"soul", "list"}}, {"soul-show.txt", []string{"soul", "show", "sherlock"}}, {"loop-show.txt", []string{"loop", "show", "pr-to-green"}}, {"research.txt", []string{"research"}}, + {"blog-show.txt", []string{"blog", "show", "positronick-cli-v0-1-0"}}, {"agent-docs.txt", []string{"agent-docs"}}, } diff --git a/internal/cli/root.go b/internal/cli/root.go index 2a8979d..e763cd8 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -38,6 +38,7 @@ func NewRootCmd() *cobra.Command { } root.AddCommand(newLoginCmd(), newLogoutCmd(), newAuthCmd()) root.AddCommand(newResearchCmd()) + root.AddCommand(newBlogCmd()) root.AddCommand(newAgentDocsCmd()) attachInstallCommands(root) registerAdminCommands(root) // hidden write commands; revealed for cached admins diff --git a/internal/cli/testdata/golden/agent-docs.json b/internal/cli/testdata/golden/agent-docs.json index 2b924bb..027dfff 100644 --- a/internal/cli/testdata/golden/agent-docs.json +++ b/internal/cli/testdata/golden/agent-docs.json @@ -155,6 +155,38 @@ } ] }, + { + "path": "positronick blog", + "use": "positronick blog [flags]", + "description": "Read the positronick.com blog (articles, releases, links)", + "flags": [] + }, + { + "path": "positronick blog list", + "use": "positronick blog list [flags]", + "description": "List published blog posts, newest first", + "flags": [ + { + "name": "kind", + "shorthand": "", + "usage": "only this post kind: article, release or link", + "default": "" + } + ] + }, + { + "path": "positronick blog show", + "use": "positronick blog show \u003cslug\u003e [flags]", + "description": "Show one blog post in full", + "flags": [ + { + "name": "raw", + "shorthand": "", + "usage": "print only the markdown body verbatim (overrides --json)", + "default": "false" + } + ] + }, { "path": "positronick cli", "use": "positronick cli [flags]", diff --git a/internal/cli/testdata/golden/agent-docs.txt b/internal/cli/testdata/golden/agent-docs.txt index fc9a5ee..459a8ae 100644 --- a/internal/cli/testdata/golden/agent-docs.txt +++ b/internal/cli/testdata/golden/agent-docs.txt @@ -115,6 +115,32 @@ Flags: - `--expires-days` (default `0`): key lifetime in days, 1-365 (0 = server default, 90) - `--name` (default `positronick-cli`): name for the new key +## positronick blog + +Usage: `positronick blog [flags]` + +Read the positronick.com blog (articles, releases, links) + +## positronick blog list + +Usage: `positronick blog list [flags]` + +List published blog posts, newest first + +Flags: + +- `--kind` (default ``): only this post kind: article, release or link + +## positronick blog show + +Usage: `positronick blog show [flags]` + +Show one blog post in full + +Flags: + +- `--raw` (default `false`): print only the markdown body verbatim (overrides --json) + ## positronick cli Usage: `positronick cli [flags]` diff --git a/internal/cli/testdata/golden/blog-list.json b/internal/cli/testdata/golden/blog-list.json new file mode 100644 index 0000000..5b525cc --- /dev/null +++ b/internal/cli/testdata/golden/blog-list.json @@ -0,0 +1,89 @@ +{ + "count": 3, + "posts": [ + { + "id": "01POSTOPENCLAWLINK0000000X", + "slug": "openclaw-joins-the-registry", + "slugHistory": [], + "kind": "link", + "title": "OpenClaw joins the registry", + "excerpt": "A new open-source agent harness is now listed on Positronick.", + "description": null, + "contentHash": "3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc", + "version": "1.0.0", + "category": "Community", + "tags": [ + "openclaw", + "news" + ], + "authorHandle": null, + "authorName": null, + "authorAvatar": null, + "authorTier": null, + "listingSlug": null, + "listingName": null, + "canonicalUrl": "https://example.com/openclaw-joins", + "status": "published", + "viewCount": 9, + "publishedAt": "2026-06-10T08:00:00.000Z", + "createdAt": "2026-06-10T08:00:00.000Z", + "updatedAt": "2026-06-10T08:00:00.000Z" + }, + { + "id": "01POSTCLIRELEASE000000000X", + "slug": "positronick-cli-v0-1-0", + "slugHistory": [], + "kind": "release", + "title": "Positronick CLI v0.1.0", + "excerpt": "Install souls and browse the registry from your terminal.", + "description": null, + "contentHash": "2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb", + "version": "1.0.0", + "category": "Releases", + "tags": [ + "cli", + "release" + ], + "authorHandle": "nsollazzo", + "authorName": "Nicholas Sollazzo", + "authorAvatar": null, + "authorTier": "verified", + "listingSlug": "positronick-cli", + "listingName": "Positronick CLI", + "canonicalUrl": "https://github.com/positronick/cli/releases/tag/v0.1.0", + "status": "published", + "viewCount": 42, + "publishedAt": "2026-06-01T09:00:00.000Z", + "createdAt": "2026-06-01T09:00:00.000Z", + "updatedAt": "2026-06-01T09:00:00.000Z" + }, + { + "id": "01POSTINTRO0000000000000XX", + "slug": "introducing-positronick", + "slugHistory": [], + "kind": "article", + "title": "Introducing Positronick", + "excerpt": "Publish once, discover and use anywhere — the marketplace for everything AI.", + "description": "Why we built a provider- and framework-agnostic registry for souls, MCP servers, CLIs, agents, skills and more.", + "contentHash": "1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa", + "version": "1.0.0", + "category": "Announcements", + "tags": [ + "launch", + "registry" + ], + "authorHandle": "positronick", + "authorName": "Positronick", + "authorAvatar": "https://example.com/positronick.png", + "authorTier": "official", + "listingSlug": null, + "listingName": null, + "canonicalUrl": null, + "status": "published", + "viewCount": 128, + "publishedAt": "2026-05-20T12:00:00.000Z", + "createdAt": "2026-05-20T12:00:00.000Z", + "updatedAt": "2026-05-21T09:00:00.000Z" + } + ] +} diff --git a/internal/cli/testdata/golden/blog-show.json b/internal/cli/testdata/golden/blog-show.json new file mode 100644 index 0000000..c6045d9 --- /dev/null +++ b/internal/cli/testdata/golden/blog-show.json @@ -0,0 +1,31 @@ +{ + "post": { + "id": "01POSTCLIRELEASE000000000X", + "slug": "positronick-cli-v0-1-0", + "slugHistory": [], + "kind": "release", + "title": "Positronick CLI v0.1.0", + "excerpt": "Install souls and browse the registry from your terminal.", + "description": null, + "contentHash": "2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb", + "version": "1.0.0", + "category": "Releases", + "tags": [ + "cli", + "release" + ], + "authorHandle": "nsollazzo", + "authorName": "Nicholas Sollazzo", + "authorAvatar": null, + "authorTier": "verified", + "listingSlug": "positronick-cli", + "listingName": "Positronick CLI", + "canonicalUrl": "https://github.com/positronick/cli/releases/tag/v0.1.0", + "status": "published", + "viewCount": 42, + "publishedAt": "2026-06-01T09:00:00.000Z", + "createdAt": "2026-06-01T09:00:00.000Z", + "updatedAt": "2026-06-01T09:00:00.000Z", + "content": "# Positronick CLI v0.1.0\n\nThe first public release of the command-line client.\n" + } +} diff --git a/internal/cli/testdata/golden/blog-show.txt b/internal/cli/testdata/golden/blog-show.txt new file mode 100644 index 0000000..0bd4b67 --- /dev/null +++ b/internal/cli/testdata/golden/blog-show.txt @@ -0,0 +1,15 @@ +TITLE Positronick CLI v0.1.0 +SLUG positronick-cli-v0-1-0 +KIND release +AUTHOR @nsollazzo (Nicholas Sollazzo) +EXCERPT Install souls and browse the registry from your terminal. +CATEGORY Releases +TAGS cli, release +VERSION 1.0.0 +LISTING Positronick CLI (positronick-cli) +CANONICAL https://github.com/positronick/cli/releases/tag/v0.1.0 +STATUS published +VIEWS 42 +PUBLISHED 2026-06-01T09:00:00.000Z +CREATED 2026-06-01T09:00:00.000Z +UPDATED 2026-06-01T09:00:00.000Z diff --git a/internal/mockapi/mockapi.go b/internal/mockapi/mockapi.go index 88e67c9..d981626 100644 --- a/internal/mockapi/mockapi.go +++ b/internal/mockapi/mockapi.go @@ -1,8 +1,9 @@ // Package mockapi serves a fixed positronick.com API fixture over // net/http/httptest-compatible handlers, implementing the read contract // (GET /api/souls, /api/souls/{slug}, /api/listings(?type=), -// /api/listings/{slug}, /api/research) and the auth contract (device flow, -// /api/me, api-key/create) for the CLI's golden and e2e tests. The dataset is +// /api/listings/{slug}, /api/research, /api/blog(?kind=), /api/blog/{slug}, +// /api/blog/{slug}.md) and the auth contract (device flow, /api/me, +// api-key/create) for the CLI's golden and e2e tests. The dataset is // deliberately frozen: golden files pin command output byte-for-byte against // it, so changing a fixture value is a contract-test change. package mockapi @@ -313,6 +314,109 @@ var ResearchPosts = []api.ResearchItem{ }, } +// Posts is the fixture blog: one post of each kind (article, release, link) +// with deliberately different publish dates, authors and nullable fields so +// --kind filtering, newest-first ordering, the did-you-mean hint and the +// null-field rendering each have something to disagree about. Ordered +// oldest-first; the handler sorts newest-first like the server. Content is the +// body only (no frontmatter) — the JSON detail's `content`; the .md endpoint +// wraps it in frontmatter via PostMarkdown. +var Posts = []api.Post{ + { + PostCard: api.PostCard{ + ID: "01POSTINTRO0000000000000XX", + Slug: "introducing-positronick", + SlugHistory: []string{}, + Kind: "article", + Title: "Introducing Positronick", + Excerpt: "Publish once, discover and use anywhere — the marketplace for everything AI.", + Description: ptr("Why we built a provider- and framework-agnostic registry for souls, MCP servers, CLIs, agents, skills and more."), + ContentHash: "1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa", + Version: "1.0.0", + Category: "Announcements", + Tags: []string{"launch", "registry"}, + AuthorHandle: ptr("positronick"), + AuthorName: ptr("Positronick"), + AuthorAvatar: ptr("https://example.com/positronick.png"), + AuthorTier: ptr("official"), + ListingSlug: nil, + ListingName: nil, + CanonicalURL: nil, + Status: "published", + ViewCount: 128, + PublishedAt: ptr("2026-05-20T12:00:00.000Z"), + CreatedAt: "2026-05-20T12:00:00.000Z", + UpdatedAt: "2026-05-21T09:00:00.000Z", + }, + Content: "# Introducing Positronick\n\nPublish once — discover and use anywhere.\n", + }, + { + PostCard: api.PostCard{ + ID: "01POSTCLIRELEASE000000000X", + Slug: "positronick-cli-v0-1-0", + SlugHistory: []string{}, + Kind: "release", + Title: "Positronick CLI v0.1.0", + Excerpt: "Install souls and browse the registry from your terminal.", + Description: nil, + ContentHash: "2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb", + Version: "1.0.0", + Category: "Releases", + Tags: []string{"cli", "release"}, + AuthorHandle: ptr("nsollazzo"), + AuthorName: ptr("Nicholas Sollazzo"), + AuthorAvatar: nil, + AuthorTier: ptr("verified"), + ListingSlug: ptr("positronick-cli"), + ListingName: ptr("Positronick CLI"), + CanonicalURL: ptr("https://github.com/positronick/cli/releases/tag/v0.1.0"), + Status: "published", + ViewCount: 42, + PublishedAt: ptr("2026-06-01T09:00:00.000Z"), + CreatedAt: "2026-06-01T09:00:00.000Z", + UpdatedAt: "2026-06-01T09:00:00.000Z", + }, + Content: "# Positronick CLI v0.1.0\n\nThe first public release of the command-line client.\n", + }, + { + PostCard: api.PostCard{ + ID: "01POSTOPENCLAWLINK0000000X", + Slug: "openclaw-joins-the-registry", + SlugHistory: []string{}, + Kind: "link", + Title: "OpenClaw joins the registry", + Excerpt: "A new open-source agent harness is now listed on Positronick.", + Description: nil, + ContentHash: "3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc", + Version: "1.0.0", + Category: "Community", + Tags: []string{"openclaw", "news"}, + AuthorHandle: nil, + AuthorName: nil, + AuthorAvatar: nil, + AuthorTier: nil, + ListingSlug: nil, + ListingName: nil, + CanonicalURL: ptr("https://example.com/openclaw-joins"), + Status: "published", + ViewCount: 9, + PublishedAt: ptr("2026-06-10T08:00:00.000Z"), + CreatedAt: "2026-06-10T08:00:00.000Z", + UpdatedAt: "2026-06-10T08:00:00.000Z", + }, + Content: "OpenClaw, an open-source agent harness, is now listed on Positronick.\n", + }, +} + +// PostMarkdown renders a fixture post as the raw markdown file the .md endpoint +// serves: a minimal frontmatter block plus the body. It is deliberately +// distinct from the JSON detail's body-only `content`, so a test can prove +// `blog show --raw` read the .md endpoint. Shared by the handler and the CLI +// raw-output test. +func PostMarkdown(p api.Post) string { + return fmt.Sprintf("---\ntitle: %s\nslug: %s\nkind: %s\n---\n\n%s", p.Title, p.Slug, p.Kind, p.Content) +} + // Handler returns an http.Handler implementing the read API over the fixture // data, including the server's JSON error envelope on 404 and on an unknown // ?type=. Requests to /api/souls/{slug}.md are answered with 418 — the .md @@ -377,6 +481,36 @@ func Handler() http.Handler { serveResearch(w, r.URL.Query()) }) + mux.HandleFunc("GET /api/blog", func(w http.ResponseWriter, r *http.Request) { + serveBlogList(w, r.URL.Query().Get("kind")) + }) + + // One pattern serves both the JSON detail and the raw .md (the {slug} + // wildcard can't carry a literal suffix), mirroring the souls route — but the + // blog .md endpoint never bumps a counter, so it answers the body instead of + // a 418. + mux.HandleFunc("GET /api/blog/{slug}", func(w http.ResponseWriter, r *http.Request) { + if slug, isMD := strings.CutSuffix(r.PathValue("slug"), ".md"); isMD { + for _, p := range Posts { + if p.Slug == slug { + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + _, _ = w.Write([]byte(PostMarkdown(p))) + return + } + } + writeError(w, http.StatusNotFound, "not_found", "Post not found") + return + } + slug := r.PathValue("slug") + for _, p := range Posts { + if p.Slug == slug { + writeJSON(w, http.StatusOK, map[string]any{"post": p}) + return + } + } + writeError(w, http.StatusNotFound, "not_found", "Post not found") + }) + mux.HandleFunc("POST /api/auth/device/code", func(w http.ResponseWriter, r *http.Request) { var body struct { ClientID string `json:"client_id"` @@ -543,6 +677,23 @@ func serveResearch(w http.ResponseWriter, sp url.Values) { writeJSON(w, http.StatusOK, map[string]any{"results": results, "latest": latest}) } +// serveBlogList implements GET /api/blog over Posts: an optional ?kind= filter, +// then newest-first by publishedAt (the order the server returns) over the +// lightweight PostCard projection — no markdown body on the list. +func serveBlogList(w http.ResponseWriter, kind string) { + cards := make([]api.PostCard, 0, len(Posts)) + for _, p := range Posts { + if kind != "" && !strings.EqualFold(p.Kind, kind) { + continue + } + cards = append(cards, p.PostCard) + } + sort.SliceStable(cards, func(i, j int) bool { + return derefStr(cards[i].PublishedAt) > derefStr(cards[j].PublishedAt) + }) + writeJSON(w, http.StatusOK, map[string]any{"posts": cards}) +} + // containsFoldStr reports whether vals contains want, case-insensitively. func containsFoldStr(vals []string, want string) bool { for _, v := range vals {