diff --git a/CHANGELOG.md b/CHANGELOG.md index 76ee611..4635fec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,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 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/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 f4a9bc5..d99b3cd 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -268,3 +268,32 @@ type FeedSyncSummary struct { // Error is the fetch/parse failure reason; empty on success. Error string `json:"error,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 38002f8..adf3bab 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, create/list for " + 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 f73deb8..de881e2 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, create/list for profiles, and list/create/update/sync for blog feed sources) 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, create/list for profiles, and list/create/update/sync for blog feed sources) 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 5d0dfbe..f03bd7c 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" @@ -292,6 +295,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 @@ -352,6 +402,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"` @@ -467,6 +521,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 {