From 331f2e8d360a9c561f8674ba817f0e60baf014de Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Mon, 31 Aug 2026 14:14:51 +0200 Subject: [PATCH 1/2] feat: close four of the gaps the coverage manifest recorded The manifest #85 introduced listed five endpoints as "not exposed yet". Four of them are worth having, so they are commands now and the skip lines are gone - which is the file working as intended, as a list of decisions to revisit rather than a place gaps go to be forgotten. `profiles duplicate` stays listed: it is already proposed in #53. notte personas update --persona-id --name ... notte profiles cookies --profile-id notte profiles cookies-set --profile-id --file cookies.json notte usage logs [--endpoint ...] [--page N] [--page-size N] `personas update` is generated: PATCH /personas/{persona_id} is a JSON body behind a $ref, so it needed an endpointMap entry and nothing else. `profiles cookies-set` accepts either shape a cookies file comes in - a bare array, which is what Playwright's storageState and the browser extensions write, or an object with a `cookies` key. Making the caller reshape their own export first would be a papercut for no reason. --source-format and --mode are sent only when passed. `usage logs` exposes --endpoint, --only-current-token and --include-system alongside the shared pagination flags. only_active is deliberately left out: it is the generic listing filter and a request log is never active or inactive. Two corrections come with them. --instructions on `functions configure` is renamed --run-instructions, and its help and both repositories' docs are rewritten. The field documents a function for whoever calls it - how long a run takes, what each variable means, which sites it trips over - and I had described it as configuration for the self-healing agent, which is what the bare name reads like and is not what it is. The API field is untouched; only the flag is renamed, through a new command-scoped override in the generator so the name stays declared in one place. Free to rename: v0.0.37 predates #84, so the flag has never shipped. The skill checker now also requires documentation for a command that both runs and has subcommands. `notte usage` is one as of this change, and the old leaf-only rule would have let it slip out of the check the moment it grew `usage logs`. Verified against us-staging: `personas update` renames and reads back; the other three are covered by mock-server tests asserting the request. `usage logs` could not be verified live - /usage/logs returns 503 after ~25s to plain curl on staging, before any of this code is involved. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 30 ++- internal/cmd/coveragegaps_test.go | 204 ++++++++++++++++++++ internal/cmd/functionconfigure_flags.gen.go | 8 +- internal/cmd/functions.go | 20 +- internal/cmd/functionsextra_test.go | 14 +- internal/cmd/personas.go | 41 ++++ internal/cmd/personaupdate_flags.gen.go | 35 ++++ internal/cmd/profiles.go | 132 ++++++++++++- internal/cmd/usage.go | 79 +++++++- scripts/checkcoverage/skills.go | 13 +- scripts/endpoint-coverage.txt | 6 +- scripts/gen-flags/parser.go | 3 +- scripts/gen-flags/parser_test.go | 18 ++ scripts/gen-flags/types.go | 30 ++- 14 files changed, 597 insertions(+), 36 deletions(-) create mode 100644 internal/cmd/coveragegaps_test.go create mode 100644 internal/cmd/personaupdate_flags.gen.go diff --git a/README.md b/README.md index 6b5081c..7c773d9 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ notte functions show --function-id # View specific function details (diffe notte functions create --file workflow.py --response-format @schema.json # ... with its response documented notte functions update --file workflow.py # Update current function code notte functions update --file workflow.py --response-format @schema.json # ... and re-document its response -notte functions configure --self-healing --instructions "..." # Set self-healing and its instructions +notte functions configure --run-instructions "..." --self-healing # Set usage notes and self-healing notte functions rollback --version # Restore an earlier version (see `versions` in show) notte functions health # Runtime health: Python version, installed packages, reachability notte functions delete # Delete current function @@ -217,6 +217,20 @@ notte functions schedule --cron "0 12 ? * * *" # Schedule current function (six notte functions unschedule # Remove schedule from current function ``` +### Personas, Profiles and Usage + +```bash +notte personas update --persona-id --name "checkout tester" # Rename a persona +notte profiles cookies --profile-id # Read a profile's cookies +notte profiles cookies-set --profile-id --file cookies.json # Import cookies into a profile +notte usage logs [--endpoint /sessions/start] [--page N] # List API requests made with your key +``` + +`profiles cookies-set` takes either a bare array of cookies — what Playwright's +`storageState` and the browser extensions export — or an object with a `cookies` +key. Add `--source-format chrome` if they came from Chrome, and `--mode append` +to add to the profile's cookies rather than replace them. + `--response-format` takes a JSON Schema describing what `run()` returns, as inline JSON, `@file.json`, or `-` for stdin. The API never derives it, so a function created without it has no documented response — which is what the @@ -228,7 +242,19 @@ python -c 'import json, typing, client; print(json.dumps(typing.get_type_hints(c notte functions create --file client.py --response-format @schema.json ``` -`configure` sends only the flags you pass, so setting `--instructions` leaves +`--run-instructions` is documentation for whoever *calls* the function — how long a +run takes, what each variable is for, which sites it trips over: + +```bash +notte functions configure --run-instructions "Takes ~3 min, so call it async. \ +Hits a captcha on the login page every few runs. \ +\`query\` is the search term; \`max_items\` caps the results." +``` + +It is not input to the self-healing agent, which is the separate +`--self-healing` flag. + +`configure` sends only the flags you pass, so setting `--run-instructions` leaves self-healing untouched. Disable self-healing with `--self-healing=false`: the API treats an absent field as "leave it alone" rather than "off". Note that it can only be enabled on functions an agent built — a CLI-created function has no diff --git a/internal/cmd/coveragegaps_test.go b/internal/cmd/coveragegaps_test.go new file mode 100644 index 0000000..d3041a8 --- /dev/null +++ b/internal/cmd/coveragegaps_test.go @@ -0,0 +1,204 @@ +package cmd + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/nottelabs/notte-cli/internal/testutil" +) + +func setupCoverageTest(t *testing.T) *testutil.MockServer { + t.Helper() + env := testutil.SetupTestEnv(t) + env.SetEnv("NOTTE_API_KEY", "test-key") + + server := testutil.NewMockServer() + t.Cleanup(func() { server.Close() }) + env.SetEnv("NOTTE_API_URL", server.URL()) + + origFormat := outputFormat + outputFormat = "json" + t.Cleanup(func() { outputFormat = origFormat }) + + return server +} + +func TestPersonaUpdate_SendsTheNewName(t *testing.T) { + server := setupCoverageTest(t) + server.AddResponse("/personas/"+"p_1", 200, `{"persona_id":"p_1","status":"active"}`) + + origID := personaID + personaID = "p_1" + t.Cleanup(func() { personaID = origID; PersonaUpdateName = "" }) + + cmd := &cobra.Command{} + RegisterPersonaUpdateFlags(cmd) + cmd.SetContext(context.Background()) + if err := cmd.Flags().Set("name", "checkout tester"); err != nil { + t.Fatalf("setting --name: %v", err) + } + + testutil.CaptureOutput(func() { + if err := runPersonaUpdate(cmd, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + requests := server.Requests("/personas/p_1") + if len(requests) != 1 { + t.Fatalf("got %d requests, want 1", len(requests)) + } + if requests[0].Method != "PATCH" { + t.Errorf("method = %s, want PATCH", requests[0].Method) + } + var body map[string]any + if err := json.Unmarshal([]byte(requests[0].Body), &body); err != nil { + t.Fatalf("parsing body: %v", err) + } + if body["name"] != "checkout tester" { + t.Errorf("name = %v", body["name"]) + } +} + +func TestProfileCookies_ReadsTheProfile(t *testing.T) { + server := setupCoverageTest(t) + server.AddResponse("/profiles/"+profileIDTest+"/cookies", 200, `{"cookies":[{"name":"a","value":"b"}]}`) + + origID := profileID + profileID = profileIDTest + t.Cleanup(func() { profileID = origID }) + + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + stdout, _ := testutil.CaptureOutput(func() { + if err := runProfileCookies(cmd, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if !strings.Contains(stdout, "cookies") { + t.Errorf("expected the cookies in the output, got %q", stdout) + } +} + +// Playwright's storageState and the browser extensions export a bare array, +// while the API wants it under a `cookies` key. Making the caller reshape their +// own export first would be a papercut for no reason. +func TestProfileCookiesSet_AcceptsBothFileShapes(t *testing.T) { + for _, tc := range []struct { + name string + content string + }{ + {name: "bare array", content: `[{"name":"a","value":"b","domain":"example.com","path":"/"}]`}, + {name: "wrapped object", content: `{"cookies":[{"name":"a","value":"b","domain":"example.com","path":"/"}]}`}, + } { + t.Run(tc.name, func(t *testing.T) { + server := setupCoverageTest(t) + server.AddResponse("/profiles/"+profileIDTest+"/cookies", 200, + `{"success":true,"message":"ok","cookies_count":1,"mode":"replace"}`) + + path := filepath.Join(t.TempDir(), "cookies.json") + if err := os.WriteFile(path, []byte(tc.content), 0o600); err != nil { + t.Fatalf("writing cookies file: %v", err) + } + + origID, origFile := profileID, profileCookiesFile + profileID, profileCookiesFile = profileIDTest, path + t.Cleanup(func() { profileID, profileCookiesFile = origID, origFile }) + + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + testutil.CaptureOutput(func() { + if err := runProfileCookiesSet(cmd, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + requests := server.Requests("/profiles/" + profileIDTest + "/cookies") + if len(requests) != 1 { + t.Fatalf("got %d requests, want 1", len(requests)) + } + var body map[string]any + if err := json.Unmarshal([]byte(requests[0].Body), &body); err != nil { + t.Fatalf("parsing body: %v", err) + } + cookies, ok := body["cookies"].([]any) + if !ok || len(cookies) != 1 { + t.Fatalf("expected one cookie under `cookies`, got %v", body) + } + // Neither optional field was passed, so neither should be sent. + if _, present := body["source_format"]; present { + t.Error("source_format was sent without --source-format") + } + if _, present := body["mode"]; present { + t.Error("mode was sent without --mode") + } + }) + } +} + +func TestProfileCookiesSet_RejectsAFileWithNoCookies(t *testing.T) { + setupCoverageTest(t) + + path := filepath.Join(t.TempDir(), "cookies.json") + if err := os.WriteFile(path, []byte(`{"notCookies":1}`), 0o600); err != nil { + t.Fatalf("writing file: %v", err) + } + + origID, origFile := profileID, profileCookiesFile + profileID, profileCookiesFile = profileIDTest, path + t.Cleanup(func() { profileID, profileCookiesFile = origID, origFile }) + + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + err := runProfileCookiesSet(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "no cookies") { + t.Fatalf("expected a no-cookies error, got %v", err) + } +} + +// The filters are sent only when asked, so the API keeps owning its defaults. +func TestUsageLogs_SendsOnlyTheFiltersPassed(t *testing.T) { + server := setupCoverageTest(t) + server.AddResponse("/usage/logs", 200, + `{"items":[{"endpoint":"/sessions/start"}],"page":1,"page_size":10,"has_next":false,"has_previous":false}`) + + origEndpoint := usageLogsEndpoint + usageLogsEndpoint = "/sessions/start" + t.Cleanup(func() { usageLogsEndpoint = origEndpoint }) + + cmd := &cobra.Command{} + registerPaginationFlags(cmd) + cmd.Flags().Bool("only-current-token", false, "") + cmd.Flags().Bool("include-system", false, "") + cmd.SetContext(context.Background()) + + testutil.CaptureOutput(func() { + if err := runUsageLogs(cmd, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + requests := server.Requests("/usage/logs") + if len(requests) != 1 { + t.Fatalf("got %d requests, want 1", len(requests)) + } + query := requests[0].Query + if !strings.Contains(query, "endpoint=") { + t.Errorf("expected the endpoint filter in %q", query) + } + for _, unwanted := range []string{"only_current_token", "include_system", "only_active"} { + if strings.Contains(query, unwanted) { + t.Errorf("%s was sent without being asked for: %q", unwanted, query) + } + } +} diff --git a/internal/cmd/functionconfigure_flags.gen.go b/internal/cmd/functionconfigure_flags.gen.go index e52d74b..395d790 100644 --- a/internal/cmd/functionconfigure_flags.gen.go +++ b/internal/cmd/functionconfigure_flags.gen.go @@ -9,17 +9,17 @@ import ( // FunctionConfigure command flags var ( - // Instructions the self-healing agent follows when the function breaks + // Notes for whoever calls this function: how long a run takes, what the variables mean, what it trips over FunctionConfigureInstructions string - // Let the agent repair the function when a run fails + // Let an agent repair the function when a run fails FunctionConfigureSelfHealing bool ) // RegisterFunctionConfigureFlags registers all flags for FunctionConfigure command func RegisterFunctionConfigureFlags(cmd *cobra.Command) { - cmd.Flags().StringVar(&FunctionConfigureInstructions, "instructions", "", "Instructions the self-healing agent follows when the function breaks") - cmd.Flags().BoolVar(&FunctionConfigureSelfHealing, "self-healing", false, "Let the agent repair the function when a run fails") + cmd.Flags().StringVar(&FunctionConfigureInstructions, "run-instructions", "", "Notes for whoever calls this function: how long a run takes, what the variables mean, what it trips over") + cmd.Flags().BoolVar(&FunctionConfigureSelfHealing, "self-healing", false, "Let an agent repair the function when a run fails") } // BuildFunctionConfigureRequest builds the API request from CLI flags diff --git a/internal/cmd/functions.go b/internal/cmd/functions.go index a8c2a60..7d9bb5c 100644 --- a/internal/cmd/functions.go +++ b/internal/cmd/functions.go @@ -121,9 +121,13 @@ var functionsUpdateCmd = &cobra.Command{ var functionsConfigureCmd = &cobra.Command{ Use: "configure", - Short: "Set self-healing and its instructions", - Long: "Update a function's metadata. Only the flags you pass are sent, so " + - "configuring instructions leaves self-healing as it was, and vice versa.", + Short: "Set usage notes and self-healing", + Long: "Update a function's metadata.\n\n" + + "--run-instructions is documentation for whoever calls the function - how long a " + + "run takes, what each variable is for, which sites it trips over. It is not " + + "input to the self-healing agent.\n\n" + + "Only the flags you pass are sent, so setting instructions leaves self-healing " + + "as it was, and vice versa.", Args: cobra.NoArgs, RunE: runFunctionConfigure, } @@ -499,14 +503,14 @@ func runFunctionConfigure(cmd *cobra.Command, args []string) error { // An empty PATCH is accepted by the API and changes nothing, which reads as // success for a command that did not do what the caller meant. - if !cmd.Flags().Changed("instructions") && !cmd.Flags().Changed("self-healing") { - return errors.New("nothing to configure: pass --instructions, --self-healing, or both") + if !cmd.Flags().Changed("run-instructions") && !cmd.Flags().Changed("self-healing") { + return errors.New("nothing to configure: pass --run-instructions, --self-healing, or both") } - // `--instructions ""` is refused rather than sent. The generated builder + // `--run-instructions ""` is refused rather than sent. The generated builder // omits an empty string, so it would otherwise travel as far as an empty // PATCH: accepted, 200, nothing changed, and the caller told it worked. - if cmd.Flags().Changed("instructions") && FunctionConfigureInstructions == "" { - return errors.New("--instructions cannot be empty") + if cmd.Flags().Changed("run-instructions") && FunctionConfigureInstructions == "" { + return errors.New("--run-instructions cannot be empty") } client, err := GetClient() diff --git a/internal/cmd/functionsextra_test.go b/internal/cmd/functionsextra_test.go index 835906e..ee8e396 100644 --- a/internal/cmd/functionsextra_test.go +++ b/internal/cmd/functionsextra_test.go @@ -45,8 +45,8 @@ func TestFunctionConfigure_SendsOnlyTheFlagsPassed(t *testing.T) { t.Cleanup(func() { outputFormat = origFormat }) cmd := configureCmd(t) - if err := cmd.Flags().Set("instructions", "retry the login step"); err != nil { - t.Fatalf("setting --instructions: %v", err) + if err := cmd.Flags().Set("run-instructions", "retry the login step"); err != nil { + t.Fatalf("setting --run-instructions: %v", err) } testutil.CaptureOutput(func() { @@ -123,26 +123,26 @@ func TestFunctionConfigure_RefusesToSendNothing(t *testing.T) { } // The generated builder sends an optional string only when it is non-empty, so -// `--instructions ""` would reach the API as an empty PATCH: 200, nothing +// `--run-instructions ""` would reach the API as an empty PATCH: 200, nothing // changed, and the caller told it worked. Refused up front instead. func TestFunctionConfigure_RejectsEmptyInstructions(t *testing.T) { server := setupFunctionTest(t) server.AddResponse("/functions/"+functionIDTest, 200, functionJSON()) cmd := configureCmd(t) - if err := cmd.Flags().Set("instructions", ""); err != nil { - t.Fatalf("setting --instructions: %v", err) + if err := cmd.Flags().Set("run-instructions", ""); err != nil { + t.Fatalf("setting --run-instructions: %v", err) } err := runFunctionConfigure(cmd, nil) if err == nil { - t.Fatal("expected an error for --instructions \"\"") + t.Fatal("expected an error for --run-instructions \"\"") } if !strings.Contains(err.Error(), "cannot be empty") { t.Fatalf("unexpected error: %v", err) } if len(server.Requests("/functions/"+functionIDTest)) != 0 { - t.Error("an empty --instructions still reached the API") + t.Error("an empty --run-instructions still reached the API") } } diff --git a/internal/cmd/personas.go b/internal/cmd/personas.go index d4c3e93..6f5e4f8 100644 --- a/internal/cmd/personas.go +++ b/internal/cmd/personas.go @@ -56,6 +56,13 @@ var personasSmsCmd = &cobra.Command{ RunE: runPersonaSms, } +var personasUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Rename a persona", + Args: cobra.NoArgs, + RunE: runPersonaUpdate, +} + func init() { rootCmd.AddCommand(personasCmd) personasCmd.AddCommand(personasListCmd) @@ -63,6 +70,7 @@ func init() { registerFilterFlag(personasListCmd, flagIncludeDeleted, "", "Include deleted personas") personasCmd.AddCommand(personasCreateCmd) + personasCmd.AddCommand(personasUpdateCmd) personasCmd.AddCommand(personasShowCmd) personasCmd.AddCommand(personasDeleteCmd) personasCmd.AddCommand(personasEmailsCmd) @@ -71,6 +79,12 @@ func init() { // Create command flags (auto-generated) RegisterPersonaCreateFlags(personasCreateCmd) + // Update command flags + personasUpdateCmd.Flags().StringVar(&personaID, "persona-id", "", "Persona ID (required)") + _ = personasUpdateCmd.MarkFlagRequired("persona-id") + RegisterPersonaUpdateFlags(personasUpdateCmd) + _ = personasUpdateCmd.MarkFlagRequired("name") + // Show command flags personasShowCmd.Flags().StringVar(&personaID, "persona-id", "", "Persona ID (required)") _ = personasShowCmd.MarkFlagRequired("persona-id") @@ -261,3 +275,30 @@ func runPersonaSms(cmd *cobra.Command, args []string) error { return GetFormatter().Print(resp.JSON200) } + +func runPersonaUpdate(cmd *cobra.Command, args []string) error { + client, err := GetClient() + if err != nil { + return err + } + + body, err := BuildPersonaUpdateRequest(cmd) + if err != nil { + return err + } + + ctx, cancel := GetContextWithTimeout(cmd.Context()) + defer cancel() + + params := &api.PersonaUpdateParams{} + resp, err := client.Client().PersonaUpdateWithResponse(ctx, personaID, params, *body) + if err != nil { + return fmt.Errorf("API request failed: %w", err) + } + + if err := HandleAPIResponse(resp.HTTPResponse, resp.Body); err != nil { + return err + } + + return GetFormatter().Print(resp.JSON200) +} diff --git a/internal/cmd/personaupdate_flags.gen.go b/internal/cmd/personaupdate_flags.gen.go new file mode 100644 index 0000000..9cdf1de --- /dev/null +++ b/internal/cmd/personaupdate_flags.gen.go @@ -0,0 +1,35 @@ +// Code generated by gen-flags DO NOT EDIT. +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/nottelabs/notte-cli/internal/api" +) + +// PersonaUpdate command flags +var ( + // New name for the persona + PersonaUpdateName string +) + +// RegisterPersonaUpdateFlags registers all flags for PersonaUpdate command +func RegisterPersonaUpdateFlags(cmd *cobra.Command) { + cmd.Flags().StringVar(&PersonaUpdateName, "name", "", "New name for the persona") +} + +// BuildPersonaUpdateRequest builds the API request from CLI flags +func BuildPersonaUpdateRequest(cmd *cobra.Command) (*api.PersonaUpdateRequest, error) { + body := &api.PersonaUpdateRequest{} + + if cmd.Flags().Changed("name") { + if PersonaUpdateName == "" { + return nil, fmt.Errorf("--name cannot be empty") + } + body.Name = PersonaUpdateName + } + + return body, nil +} diff --git a/internal/cmd/profiles.go b/internal/cmd/profiles.go index 4ddc1b1..b7a307c 100644 --- a/internal/cmd/profiles.go +++ b/internal/cmd/profiles.go @@ -1,14 +1,22 @@ package cmd import ( + "bytes" + "encoding/json" "fmt" + "os" "github.com/spf13/cobra" "github.com/nottelabs/notte-cli/internal/api" ) -var profileID string +var ( + profileID string + profileCookiesFile string + profileCookiesFormat string + profileCookiesMode string +) var profilesCmd = &cobra.Command{ Use: "profiles", @@ -42,6 +50,23 @@ var profilesDeleteCmd = &cobra.Command{ RunE: runProfileDelete, } +var profilesCookiesCmd = &cobra.Command{ + Use: "cookies", + Short: "Get the cookies stored in a profile", + Args: cobra.NoArgs, + RunE: runProfileCookies, +} + +var profilesCookiesSetCmd = &cobra.Command{ + Use: "cookies-set", + Short: "Import cookies into a profile", + Long: "Import cookies into a profile from a JSON file.\n\n" + + "The file may be a bare array of cookies, which is what Playwright and the\n" + + "Chrome extensions export, or an object with a `cookies` key.", + Args: cobra.NoArgs, + RunE: runProfileCookiesSet, +} + func init() { rootCmd.AddCommand(profilesCmd) profilesCmd.AddCommand(profilesListCmd) @@ -63,6 +88,19 @@ func init() { // Delete command flags profilesDeleteCmd.Flags().StringVar(&profileID, "profile-id", "", "Profile ID (required)") _ = profilesDeleteCmd.MarkFlagRequired("profile-id") + + // Cookies command flags + profilesCmd.AddCommand(profilesCookiesCmd) + profilesCookiesCmd.Flags().StringVar(&profileID, "profile-id", "", "Profile ID (required)") + _ = profilesCookiesCmd.MarkFlagRequired("profile-id") + + profilesCmd.AddCommand(profilesCookiesSetCmd) + profilesCookiesSetCmd.Flags().StringVar(&profileID, "profile-id", "", "Profile ID (required)") + _ = profilesCookiesSetCmd.MarkFlagRequired("profile-id") + profilesCookiesSetCmd.Flags().StringVar(&profileCookiesFile, "file", "", "Path to a cookies JSON file (required)") + _ = profilesCookiesSetCmd.MarkFlagRequired("file") + profilesCookiesSetCmd.Flags().StringVar(&profileCookiesFormat, "source-format", "", "Format the cookies were exported in (playwright, chrome)") + profilesCookiesSetCmd.Flags().StringVar(&profileCookiesMode, "mode", "", "replace the profile's cookies, or append to them") } func runProfilesList(cmd *cobra.Command, args []string) error { @@ -198,3 +236,95 @@ func runProfileDelete(cmd *cobra.Command, args []string) error { "status": "deleted", }) } + +func runProfileCookies(cmd *cobra.Command, args []string) error { + client, err := GetClient() + if err != nil { + return err + } + + ctx, cancel := GetContextWithTimeout(cmd.Context()) + defer cancel() + + params := &api.ProfileCookiesGetParams{} + resp, err := client.Client().ProfileCookiesGetWithResponse(ctx, profileID, params) + if err != nil { + return fmt.Errorf("API request failed: %w", err) + } + + if err := HandleAPIResponse(resp.HTTPResponse, resp.Body); err != nil { + return err + } + + return GetFormatter().Print(resp.JSON200) +} + +func runProfileCookiesSet(cmd *cobra.Command, args []string) error { + client, err := GetClient() + if err != nil { + return err + } + + fileData, err := os.ReadFile(profileCookiesFile) + if err != nil { + return fmt.Errorf("failed to read cookies file: %w", err) + } + + body, err := parseProfileCookies(fileData) + if err != nil { + return err + } + if profileCookiesFormat != "" { + format := api.ProfileCookiesImportRequestSourceFormat(profileCookiesFormat) + body.SourceFormat = &format + } + if profileCookiesMode != "" { + mode := api.ProfileCookiesImportRequestMode(profileCookiesMode) + body.Mode = &mode + } + + ctx, cancel := GetContextWithTimeout(cmd.Context()) + defer cancel() + + params := &api.ProfileCookiesSetParams{} + resp, err := client.Client().ProfileCookiesSetWithResponse(ctx, profileID, params, *body) + if err != nil { + return fmt.Errorf("API request failed: %w", err) + } + + if err := HandleAPIResponse(resp.HTTPResponse, resp.Body); err != nil { + return err + } + + return GetFormatter().Print(resp.JSON200) +} + +// parseProfileCookies accepts either shape a cookies file comes in. +// +// Playwright's storageState and the browser extensions people use both write a +// bare array, while the API wants it under a `cookies` key. Requiring the +// caller to reshape their own export first would be a papercut for no reason, +// so both are read here. +func parseProfileCookies(data []byte) (*api.ProfileCookiesImportRequest, error) { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 { + return nil, fmt.Errorf("cookies file %s is empty", profileCookiesFile) + } + + if trimmed[0] == '[' { + var cookies []api.Cookie + if err := json.Unmarshal(trimmed, &cookies); err != nil { + return nil, fmt.Errorf("failed to parse cookies JSON: %w", err) + } + return &api.ProfileCookiesImportRequest{Cookies: cookies}, nil + } + + var body api.ProfileCookiesImportRequest + if err := json.Unmarshal(trimmed, &body); err != nil { + return nil, fmt.Errorf("failed to parse cookies JSON: %w", err) + } + if len(body.Cookies) == 0 { + return nil, fmt.Errorf("cookies file %s has no cookies: expected an array, or an object with a \"cookies\" key", profileCookiesFile) + } + return &body, nil +} diff --git a/internal/cmd/usage.go b/internal/cmd/usage.go index 7492762..dac8998 100644 --- a/internal/cmd/usage.go +++ b/internal/cmd/usage.go @@ -8,7 +8,12 @@ import ( "github.com/nottelabs/notte-cli/internal/api" ) -var usageShowPeriod string +var ( + usageShowPeriod string + usageLogsEndpoint string + usageLogsCurrentOnly bool + usageLogsSystem bool +) var usageCmd = &cobra.Command{ Use: "usage", @@ -17,11 +22,26 @@ var usageCmd = &cobra.Command{ RunE: runUsageShow, } +var usageLogsCmd = &cobra.Command{ + Use: "logs", + Short: "List API request logs", + Long: "List the API requests made with this workspace's credentials, newest first.", + Args: cobra.NoArgs, + RunE: runUsageLogs, +} + func init() { rootCmd.AddCommand(usageCmd) // Flags for usage show command usageCmd.Flags().StringVar(&usageShowPeriod, "period", "", "Monthly period to get usage for (e.g., 'May 2025')") + + // Flags for usage logs command + usageCmd.AddCommand(usageLogsCmd) + registerPaginationFlags(usageLogsCmd) + usageLogsCmd.Flags().StringVar(&usageLogsEndpoint, "endpoint", "", "Only show requests to this endpoint, e.g. /sessions/start") + usageLogsCmd.Flags().BoolVar(&usageLogsCurrentOnly, "only-current-token", false, "Only show requests made with the API key in use now") + usageLogsCmd.Flags().BoolVar(&usageLogsSystem, "include-system", false, "Include Notte's own internal requests") } func runUsageShow(cmd *cobra.Command, args []string) error { @@ -50,3 +70,60 @@ func runUsageShow(cmd *cobra.Command, args []string) error { formatter := GetFormatter() return formatter.Print(resp.JSON200) } + +func runUsageLogs(cmd *cobra.Command, args []string) error { + client, err := GetClient() + if err != nil { + return err + } + + page, err := getPageFlag(cmd) + if err != nil { + return err + } + pageSize, err := getPageSizeFlag(cmd) + if err != nil { + return err + } + + params := &api.GetUsageLogsParams{ + Page: page, + PageSize: pageSize, + } + if usageLogsEndpoint != "" { + params.Endpoint = &usageLogsEndpoint + } + // Sent only when asked, like every other optional filter: the API owns the + // defaults, and transmitting false would freeze today's values into the + // client. `only_active` is deliberately not exposed - it is the shared + // listing filter, and a request log is never active or inactive. + if cmd.Flags().Changed("only-current-token") { + params.OnlyCurrentToken = &usageLogsCurrentOnly + } + if cmd.Flags().Changed("include-system") { + params.IncludeSystem = &usageLogsSystem + } + + ctx, cancel := GetContextWithTimeout(cmd.Context()) + defer cancel() + + resp, err := client.Client().GetUsageLogsWithResponse(ctx, params) + if err != nil { + return fmt.Errorf("API request failed: %w", err) + } + + if err := HandleAPIResponse(resp.HTTPResponse, resp.Body); err != nil { + return err + } + var items []api.UsageLog + if resp.JSON200 != nil { + items = resp.JSON200.Items + } + if printed, err := PrintListOrEmpty(items, "No usage logs found."); err != nil { + return err + } else if printed { + return nil + } + + return GetFormatter().Print(items) +} diff --git a/scripts/checkcoverage/skills.go b/scripts/checkcoverage/skills.go index 7faf783..6b478a0 100644 --- a/scripts/checkcoverage/skills.go +++ b/scripts/checkcoverage/skills.go @@ -48,15 +48,18 @@ func leafCommands(root *cobra.Command) []string { if c.Hidden || c.Name() == "help" || c.Name() == "completion" { return } - children := c.Commands() - runnable := false - for _, child := range children { + hasVisibleChildren := false + for _, child := range c.Commands() { if !child.Hidden && child.Name() != "help" && child.Name() != "completion" { - runnable = true + hasVisibleChildren = true walk(child) } } - if !runnable && c != root { + // A group that also runs - `notte usage` shows usage and owns + // `usage logs` - is a command in its own right, so it needs prose too. + // Keying on "has no children" alone would let it slip through the moment + // it grew a subcommand. + if c != root && (!hasVisibleChildren || c.Runnable()) { leaves = append(leaves, commandPath(c)) } } diff --git a/scripts/endpoint-coverage.txt b/scripts/endpoint-coverage.txt index 036a172..f3aff6c 100644 --- a/scripts/endpoint-coverage.txt +++ b/scripts/endpoint-coverage.txt @@ -61,8 +61,4 @@ skip POST /anything/start # anything.notte.cc entry point, not a CLI workflow # --- not exposed yet --------------------------------------------------------- # Each of these is a gap someone should close or rule out; listing them is the # point of this file, not an endorsement. -skip PATCH /personas/{persona_id} # no `personas update` command yet -skip POST /profiles/{profile_id}/cookies # no `profiles cookies-set` command yet -skip GET /profiles/{profile_id}/cookies # no `profiles cookies` command yet -skip POST /profiles/{profile_id}/duplicate # `profiles duplicate` is proposed in #53 -skip GET /usage/logs # no `usage logs` command yet +skip POST /profiles/{profile_id}/duplicate # `profiles duplicate` is proposed in #53 diff --git a/scripts/gen-flags/parser.go b/scripts/gen-flags/parser.go index 9a8008f..0f1ae17 100644 --- a/scripts/gen-flags/parser.go +++ b/scripts/gen-flags/parser.go @@ -98,6 +98,7 @@ type endpoint struct { var endpointMap = map[endpoint]string{ {"POST", "/sessions/start"}: "SessionStart", {"POST", "/personas/create"}: "PersonaCreate", + {"PATCH", "/personas/{persona_id}"}: "PersonaUpdate", {"POST", "/profiles/create"}: "ProfileCreate", {"POST", "/vaults/create"}: "VaultCreate", {"PATCH", "/vaults/{vault_id}"}: "VaultUpdate", @@ -255,7 +256,7 @@ func processField(commandName, fieldName string, field *Field, schemas map[strin category = CategoryFlattenedFlags } - flagName := toKebabCase(fieldName) + flagName := FlagNameFor(commandName, fieldName) varName := commandName + toCamelCase(fieldName) fc := &FieldConfig{ diff --git a/scripts/gen-flags/parser_test.go b/scripts/gen-flags/parser_test.go index 78a10ca..6a00e7f 100644 --- a/scripts/gen-flags/parser_test.go +++ b/scripts/gen-flags/parser_test.go @@ -196,3 +196,21 @@ func TestCommandScopedSkipOnlyAppliesToItsCommand(t *testing.T) { t.Error("variables should not be skipped outside FunctionScheduleSet") } } + +// A flag may be named differently from its API field when the field name is +// ambiguous on a command line: `instructions` on FunctionConfigure documents +// the function for its callers, and bare --instructions was read as input to +// the self-healing agent. +func TestFlagNameOverrideRenamesOnlyItsOwnCommand(t *testing.T) { + if got := FlagNameFor("FunctionConfigure", "instructions"); got != "run-instructions" { + t.Errorf("FlagNameFor(FunctionConfigure, instructions) = %q, want run-instructions", got) + } + // The same field name elsewhere is untouched: `notte page scrape + // --instructions` means what it says. + if got := FlagNameFor("ScrapeWebpage", "instructions"); got != "instructions" { + t.Errorf("FlagNameFor(ScrapeWebpage, instructions) = %q, want instructions", got) + } + if got := FlagNameFor("FunctionConfigure", "self_healing"); got != "self-healing" { + t.Errorf("FlagNameFor(FunctionConfigure, self_healing) = %q, want self-healing", got) + } +} diff --git a/scripts/gen-flags/types.go b/scripts/gen-flags/types.go index 7a5f44a..642f0e6 100644 --- a/scripts/gen-flags/types.go +++ b/scripts/gen-flags/types.go @@ -77,6 +77,29 @@ var FlattenWithoutPrefix = map[string]map[string]bool{ }, } +// FieldFlagNameOverrides renames a flag away from its field name, per command. +// +// Only for fields whose API name is ambiguous on a command line. +// FunctionConfigure's `instructions` is the case it exists for: it documents a +// function for whoever calls it - how long a run takes, what the variables mean +// - and bare `--instructions` reads like configuration for the self-healing +// agent, which is what it was mistaken for. +var FieldFlagNameOverrides = map[string]map[string]string{ + "FunctionConfigure": { + "instructions": "run-instructions", + }, +} + +// FlagNameFor returns the flag a field is exposed as. +func FlagNameFor(commandName, fieldName string) string { + if overrides, ok := FieldFlagNameOverrides[commandName]; ok { + if name, ok := overrides[fieldName]; ok { + return name + } + } + return toKebabCase(fieldName) +} + // FieldDescriptionOverrides contains command-scoped descriptions for fields // whose OpenAPI metadata is currently flattened away before flag generation. var FieldDescriptionOverrides = map[string]map[string]string{ @@ -103,9 +126,12 @@ var FieldDescriptionOverrides = map[string]map[string]string{ "VaultUpdate": { "name": "New name for the vault", }, + "PersonaUpdate": { + "name": "New name for the persona", + }, "FunctionConfigure": { - "instructions": "Instructions the self-healing agent follows when the function breaks", - "self_healing": "Let the agent repair the function when a run fails", + "instructions": "Notes for whoever calls this function: how long a run takes, what the variables mean, what it trips over", + "self_healing": "Let an agent repair the function when a run fails", }, } From 98ede2121d51b3568248e6a84607722750466ae7 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Mon, 31 Aug 2026 14:19:19 +0200 Subject: [PATCH 2/2] fix: refuse an empty cookie set instead of emptying the profile Greptile catch. `[]` parses fine as an array, so the bare-array branch sent an empty cookie list - and --mode defaults to replace, so the API would have emptied the profile. The wrapped-object branch already refused this; now both do, through one error so they cannot drift apart again. The test covers every shape that means "no cookies": a bare `[]`, a wrapped empty list, an object with no cookies key, and whitespace. Each asserts nothing reached the API, not just that an error came back. Co-Authored-By: Claude Opus 5 (1M context) --- internal/cmd/coveragegaps_test.go | 36 ++++++++++++++++++++----------- internal/cmd/profiles.go | 14 +++++++++++- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/internal/cmd/coveragegaps_test.go b/internal/cmd/coveragegaps_test.go index d3041a8..df2ece7 100644 --- a/internal/cmd/coveragegaps_test.go +++ b/internal/cmd/coveragegaps_test.go @@ -145,24 +145,34 @@ func TestProfileCookiesSet_AcceptsBothFileShapes(t *testing.T) { } } +// Both shapes have to refuse an empty set. `--mode` defaults to replace, so +// sending zero cookies empties the profile - a destructive result from what is +// almost always a bad export. func TestProfileCookiesSet_RejectsAFileWithNoCookies(t *testing.T) { - setupCoverageTest(t) + for _, content := range []string{`{"notCookies":1}`, `[]`, `{"cookies":[]}`, ` `} { + t.Run(content, func(t *testing.T) { + server := setupCoverageTest(t) - path := filepath.Join(t.TempDir(), "cookies.json") - if err := os.WriteFile(path, []byte(`{"notCookies":1}`), 0o600); err != nil { - t.Fatalf("writing file: %v", err) - } + path := filepath.Join(t.TempDir(), "cookies.json") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("writing file: %v", err) + } - origID, origFile := profileID, profileCookiesFile - profileID, profileCookiesFile = profileIDTest, path - t.Cleanup(func() { profileID, profileCookiesFile = origID, origFile }) + origID, origFile := profileID, profileCookiesFile + profileID, profileCookiesFile = profileIDTest, path + t.Cleanup(func() { profileID, profileCookiesFile = origID, origFile }) - cmd := &cobra.Command{} - cmd.SetContext(context.Background()) + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) - err := runProfileCookiesSet(cmd, nil) - if err == nil || !strings.Contains(err.Error(), "no cookies") { - t.Fatalf("expected a no-cookies error, got %v", err) + err := runProfileCookiesSet(cmd, nil) + if err == nil { + t.Fatal("expected an error rather than a request that empties the profile") + } + if len(server.Requests("/profiles/"+profileIDTest+"/cookies")) != 0 { + t.Error("an empty cookie set still reached the API") + } + }) } } diff --git a/internal/cmd/profiles.go b/internal/cmd/profiles.go index b7a307c..8f79753 100644 --- a/internal/cmd/profiles.go +++ b/internal/cmd/profiles.go @@ -316,6 +316,12 @@ func parseProfileCookies(data []byte) (*api.ProfileCookiesImportRequest, error) if err := json.Unmarshal(trimmed, &cookies); err != nil { return nil, fmt.Errorf("failed to parse cookies JSON: %w", err) } + // `[]` parses fine and would be sent as an empty list, which in the + // default replace mode empties the profile - a destructive result from + // what is almost always a bad export. + if len(cookies) == 0 { + return nil, emptyCookiesError() + } return &api.ProfileCookiesImportRequest{Cookies: cookies}, nil } @@ -324,7 +330,13 @@ func parseProfileCookies(data []byte) (*api.ProfileCookiesImportRequest, error) return nil, fmt.Errorf("failed to parse cookies JSON: %w", err) } if len(body.Cookies) == 0 { - return nil, fmt.Errorf("cookies file %s has no cookies: expected an array, or an object with a \"cookies\" key", profileCookiesFile) + return nil, emptyCookiesError() } return &body, nil } + +func emptyCookiesError() error { + return fmt.Errorf( + "cookies file %s has no cookies: expected an array, or an object with a \"cookies\" key", + profileCookiesFile) +}