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
33 changes: 2 additions & 31 deletions cmd/pilotctl/appstore_catalogue.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,39 +247,10 @@ func cmdAppStoreCatalogue(_ []string) {
fmt.Println("catalogue is empty")
return
}
fmt.Printf("Catalogue: %s (updated %s)\n\n", catalogueURL(), c.UpdatedAt)
fmt.Println("Installable apps:")
for _, e := range c.Apps {
name := e.ID
if e.DisplayName != "" {
name = fmt.Sprintf("%s (%s)", e.DisplayName, e.ID)
}
fmt.Printf("\n %s v%s\n", name, e.Version)
// Teaser line: vendor · categories · license · size — only the
// parts a v2 entry actually carries. v1 entries skip it entirely.
var bits []string
if e.Vendor != "" {
bits = append(bits, e.Vendor)
}
if len(e.Categories) > 0 {
bits = append(bits, strings.Join(e.Categories, ", "))
}
if e.License != "" {
bits = append(bits, e.License)
}
if e.BundleSize > 0 {
bits = append(bits, formatBytes(uint64(e.BundleSize)))
}
if len(bits) > 0 {
fmt.Printf(" %s\n", strings.Join(bits, " · "))
}
fmt.Printf(" %s\n", e.Description)
// Point at the new detail view when extended metadata is published.
if e.MetadataURL != "" {
fmt.Printf(" view: pilotctl appstore view %s\n", e.ID)
}
fmt.Printf(" install: pilotctl appstore install %s\n", e.ID)
fmt.Printf("%-40s %s\n", e.ID, e.Description)
}
fmt.Println("\nRun 'pilotctl appstore view <id>' for full details.")
}

// installSource tags how a bundle reached the install command.
Expand Down
113 changes: 113 additions & 0 deletions cmd/pilotctl/review.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package main

import (
"fmt"
"os"
"strconv"
"strings"
)

// reviewHelpText is the canonical help block for `pilotctl review`.
// Registered in commandHelp in main.go so `pilotctl review --help` works.
const reviewHelpText = `Usage: pilotctl review <pilot|app-id> [--rating N] [--text "..."]

Submit a review for Pilot itself or for an installed app.

Arguments:
pilot review the Pilot Protocol itself
<app-id> review a specific app (e.g. io.pilot.cosift)

Flags:
--rating N integer rating 1–5 (optional)
--text "..." review text (optional)
--help show this help

Examples:
pilotctl review pilot
pilotctl review pilot --rating 5 --text "Works great"
pilotctl review io.pilot.cosift --rating 4
pilotctl review io.pilot.cosift --text "Very useful app"

Note: telemetry routing is not yet enabled (PILOT-411). This command
validates input and confirms receipt; no data is transmitted.
`

// cmdReview handles `pilotctl review <pilot|app-id> [--rating N] [--text "..."]`.
//
// Validation:
// - subject is required and must be non-empty
// - --rating, when present, must be an integer in [1, 5]
// - --text is free-form (no constraint)
//
// On valid input: prints a confirmation line and exits 0.
// On invalid input: prints an error + usage hint to stderr, exits 1.
//
// Telemetry routing (PILOT-411) is not yet implemented; this is a
// validation + stub only.
func cmdReview(args []string) {
flags, pos := parseFlags(args)

if len(pos) == 0 {
if jsonOutput {
fatalCode("invalid_argument",
"subject is required: 'pilot' or an app-id (e.g. io.pilot.cosift)")
}
fmt.Fprintf(os.Stderr, "error: subject is required\nhint: usage: pilotctl review <pilot|app-id> [--rating N] [--text \"...\"]\n")
os.Exit(1)
}

subject := pos[0]
if strings.TrimSpace(subject) == "" {
fatalCode("invalid_argument", "subject must not be empty")
}

// Validate --rating when provided.
var rating int
hasRating := false
if rStr, ok := flags["rating"]; ok {
hasRating = true
n, err := strconv.Atoi(rStr)
if err != nil {
if jsonOutput {
fatalCode("invalid_argument",
"--rating must be an integer between 1 and 5, got %q", rStr)
}
fmt.Fprintf(os.Stderr,
"error: --rating must be an integer between 1 and 5, got %q\nhint: usage: pilotctl review <pilot|app-id> [--rating N] [--text \"...\"]\n",
rStr)
os.Exit(1)
}
if n < 1 || n > 5 {
if jsonOutput {
fatalCode("invalid_argument",
"--rating must be between 1 and 5, got %d", n)
}
fmt.Fprintf(os.Stderr,
"error: --rating must be between 1 and 5, got %d\nhint: usage: pilotctl review <pilot|app-id> [--rating N] [--text \"...\"]\n",
n)
os.Exit(1)
}
rating = n
}

reviewText := flagString(flags, "text", "")

if jsonOutput {
out := map[string]interface{}{
"subject": subject,
"submitted": true,
}
if hasRating {
out["rating"] = rating
}
if reviewText != "" {
out["text"] = reviewText
}
outputOK(out)
return
}

fmt.Printf("Review submitted for %s. Thank you!\n", subject)
}
87 changes: 87 additions & 0 deletions cmd/pilotctl/zz_appstore_cmds_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -880,3 +880,90 @@ func TestCmdAppStoreDispatcher(t *testing.T) {
// no-args branch prints help to stderr
_ = captureStderr(t, func() { cmdAppStore(nil) })
}

