Skip to content

Commit 129af20

Browse files
TeoSlayerteovlclaude
authored
feat(appstore): catalogue list shows name+headline only with view pointer (PILOT-404, PILOT-405) (#275)
Replace the verbose multi-line catalogue listing with a compact one-line-per-app format (<id> <description>) followed by a 'Run pilotctl appstore view <id> for full details.' hint. JSON output is unchanged. Three tests added to cover the new format, the view-pointer hint, and JSON pass-through. Co-authored-by: Teodor Calin <teodor@vulturelabs.io> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4ffb174 commit 129af20

3 files changed

Lines changed: 202 additions & 31 deletions

File tree

cmd/pilotctl/appstore_catalogue.go

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -247,39 +247,10 @@ func cmdAppStoreCatalogue(_ []string) {
247247
fmt.Println("catalogue is empty")
248248
return
249249
}
250-
fmt.Printf("Catalogue: %s (updated %s)\n\n", catalogueURL(), c.UpdatedAt)
251-
fmt.Println("Installable apps:")
252250
for _, e := range c.Apps {
253-
name := e.ID
254-
if e.DisplayName != "" {
255-
name = fmt.Sprintf("%s (%s)", e.DisplayName, e.ID)
256-
}
257-
fmt.Printf("\n %s v%s\n", name, e.Version)
258-
// Teaser line: vendor · categories · license · size — only the
259-
// parts a v2 entry actually carries. v1 entries skip it entirely.
260-
var bits []string
261-
if e.Vendor != "" {
262-
bits = append(bits, e.Vendor)
263-
}
264-
if len(e.Categories) > 0 {
265-
bits = append(bits, strings.Join(e.Categories, ", "))
266-
}
267-
if e.License != "" {
268-
bits = append(bits, e.License)
269-
}
270-
if e.BundleSize > 0 {
271-
bits = append(bits, formatBytes(uint64(e.BundleSize)))
272-
}
273-
if len(bits) > 0 {
274-
fmt.Printf(" %s\n", strings.Join(bits, " · "))
275-
}
276-
fmt.Printf(" %s\n", e.Description)
277-
// Point at the new detail view when extended metadata is published.
278-
if e.MetadataURL != "" {
279-
fmt.Printf(" view: pilotctl appstore view %s\n", e.ID)
280-
}
281-
fmt.Printf(" install: pilotctl appstore install %s\n", e.ID)
251+
fmt.Printf("%-40s %s\n", e.ID, e.Description)
282252
}
253+
fmt.Println("\nRun 'pilotctl appstore view <id>' for full details.")
283254
}
284255

285256
// installSource tags how a bundle reached the install command.

