Skip to content
Draft
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## next

- Add `--skill` root flag to identify the AI skill/workflow invoking a command; folded into the `User-Agent` header sent with API requests

<!-- Add manual release notes here. They will be merged into the generated changelog at release time. -->

## 1.0.3
Expand Down
6 changes: 3 additions & 3 deletions agentenv/agentenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,18 @@ func Detected() string {
if name == "" { // generic var: forward its value
name = val
}
if !validHeaderValue(name) {
if !ValidHeaderValue(name) {
continue
}
return name // first usable match wins
}
return ""
}

// validHeaderValue reports whether s is safe to embed as a single
// ValidHeaderValue reports whether s is safe to embed as a single
// space-separated User-Agent token: no control characters, and no
// whitespace (which would split the token across multiple segments).
func validHeaderValue(s string) bool {
func ValidHeaderValue(s string) bool {
for i := 0; i < len(s); i++ {
b := s[i]
if b < 0x20 || b == 0x7f || b == ' ' {
Expand Down
4 changes: 2 additions & 2 deletions agentenv/agentenv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ func TestValidHeaderValue(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := validHeaderValue(tt.value); got != tt.want {
t.Fatalf("validHeaderValue(%q) = %v, want %v", tt.value, got, tt.want)
if got := ValidHeaderValue(tt.value); got != tt.want {
t.Fatalf("ValidHeaderValue(%q) = %v, want %v", tt.value, got, tt.want)
}
})
}
Expand Down
33 changes: 27 additions & 6 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ const DefaultRecordLimit = 200

// API represents what is allowed to be called on the Prolific client.
type API interface {
// UserAgent returns the User-Agent string to send with requests that
// build their own *http.Request and bypass Execute.
UserAgent() string

GetMe() (*MeResponse, error)

CreateStudy(model.CreateStudy) (*model.Study, error)
Expand Down Expand Up @@ -152,6 +156,28 @@ type Client struct {
BaseURL string
Token string
Debug bool
Skill string
}

// ComposeUserAgent builds the User-Agent string sent with every outgoing
// request: the CLI's own identity, followed by the detected driving AI
// agent (if any), followed by the invoking skill/workflow (if any and
// valid). Each token is independent and omitted when empty or invalid.
func ComposeUserAgent(skill string) string {
ua := "prolific-oss/cli/" + version.Get()
if agent := agentenv.Detected(); agent != "" {
ua += " agent/" + agent
}
if skill != "" && agentenv.ValidHeaderValue(skill) {
ua += " skill/" + skill
}
return ua
}

// UserAgent returns the composed User-Agent string for this client, folding
// in the configured Skill.
func (c *Client) UserAgent() string {
return ComposeUserAgent(c.Skill)
}

// New will return a new Prolific client.
Expand Down Expand Up @@ -190,13 +216,8 @@ func (c *Client) Execute(method, url string, body any, response any) (*http.Resp
return nil, err
}

userAgent := "prolific-oss/cli/" + version.Get()
if agent := agentenv.Detected(); agent != "" {
userAgent += " agent/" + agent
}

request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", userAgent)
request.Header.Set("User-Agent", c.UserAgent())
request.Header.Set("Authorization", fmt.Sprintf("Token %s", c.Token))

if c.Debug {
Expand Down
77 changes: 77 additions & 0 deletions client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,83 @@ func TestFormatBatchErrorBody(t *testing.T) {
})
}
}
func TestComposeUserAgent(t *testing.T) {
knownVars := []string{"CLAUDE_CODE", "ANTIGRAVITY_AGENT", "AI_AGENT", "LLM_AGENT"}

tests := []struct {
name string
skill string
agentEnv map[string]string
want string
}{
{
name: "no skill, no agent",
skill: "",
want: "prolific-oss/cli/" + version.Get(),
},
{
name: "skill only",
skill: "cli-command-create",
want: "prolific-oss/cli/" + version.Get() + " skill/cli-command-create",
},
{
name: "agent and skill together",
skill: "cli-command-create",
agentEnv: map[string]string{"CLAUDE_CODE": "1"},
want: "prolific-oss/cli/" + version.Get() + " agent/claude-code skill/cli-command-create",
},
{
name: "invalid skill (control characters) is dropped",
skill: "bad\nvalue",
want: "prolific-oss/cli/" + version.Get(),
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for _, k := range knownVars {
t.Setenv(k, "") // isolate from ambient agent env vars
}
for k, v := range tt.agentEnv {
t.Setenv(k, v)
}

if got := ComposeUserAgent(tt.skill); got != tt.want {
t.Fatalf("ComposeUserAgent(%q) = %q, want %q", tt.skill, got, tt.want)
}
})
}
}

func TestExecuteSetsSkillInUserAgent(t *testing.T) {
// Isolate from ambient agent env vars (this shell has AI_AGENT set).
for _, k := range []string{"CLAUDE_CODE", "ANTIGRAVITY_AGENT", "AI_AGENT", "LLM_AGENT"} {
t.Setenv(k, "")
}

var gotUserAgent string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUserAgent = r.Header.Get("User-Agent")
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

c := Client{
Client: server.Client(),
BaseURL: server.URL,
Token: "test-token",
Skill: "cli-command-create",
}

if _, err := c.Execute(http.MethodGet, "/studies", nil, nil); err != nil {
t.Fatalf("Execute returned error: %v", err)
}

if want := "prolific-oss/cli/" + version.Get() + " skill/cli-command-create"; gotUserAgent != want {
t.Fatalf("User-Agent = %q, want %q", gotUserAgent, want)
}
}

func TestExecuteSetsAgentInUserAgent(t *testing.T) {
// Isolate from ambient agent env vars (this shell has AI_AGENT set).
for _, k := range []string{"CLAUDE_CODE", "ANTIGRAVITY_AGENT", "AI_AGENT", "LLM_AGENT"} {
Expand Down
11 changes: 6 additions & 5 deletions cmd/aitaskbuilder/batch_export.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ func exportBatch(c client.API, opts BatchExportOptions, w io.Writer) error {

// If already complete (cached result), download immediately.
if initResult.Status == batchExportStatusComplete {
return batchDownloadExport(initResult.URL, opts.Output, w)
return batchDownloadExport(initResult.URL, opts.Output, c.UserAgent(), w)
}

if initResult.Status != batchExportStatusGenerating {
Expand All @@ -129,7 +129,7 @@ func exportBatch(c client.API, opts BatchExportOptions, w io.Writer) error {

switch pollResult.Status {
case batchExportStatusComplete:
return batchDownloadExport(pollResult.URL, opts.Output, w)
return batchDownloadExport(pollResult.URL, opts.Output, c.UserAgent(), w)
case batchExportStatusFailed:
return fmt.Errorf("export generation failed for batch %s", batchID)
case batchExportStatusGenerating:
Expand All @@ -140,16 +140,16 @@ func exportBatch(c client.API, opts BatchExportOptions, w io.Writer) error {
}
}

func batchDownloadExport(rawURL, outputPath string, w io.Writer) error {
func batchDownloadExport(rawURL, outputPath, userAgent string, w io.Writer) error {
fmt.Fprintf(w, "\nExport ready. Downloading to %s...\n", outputPath)
if err := batchDownloadFile(rawURL, outputPath); err != nil {
if err := batchDownloadFile(rawURL, outputPath, userAgent); err != nil {
return fmt.Errorf("error downloading export: %s", err.Error())
}
fmt.Fprintf(w, "Export saved to %s\n", outputPath)
return nil
}

func batchDownloadFile(rawURL, outputPath string) error {
func batchDownloadFile(rawURL, outputPath, userAgent string) error {
parsed, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid download URL: %w", err)
Expand All @@ -165,6 +165,7 @@ func batchDownloadFile(rawURL, outputPath string) error {
if err != nil {
return fmt.Errorf("failed to create download request: %w", err)
}
req.Header.Set("User-Agent", userAgent)

resp, err := batchExportDownloadClient.Do(req)
if err != nil {
Expand Down
20 changes: 15 additions & 5 deletions cmd/aitaskbuilder/batch_export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,17 @@ const testBatchID = "batch-id-123"
// newBatchZIPServer creates a TLS test server that returns the given bytes as a
// download response. Tests must inject srv.Client() via SetBatchExportDownloadClientForTesting
// so that the HTTPS scheme check passes and the self-signed cert is trusted.
func newBatchZIPServer(t *testing.T, content []byte) *httptest.Server {
// The returned pointer captures the User-Agent header of the last request.
func newBatchZIPServer(t *testing.T, content []byte) (*httptest.Server, *string) {
t.Helper()
var gotUserAgent string
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUserAgent = r.Header.Get("User-Agent")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(content)
}))
t.Cleanup(srv.Close)
return srv
return srv, &gotUserAgent
}

func TestNewBatchExportCommand(t *testing.T) {
Expand Down Expand Up @@ -69,13 +72,14 @@ func TestBatchExportCommandRequiresBatchID(t *testing.T) {
// status "complete" immediately (server has a valid cached export).
func TestBatchExportCommandImmediateComplete(t *testing.T) {
zipContent := []byte("PK\x03\x04fake zip content")
srv := newBatchZIPServer(t, zipContent)
srv, gotUserAgent := newBatchZIPServer(t, zipContent)
defer aitaskbuilder.SetBatchExportDownloadClientForTesting(srv.Client())()

ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockClient := mock_client.NewMockAPI(ctrl)

mockClient.EXPECT().UserAgent().Return(testUserAgent).Times(1)
mockClient.
EXPECT().
InitiateBatchExport(gomock.Eq(testBatchID)).
Expand Down Expand Up @@ -113,6 +117,10 @@ func TestBatchExportCommandImmediateComplete(t *testing.T) {
if !strings.Contains(output, outputPath) {
t.Errorf("expected output to mention file path %q, got: %s", outputPath, output)
}

if *gotUserAgent != testUserAgent {
t.Errorf("expected User-Agent %q, got %q", testUserAgent, *gotUserAgent)
}
}

// TestBatchExportCommandPollingToComplete covers the normal async flow:
Expand All @@ -121,13 +129,14 @@ func TestBatchExportCommandPollingToComplete(t *testing.T) {
defer aitaskbuilder.SetBatchExportPollSleepForTesting(func(time.Duration) {})()

zipContent := []byte("PK\x03\x04fake zip content")
srv := newBatchZIPServer(t, zipContent)
srv, _ := newBatchZIPServer(t, zipContent)
defer aitaskbuilder.SetBatchExportDownloadClientForTesting(srv.Client())()

ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockClient := mock_client.NewMockAPI(ctrl)

mockClient.EXPECT().UserAgent().Return(testUserAgent).Times(1)
mockClient.EXPECT().
InitiateBatchExport(gomock.Eq(testBatchID)).
Return(&client.BatchExportResponse{
Expand Down Expand Up @@ -228,13 +237,14 @@ func TestBatchExportCommandInitiateError(t *testing.T) {

func TestBatchExportCommandDefaultOutputPath(t *testing.T) {
zipContent := []byte("PK\x03\x04fake zip content")
srv := newBatchZIPServer(t, zipContent)
srv, _ := newBatchZIPServer(t, zipContent)
defer aitaskbuilder.SetBatchExportDownloadClientForTesting(srv.Client())()

ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockClient := mock_client.NewMockAPI(ctrl)

mockClient.EXPECT().UserAgent().Return(testUserAgent).Times(1)
mockClient.EXPECT().
InitiateBatchExport(gomock.Eq(testBatchID)).
Return(&client.BatchExportResponse{
Expand Down
9 changes: 8 additions & 1 deletion cmd/aitaskbuilder/get_dataset_status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,17 @@ import (
"github.com/prolific-oss/cli/model"
)

// testUserAgent is the default value returned by the UserAgent() mock set up
// in setupMockClient. It's a cross-cutting header sent with every request,
// so most tests don't assert on it directly and just need a harmless default.
const testUserAgent = "prolific-oss/cli/test agent/test-agent skill/test-skill"

func setupMockClient(t *testing.T) *mock_client.MockAPI {
ctrl := gomock.NewController(t)
t.Cleanup(func() { ctrl.Finish() })
return mock_client.NewMockAPI(ctrl)
c := mock_client.NewMockAPI(ctrl)
c.EXPECT().UserAgent().Return(testUserAgent).AnyTimes()
return c
}

func TestNewGetDatasetStatusCommand(t *testing.T) {
Expand Down
5 changes: 3 additions & 2 deletions cmd/aitaskbuilder/upload_dataset.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ func uploadDatasetFile(client client.API, opts DatasetUploadOptions, w io.Writer
fmt.Fprintf(w, "Uploading %s...\n", uploadRequest.DisplayName)

// Upload file to the presigned URL
err = uploadFileToPresignedURL(uploadRequest.LocalPath, uploadResponse.UploadURL, uploadResponse.HTTPMethod, uploadResponse.ContentType)
err = uploadFileToPresignedURL(uploadRequest.LocalPath, uploadResponse.UploadURL, uploadResponse.HTTPMethod, uploadResponse.ContentType, client.UserAgent())
if err != nil {
return fmt.Errorf("failed to upload file: %w", err)
}
Expand Down Expand Up @@ -211,7 +211,7 @@ func detectDatasetUploadFormat(fileName string) (model.DatasetImportFormat, erro
}

// uploadFileToPresignedURL uploads a file to a presigned URL using the API-provided method and content type.
func uploadFileToPresignedURL(filePath, uploadURL, method, contentType string) error {
func uploadFileToPresignedURL(filePath, uploadURL, method, contentType, userAgent string) error {
if method == "" {
return errors.New("upload response missing http method")
}
Expand All @@ -238,6 +238,7 @@ func uploadFileToPresignedURL(filePath, uploadURL, method, contentType string) e

request.ContentLength = info.Size()
request.Header.Set("Content-Type", contentType)
request.Header.Set("User-Agent", userAgent)

response, err := http.DefaultClient.Do(request)
if err != nil {
Expand Down
6 changes: 6 additions & 0 deletions cmd/aitaskbuilder/upload_dataset_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ func TestDatasetUploadCommandUploadsDetectedCSV(t *testing.T) {
var gotContentLength int64
var gotTransferEncoding []string
var gotBody []byte
var gotUserAgent string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
Expand All @@ -94,6 +95,7 @@ func TestDatasetUploadCommandUploadsDetectedCSV(t *testing.T) {
gotContentLength = r.ContentLength
gotTransferEncoding = r.TransferEncoding
gotBody = body
gotUserAgent = r.Header.Get("User-Agent")
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
Expand Down Expand Up @@ -163,6 +165,10 @@ func TestDatasetUploadCommandUploadsDetectedCSV(t *testing.T) {
t.Fatalf("expected uploaded file contents to match")
}

if gotUserAgent != testUserAgent {
t.Fatalf("expected User-Agent %q, got %q", testUserAgent, gotUserAgent)
}

output := b.String()
if !strings.Contains(output, "Import ID: import-123") {
t.Fatalf("expected output to contain import ID, got %s", output)
Expand Down
Loading
Loading