// TestCmdAppStoreCatalogueTextOneLinePerApp asserts that text-mode output
// prints exactly one line per app in the form "<id> <description>" (PILOT-405).
func TestCmdAppStoreCatalogueTextOneLinePerApp(t *testing.T) {
stageCatalogue(t, `{"version":2,"updated_at":"2026-06-17","apps":[
{"id":"io.pilot.wallet","version":"1.0.0","description":"Manages x402 payment credentials","bundle_url":"https://x/a.tgz","bundle_sha256":"abc"},
{"id":"io.pilot.cosift","version":"0.2.0","description":"Web search and answer agent","bundle_url":"https://x/b.tgz","bundle_sha256":"def"}
]}`)

prev := jsonOutput
defer func() { jsonOutput = prev }()
jsonOutput = false

out := captureStdout(t, func() { cmdAppStoreCatalogue(nil) })

// Each app must appear as a single line containing both id and description.
for _, want := range []string{
"io.pilot.wallet",
"Manages x402 payment credentials",
"io.pilot.cosift",
"Web search and answer agent",
} {
if !strings.Contains(out, want) {
t.Errorf("missing %q in catalogue output:\n%s", want, out)
}
}

// Both apps must appear on their own lines (one line each, not merged).
lines := strings.Split(strings.TrimSpace(out), "\n")
var appLines []string
for _, l := range lines {
if strings.Contains(l, "io.pilot.") {
appLines = append(appLines, l)
}
}
if len(appLines) != 2 {
t.Errorf("expected 2 app lines, got %d:\n%s", len(appLines), out)
}
// Each app line must contain the id and the description on the same line.
if !strings.Contains(appLines[0], "io.pilot.wallet") || !strings.Contains(appLines[0], "Manages x402") {
t.Errorf("first app line wrong: %q", appLines[0])
}
if !strings.Contains(appLines[1], "io.pilot.cosift") || !strings.Contains(appLines[1], "Web search") {
t.Errorf("second app line wrong: %q", appLines[1])
}
}

// TestCmdAppStoreCatalogueTextViewPointerHint asserts the view-pointer hint
// appears at the end of text-mode catalogue output (PILOT-405).
func TestCmdAppStoreCatalogueTextViewPointerHint(t *testing.T) {
stageCatalogue(t, `{"version":1,"updated_at":"2026-06-17","apps":[
{"id":"io.pilot.wallet","version":"1.0.0","description":"Manages x402 payment credentials","bundle_url":"https://x/a.tgz","bundle_sha256":"abc"}
]}`)

prev := jsonOutput
defer func() { jsonOutput = prev }()
jsonOutput = false

out := captureStdout(t, func() { cmdAppStoreCatalogue(nil) })

const hint = "Run 'pilotctl appstore view <id>' for full details."
if !strings.Contains(out, hint) {
t.Errorf("view-pointer hint missing from catalogue output:\n%s", out)
}
}

// TestCmdAppStoreCatalogueJSONUnchanged confirms --json output is unchanged:
// it still emits a JSON array of catalogue entries (PILOT-405).
func TestCmdAppStoreCatalogueJSONUnchanged(t *testing.T) {
stageCatalogue(t, `{"version":1,"updated_at":"2026-06-17","apps":[
{"id":"io.pilot.wallet","version":"1.0.0","description":"Manages x402 payment credentials","bundle_url":"https://x/a.tgz","bundle_sha256":"abc"}
]}`)

prev := jsonOutput
defer func() { jsonOutput = prev }()
jsonOutput = true

out := captureStdout(t, func() { cmdAppStoreCatalogue(nil) })

var apps []catalogueEntry
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &apps); err != nil {
t.Fatalf("json output is not a valid array: %v\n%s", err, out)
}
if len(apps) != 1 || apps[0].ID != "io.pilot.wallet" {
t.Errorf("unexpected apps: %+v", apps)
}
}
Loading