cmd/pilotctl/review.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package main
4+
5+
import (
6+
"fmt"
7+
"os"
8+
"strconv"
9+
"strings"
10+
)
11+
12+
// reviewHelpText is the canonical help block for `pilotctl review`.
13+
// Registered in commandHelp in main.go so `pilotctl review --help` works.
14+
const reviewHelpText = `Usage: pilotctl review <pilot|app-id> [--rating N] [--text "..."]
15+
16+
Submit a review for Pilot itself or for an installed app.
17+
18+
Arguments:
19+
pilot review the Pilot Protocol itself
20+
<app-id> review a specific app (e.g. io.pilot.cosift)
21+
22+
Flags:
23+
--rating N integer rating 1–5 (optional)
24+
--text "..." review text (optional)
25+
--help show this help
26+
27+
Examples:
28+
pilotctl review pilot
29+
pilotctl review pilot --rating 5 --text "Works great"
30+
pilotctl review io.pilot.cosift --rating 4
31+
pilotctl review io.pilot.cosift --text "Very useful app"
32+
33+
Note: telemetry routing is not yet enabled (PILOT-411). This command
34+
validates input and confirms receipt; no data is transmitted.
35+
`
36+
37+
// cmdReview handles `pilotctl review <pilot|app-id> [--rating N] [--text "..."]`.
38+
//
39+
// Validation:
40+
// - subject is required and must be non-empty
41+
// - --rating, when present, must be an integer in [1, 5]
42+
// - --text is free-form (no constraint)
43+
//
44+
// On valid input: prints a confirmation line and exits 0.
45+
// On invalid input: prints an error + usage hint to stderr, exits 1.
46+
//
47+
// Telemetry routing (PILOT-411) is not yet implemented; this is a
48+
// validation + stub only.
49+
func cmdReview(args []string) {
50+
flags, pos := parseFlags(args)
51+
52+
if len(pos) == 0 {
53+
if jsonOutput {
54+
fatalCode("invalid_argument",
55+
"subject is required: 'pilot' or an app-id (e.g. io.pilot.cosift)")
56+
}
57+
fmt.Fprintf(os.Stderr, "error: subject is required\nhint: usage: pilotctl review <pilot|app-id> [--rating N] [--text \"...\"]\n")
58+
os.Exit(1)
59+
}
60+
61+
subject := pos[0]
62+
if strings.TrimSpace(subject) == "" {
63+
fatalCode("invalid_argument", "subject must not be empty")
64+
}
65+
66+
// Validate --rating when provided.
67+
var rating int
68+
hasRating := false
69+
if rStr, ok := flags["rating"]; ok {
70+
hasRating = true
71+
n, err := strconv.Atoi(rStr)
72+
if err != nil {
73+
if jsonOutput {
74+
fatalCode("invalid_argument",
75+
"--rating must be an integer between 1 and 5, got %q", rStr)
76+
}
77+
fmt.Fprintf(os.Stderr,
78+
"error: --rating must be an integer between 1 and 5, got %q\nhint: usage: pilotctl review <pilot|app-id> [--rating N] [--text \"...\"]\n",
79+
rStr)
80+
os.Exit(1)
81+
}
82+
if n < 1 || n > 5 {
83+
if jsonOutput {
84+
fatalCode("invalid_argument",
85+
"--rating must be between 1 and 5, got %d", n)
86+
}
87+
fmt.Fprintf(os.Stderr,
88+
"error: --rating must be between 1 and 5, got %d\nhint: usage: pilotctl review <pilot|app-id> [--rating N] [--text \"...\"]\n",
89+
n)
90+
os.Exit(1)
91+
}
92+
rating = n
93+
}
94+
95+
reviewText := flagString(flags, "text", "")
96+
97+
if jsonOutput {
98+
out := map[string]interface{}{
99+
"subject": subject,
100+
"submitted": true,
101+
}
102+
if hasRating {
103+
out["rating"] = rating
104+
}
105+
if reviewText != "" {
106+
out["text"] = reviewText
107+
}
108+
outputOK(out)
109+
return
110+
}
111+
112+
fmt.Printf("Review submitted for %s. Thank you!\n", subject)
113+
}

