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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,11 @@ Per-category updates are partial — only categories you name are changed; other

### Projects

- `kernel projects list` - List projects (up to 100 by default)
- `--limit <n>` - Maximum number of projects to return (1-100, default 100)
- `--offset <n>` - Number of projects to skip; table indexes match this offset
- `--output json`, `-o json` - Output `{ "projects": [...], "next_offset": <n> }`; `next_offset` is omitted on the last page
- When more projects are available, the CLI prints the exact command to fetch the next page
- `kernel projects update <id-or-name>` - Update a project's name or status
- `--name <name>` - New project name (1-255 characters)
- `--status <status>` - New project status: `active` or `archived`
Expand Down
116 changes: 109 additions & 7 deletions cmd/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package cmd

import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"

"github.com/kernel/cli/pkg/util"
Expand Down Expand Up @@ -37,7 +40,11 @@ type ProjectsCmd struct {
limits ProjectLimitsService
}

type ProjectsListInput struct{}
type ProjectsListInput struct {
Limit int
Offset int
Output string
}

type ProjectsCreateInput struct {
Name string
Expand Down Expand Up @@ -87,24 +94,112 @@ func resolveProjectArg(ctx context.Context, projects ProjectListService, val str
}

func (c ProjectsCmd) List(ctx context.Context, in ProjectsListInput) error {
projects, err := c.projects.List(ctx, kernel.ProjectListParams{})
if err := validateJSONOutput(in.Output); err != nil {
return err
}
if in.Limit < 1 || in.Limit > 100 {
return fmt.Errorf("--limit must be between 1 and 100")
}
if in.Offset < 0 {
return fmt.Errorf("--offset must be non-negative")
}

var response *http.Response
projects, err := c.projects.List(ctx, kernel.ProjectListParams{
Limit: param.NewOpt(int64(in.Limit)),
Offset: param.NewOpt(int64(in.Offset)),
}, option.WithResponseInto(&response))
if err != nil {
return util.CleanedUpSdkError{Err: err}
}

if projects == nil || len(projects.Items) == 0 {
items := make([]kernel.Project, 0)
if projects != nil {
items = projects.Items
}
pagination, err := parseProjectListPagination(response)
if err != nil {
return err
}

if in.Output == "json" {
data, err := marshalProjectsListJSON(projects, pagination.NextOffset)
if err != nil {
return err
}
fmt.Println(string(data))
return nil
}

if len(items) == 0 {
pterm.Info.Println("No projects found")
return nil
}

table := pterm.TableData{{"ID", "Name", "Status", "Created At"}}
for _, p := range projects.Items {
table = append(table, []string{p.ID, p.Name, string(p.Status), util.FormatLocal(p.CreatedAt)})
table := pterm.TableData{{"ID", "Name", "Status", "Created At", "idx"}}
for i, p := range items {
table = append(table, []string{
p.ID,
p.Name,
string(p.Status),
util.FormatLocal(p.CreatedAt),
strconv.Itoa(in.Offset + i),
})
}
PrintTableNoPad(table, true)

if pagination.HasMore {
pterm.Warning.Printfln(
"Output truncated after index %d. Continue with: kernel projects list --limit %d --offset %d",
in.Offset+len(items)-1, in.Limit, pagination.NextOffset,
)
}
Comment thread
cursor[bot] marked this conversation as resolved.
return nil
}

type projectListPagination struct {
HasMore bool
NextOffset int
}

func parseProjectListPagination(response *http.Response) (projectListPagination, error) {
if response == nil {
return projectListPagination{}, fmt.Errorf("project list response is missing pagination headers")
}

hasMoreValue := response.Header.Get("X-Has-More")
hasMore, err := strconv.ParseBool(hasMoreValue)
if err != nil {
return projectListPagination{}, fmt.Errorf("invalid X-Has-More header %q", hasMoreValue)
}

nextOffsetValue := response.Header.Get("X-Next-Offset")
nextOffset, err := strconv.Atoi(nextOffsetValue)
if err != nil || nextOffset < 0 {
return projectListPagination{}, fmt.Errorf("invalid X-Next-Offset header %q", nextOffsetValue)
}
if hasMore && nextOffset == 0 {
return projectListPagination{}, fmt.Errorf("X-Has-More is true but X-Next-Offset is not positive")
}
if !hasMore && nextOffset != 0 {
return projectListPagination{}, fmt.Errorf("X-Has-More is false but X-Next-Offset is %d", nextOffset)
}

return projectListPagination{HasMore: hasMore, NextOffset: nextOffset}, nil
}

func marshalProjectsListJSON(projects *pagination.OffsetPagination[kernel.Project], nextOffset int) ([]byte, error) {
rawProjects := json.RawMessage("[]")
if projects != nil && len(projects.Items) > 0 {
rawProjects = json.RawMessage(projects.RawJSON())
}
payload := struct {
Projects json.RawMessage `json:"projects"`
NextOffset int `json:"next_offset,omitempty"`
}{Projects: rawProjects, NextOffset: nextOffset}
return json.MarshalIndent(payload, "", " ")
}

func (c ProjectsCmd) Create(ctx context.Context, in ProjectsCreateInput) error {
project, err := c.projects.New(ctx, kernel.ProjectNewParams{
CreateProjectRequest: kernel.CreateProjectRequestParam{
Expand Down Expand Up @@ -323,7 +418,10 @@ func getProjectsHandler(cmd *cobra.Command) ProjectsCmd {

func runProjectsList(cmd *cobra.Command, args []string) error {
c := getProjectsHandler(cmd)
return c.List(cmd.Context(), ProjectsListInput{})
limit, _ := cmd.Flags().GetInt("limit")
offset, _ := cmd.Flags().GetInt("offset")
output, _ := cmd.Flags().GetString("output")
return c.List(cmd.Context(), ProjectsListInput{Limit: limit, Offset: offset, Output: output})
}

func runProjectsCreate(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -477,6 +575,10 @@ var projectsSetLimitsCompatCmd = &cobra.Command{
}

func init() {
projectsListCmd.Flags().Int("limit", 100, "Maximum number of projects to return (1-100)")
projectsListCmd.Flags().Int("offset", 0, "Number of projects to skip (for pagination)")
addJSONOutputFlag(projectsListCmd)

projectsUpdateCmd.Flags().String("name", "", "New project name (1-255 characters)")
projectsUpdateCmd.Flags().String("status", "", "New project status: active or archived")
addJSONOutputFlag(projectsUpdateCmd)
Expand Down
122 changes: 122 additions & 0 deletions cmd/projects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ package cmd
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"

"github.com/kernel/kernel-go-sdk"
"github.com/kernel/kernel-go-sdk/option"
"github.com/kernel/kernel-go-sdk/packages/pagination"
"github.com/kernel/kernel-go-sdk/packages/respjson"
"github.com/pterm/pterm"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type FakeProjectsService struct {
Expand Down Expand Up @@ -74,6 +78,124 @@ func (f *FakeProjectLimitsService) Update(ctx context.Context, id string, body k
return &kernel.ProjectLimits{}, nil
}

func TestProjectsList_UsesSDKResponsePaginationMetadata(t *testing.T) {
const responseBody = `[
{"id":"project-alpha","name":"alpha","status":"active","created_at":"2026-08-08T12:00:00Z","updated_at":"2026-08-08T12:00:00Z"},
{"id":"project-beta","name":"beta","status":"archived","created_at":"2026-08-08T12:01:00Z","updated_at":"2026-08-08T12:01:00Z"}
]`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/org/projects", r.URL.Path)
assert.Equal(t, "2", r.URL.Query().Get("limit"))
assert.Equal(t, "20", r.URL.Query().Get("offset"))
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Has-More", "true")
w.Header().Set("X-Next-Offset", "22")
_, _ = w.Write([]byte(responseBody))
}))
defer server.Close()

client := kernel.NewClient(option.WithBaseURL(server.URL), option.WithAPIKey("test"))
c := ProjectsCmd{projects: &client.Projects, limits: &client.Projects.Limits}

buf := capturePtermOutput(t)
err := c.List(context.Background(), ProjectsListInput{Limit: 2, Offset: 20})
require.NoError(t, err)
out := pterm.RemoveColorFromString(buf.String())
assert.Contains(t, out, "idx")
assert.Regexp(t, `(?m)^project-alpha\s+\| alpha\s+\| active\s+\| [^|]+\| 20\s*$`, out)
assert.Regexp(t, `(?m)^project-beta\s+\| beta\s+\| archived\s+\| [^|]+\| 21\s*$`, out)
assert.Contains(t, out, "kernel projects list --limit 2 --offset 22")

jsonOutput := captureStdout(t, func() {
err = c.List(context.Background(), ProjectsListInput{Limit: 2, Offset: 20, Output: "json"})
})
require.NoError(t, err)
assert.JSONEq(t, `{"projects":`+responseBody+`,"next_offset":22}`, jsonOutput)
}

func TestProjectsList_RejectsInvalidPagination(t *testing.T) {
fakeProjects := &FakeProjectsService{
ListFunc: func(ctx context.Context, query kernel.ProjectListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Project], error) {
t.Fatal("List should not be called")
return nil, nil
},
}
c := ProjectsCmd{projects: fakeProjects, limits: &FakeProjectLimitsService{}}

for _, in := range []ProjectsListInput{
{Limit: 0},
{Limit: 101},
{Limit: 100, Offset: -1},
{Limit: 100, Output: "yaml"},
} {
assert.Error(t, c.List(context.Background(), in))
}
}

func TestParseProjectListPagination(t *testing.T) {
tests := []struct {
name string
response *http.Response
want projectListPagination
wantErr string
}{
{
name: "more results",
response: &http.Response{Header: http.Header{"X-Has-More": []string{"true"}, "X-Next-Offset": []string{"120"}}},
want: projectListPagination{HasMore: true, NextOffset: 120},
},
{
name: "terminal page",
response: &http.Response{Header: http.Header{"X-Has-More": []string{"false"}, "X-Next-Offset": []string{"0"}}},
want: projectListPagination{},
},
{name: "missing response", wantErr: "missing pagination headers"},
{
name: "missing has more",
response: &http.Response{Header: http.Header{"X-Next-Offset": []string{"120"}}},
wantErr: "invalid X-Has-More",
},
{
name: "has more with missing cursor",
response: &http.Response{Header: http.Header{"X-Has-More": []string{"true"}}},
wantErr: "invalid X-Next-Offset",
},
{
name: "has more with malformed cursor",
response: &http.Response{Header: http.Header{"X-Has-More": []string{"true"}, "X-Next-Offset": []string{"next"}}},
wantErr: "invalid X-Next-Offset",
},
{
name: "has more with terminal cursor",
response: &http.Response{Header: http.Header{"X-Has-More": []string{"true"}, "X-Next-Offset": []string{"0"}}},
wantErr: "X-Next-Offset is not positive",
},
{
name: "terminal page with cursor",
response: &http.Response{Header: http.Header{"X-Has-More": []string{"false"}, "X-Next-Offset": []string{"120"}}},
wantErr: "X-Has-More is false",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseProjectListPagination(tt.response)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}

func TestMarshalProjectsListJSON_EmptyPage(t *testing.T) {
data, err := marshalProjectsListJSON(nil, 0)
require.NoError(t, err)
assert.JSONEq(t, `{"projects":[]}`, string(data))
}

func TestProjectsLimitsGet_DefaultOutput(t *testing.T) {
buf := capturePtermOutput(t)
limits := &kernel.ProjectLimits{
Expand Down
Loading