diff --git a/CHANGELOG.md b/CHANGELOG.md index 4635fec..fe63e4b 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. - **`positronick feed` (admin)**: manage the blog feed sources (GitHub release / RSS mirroring) the ingestor polls — `feed list`, `feed create --label --feed-url --kind github_release|rss --category ` (`--author`/`--listing` attribution, repeatable `--tag`, `--auto-publish`, `--enabled`), `feed update ` (`--enabled=false` pauses a feed — there is no delete verb), and `feed sync ` (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`. 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 d99b3cd..2ef1775 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -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 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 de881e2..b1dea07 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 f03bd7c..169aa2d 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 @@ -342,6 +343,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 @@ -406,6 +510,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"` @@ -572,6 +706,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 {