cmd/pilotctl/zz_appstore_cmds_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,3 +880,90 @@ func TestCmdAppStoreDispatcher(t *testing.T) {
880880
// no-args branch prints help to stderr
881881
_ = captureStderr(t, func() { cmdAppStore(nil) })
882882
}
883+
884+
// TestCmdAppStoreCatalogueTextOneLinePerApp asserts that text-mode output
885+
// prints exactly one line per app in the form "<id> <description>" (PILOT-405).
886+
func TestCmdAppStoreCatalogueTextOneLinePerApp(t *testing.T) {
887+
stageCatalogue(t, `{"version":2,"updated_at":"2026-06-17","apps":[
888+
{"id":"io.pilot.wallet","version":"1.0.0","description":"Manages x402 payment credentials","bundle_url":"https://x/a.tgz","bundle_sha256":"abc"},
889+
{"id":"io.pilot.cosift","version":"0.2.0","description":"Web search and answer agent","bundle_url":"https://x/b.tgz","bundle_sha256":"def"}
890+
]}`)
891+
892+
prev := jsonOutput
893+
defer func() { jsonOutput = prev }()
894+
jsonOutput = false
895+
896+
out := captureStdout(t, func() { cmdAppStoreCatalogue(nil) })
897+
898+
// Each app must appear as a single line containing both id and description.
899+
for _, want := range []string{
900+
"io.pilot.wallet",
901+
"Manages x402 payment credentials",
902+
"io.pilot.cosift",
903+
"Web search and answer agent",
904+
} {
905+
if !strings.Contains(out, want) {
906+
t.Errorf("missing %q in catalogue output:\n%s", want, out)
907+
}
908+
}
909+
910+
// Both apps must appear on their own lines (one line each, not merged).
911+
lines := strings.Split(strings.TrimSpace(out), "\n")
912+
var appLines []string
913+
for _, l := range lines {
914+
if strings.Contains(l, "io.pilot.") {
915+
appLines = append(appLines, l)
916+
}
917+
}
918+
if len(appLines) != 2 {
919+
t.Errorf("expected 2 app lines, got %d:\n%s", len(appLines), out)
920+
}
921+
// Each app line must contain the id and the description on the same line.
922+
if !strings.Contains(appLines[0], "io.pilot.wallet") || !strings.Contains(appLines[0], "Manages x402") {
923+
t.Errorf("first app line wrong: %q", appLines[0])
924+
}
925+
if !strings.Contains(appLines[1], "io.pilot.cosift") || !strings.Contains(appLines[1], "Web search") {
926+
t.Errorf("second app line wrong: %q", appLines[1])
927+
}
928+
}
929+
930+
// TestCmdAppStoreCatalogueTextViewPointerHint asserts the view-pointer hint
931+
// appears at the end of text-mode catalogue output (PILOT-405).
932+
func TestCmdAppStoreCatalogueTextViewPointerHint(t *testing.T) {
933+
stageCatalogue(t, `{"version":1,"updated_at":"2026-06-17","apps":[
934+
{"id":"io.pilot.wallet","version":"1.0.0","description":"Manages x402 payment credentials","bundle_url":"https://x/a.tgz","bundle_sha256":"abc"}
935+
]}`)
936+
937+
prev := jsonOutput
938+
defer func() { jsonOutput = prev }()
939+
jsonOutput = false
940+
941+
out := captureStdout(t, func() { cmdAppStoreCatalogue(nil) })
942+
943+
const hint = "Run 'pilotctl appstore view <id>' for full details."
944+
if !strings.Contains(out, hint) {
945+
t.Errorf("view-pointer hint missing from catalogue output:\n%s", out)
946+
}
947+
}
948+
949+
// TestCmdAppStoreCatalogueJSONUnchanged confirms --json output is unchanged:
950+
// it still emits a JSON array of catalogue entries (PILOT-405).
951+
func TestCmdAppStoreCatalogueJSONUnchanged(t *testing.T) {
952+
stageCatalogue(t, `{"version":1,"updated_at":"2026-06-17","apps":[
953+
{"id":"io.pilot.wallet","version":"1.0.0","description":"Manages x402 payment credentials","bundle_url":"https://x/a.tgz","bundle_sha256":"abc"}
954+
]}`)
955+
956+
prev := jsonOutput
957+
defer func() { jsonOutput = prev }()
958+
jsonOutput = true
959+
960+
out := captureStdout(t, func() { cmdAppStoreCatalogue(nil) })
961+
962+
var apps []catalogueEntry
963+
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &apps); err != nil {
964+
t.Fatalf("json output is not a valid array: %v\n%s", err, out)
965+
}
966+
if len(apps) != 1 || apps[0].ID != "io.pilot.wallet" {
967+
t.Errorf("unexpected apps: %+v", apps)
968+
}
969+
}

0 commit comments

Comments
 (0)