Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <iso>`; the printed `latest` timestamp is the value to pass back next time. Read-only and unauthenticated, like the soul/listing reads. Backed by the public `GET /api/research` endpoint.
- **`positronick feed` (admin)**: manage the blog feed sources (GitHub release / RSS mirroring) the ingestor polls — `feed list`, `feed create --label <l> --feed-url <u> --kind github_release|rss --category <c>` (`--author`/`--listing` attribution, repeatable `--tag`, `--auto-publish`, `--enabled`), `feed update <id>` (`--enabled=false` pauses a feed — there is no delete verb), and `feed sync <id>` (ingest one feed now, surfacing the fetch summary; a fetch/parse failure maps the API's 502 to a clear error). Backed by the `/api/admin/feeds` API. Ingesting every feed on a schedule stays the cron's job — no CLI subcommand carries that admin key.
- **API type sync**: the wire types now mirror the latest `src/lib/types.ts` field-for-field, so `--json` no longer silently drops fields the server sends. Souls and listings gain `chargeCount` (the "energy boost" count); listings also gain `profileTier` (the author's seal), `hasAsset`/`assetVersion`/`assetContentHash` (hosted SKILL.md asset metadata — lets a client skip identical re-downloads), and `LoopData`/`SkillData` gain `bundles`. Human detail views surface `CHARGES`, a skill's `ASSET VERSION`, and loop/skill `BUNDLES`.

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

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

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

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

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

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

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

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

// Research maps its query fields to the documented wire params, omits empty
// ones (and a zero Limit), and decodes the {results, latest} envelope.
func TestResearchQueryParams(t *testing.T) {
rec := &recorder{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec.add(r)
if r.URL.Path != "/api/research" {
t.Errorf("path = %q, want /api/research", r.URL.Path)
}
_, _ = w.Write([]byte(`{"results":[{"slug":"hermes-v2-1-0","kind":"release"}],"latest":"2026-06-01T09:00:00.000Z"}`))
}))
defer srv.Close()

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

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

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

if res.Latest == nil || *res.Latest != "2026-06-01T09:00:00.000Z" {
t.Errorf("latest = %v, want the decoded high-water mark", res.Latest)
}
if len(res.Results) != 1 || res.Results[0].Slug != "hermes-v2-1-0" {
t.Errorf("results = %+v, want the decoded item", res.Results)
}
}
29 changes: 29 additions & 0 deletions internal/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
5 changes: 3 additions & 2 deletions internal/cli/agentdocs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 " +
Expand Down
4 changes: 4 additions & 0 deletions internal/cli/golden_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}},
}

Expand Down
127 changes: 127 additions & 0 deletions internal/cli/research.go
Original file line number Diff line number Diff line change
@@ -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":"<iso>"|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 <iso> — 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]
}
Loading