Skip to content

Commit 4850029

Browse files
committed
chore: initial project setup
1 parent 129b85a commit 4850029

8 files changed

Lines changed: 439 additions & 0 deletions

File tree

.github/workflows/cd.yml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
name: CD
2+
3+
on:
4+
push:
5+
tags: ['v*']
6+
workflow_dispatch:
7+
inputs:
8+
tag:
9+
description: 'Tag to release (e.g. v0.2.0)'
10+
required: true
11+
12+
env:
13+
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
14+
15+
jobs:
16+
test:
17+
name: Test
18+
runs-on: ubuntu-latest
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- name: Set up Go
23+
uses: actions/setup-go@v5
24+
with:
25+
go-version: "1.24.4"
26+
27+
- name: Run tests
28+
run: go test ./...
29+
30+
release:
31+
name: Release
32+
runs-on: ubuntu-latest
33+
needs: [test]
34+
permissions:
35+
contents: write
36+
steps:
37+
- uses: actions/checkout@v4
38+
39+
- name: Create GitHub Release
40+
env:
41+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
42+
run: |
43+
gh release view ${{ env.RELEASE_TAG }} --repo ${{ github.repository }} \
44+
&& echo "Release already exists, skipping" \
45+
|| gh release create ${{ env.RELEASE_TAG }} \
46+
--title "${{ env.RELEASE_TAG }}" \
47+
--generate-notes \
48+
--repo ${{ github.repository }}

.github/workflows/ci.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
build-and-test:
11+
name: Build and Test
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- name: Set up Go
17+
uses: actions/setup-go@v5
18+
with:
19+
go-version: "1.24.4"
20+
21+
- name: Run tests
22+
run: go test ./...

capabilityclient/client.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Package capabilityclient provides a typed client for calling ctx capabilities
2+
// from within extension containers.
3+
//
4+
// The client calls the capability server sidecar via HTTP. The server URL and
5+
// bearer token are injected by ctx into the container environment as
6+
// CTX_CAPABILITY_URL and CTX_CAPABILITY_TOKEN.
7+
package capabilityclient
8+
9+
import (
10+
"bytes"
11+
"context"
12+
"encoding/json"
13+
"fmt"
14+
"io"
15+
"net/http"
16+
"os"
17+
"strings"
18+
)
19+
20+
const (
21+
envCapabilityURL = "CTX_CAPABILITY_URL"
22+
envCapabilityToken = "CTX_CAPABILITY_TOKEN"
23+
)
24+
25+
// Client calls ctx capabilities via the capability server sidecar.
26+
type Client struct {
27+
httpC *http.Client
28+
}
29+
30+
// New returns a Client. The client requires CTX_CAPABILITY_URL to be set in
31+
// the environment (injected by ctx when starting the container).
32+
func New() *Client {
33+
return &Client{httpC: &http.Client{}}
34+
}
35+
36+
// Run executes a named capability with the given input (marshalled to JSON)
37+
// and returns the raw JSON output bytes.
38+
//
39+
// Returns an error if CTX_CAPABILITY_URL is not set or the server returns
40+
// a non-2xx status.
41+
func (c *Client) Run(ctx context.Context, capability string, input any) ([]byte, error) {
42+
url := os.Getenv(envCapabilityURL)
43+
if url == "" {
44+
return nil, fmt.Errorf("capability-client: CTX_CAPABILITY_URL is not set; capabilities require a ctx-managed container")
45+
}
46+
47+
inputJSON, err := json.Marshal(input)
48+
if err != nil {
49+
return nil, fmt.Errorf("capability-client: marshal input for %q: %w", capability, err)
50+
}
51+
envelope := struct {
52+
Input json.RawMessage `json:"input"`
53+
}{Input: inputJSON}
54+
body, err := json.Marshal(envelope)
55+
if err != nil {
56+
return nil, fmt.Errorf("capability-client: marshal envelope for %q: %w", capability, err)
57+
}
58+
59+
reqURL := strings.TrimRight(url, "/") + "/run/" + capability
60+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(body))
61+
if err != nil {
62+
return nil, fmt.Errorf("capability-client: build request for %q: %w", capability, err)
63+
}
64+
req.Header.Set("Content-Type", "application/json")
65+
if token := os.Getenv(envCapabilityToken); token != "" {
66+
req.Header.Set("Authorization", "Bearer "+token)
67+
}
68+
69+
resp, err := c.httpC.Do(req)
70+
if err != nil {
71+
return nil, fmt.Errorf("capability-client: http request for %q: %w", capability, err)
72+
}
73+
defer resp.Body.Close()
74+
75+
respBody, err := io.ReadAll(resp.Body)
76+
if err != nil {
77+
return nil, fmt.Errorf("capability-client: read response for %q: %w", capability, err)
78+
}
79+
if resp.StatusCode != http.StatusOK {
80+
return nil, &RunError{
81+
Capability: capability,
82+
Cause: fmt.Errorf("http status %d", resp.StatusCode),
83+
Stderr: strings.TrimSpace(string(respBody)),
84+
}
85+
}
86+
return respBody, nil
87+
}
88+
89+
// RunError is returned when the capability call fails (non-2xx HTTP status).
90+
type RunError struct {
91+
Capability string
92+
Cause error
93+
Stderr string
94+
}
95+
96+
func (e *RunError) Error() string {
97+
if e.Stderr != "" {
98+
return fmt.Sprintf("capability-client: %s: %v\n%s", e.Capability, e.Cause, e.Stderr)
99+
}
100+
return fmt.Sprintf("capability-client: %s: %v", e.Capability, e.Cause)
101+
}
102+
103+
func (e *RunError) Unwrap() error { return e.Cause }

capabilityclient/client_test.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package capabilityclient_test
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"io"
8+
"net/http"
9+
"net/http/httptest"
10+
"strings"
11+
"testing"
12+
13+
"github.com/delegateas/ctx-sdk/capabilityclient"
14+
)
15+
16+
func TestClient_New(t *testing.T) {
17+
if capabilityclient.New() == nil {
18+
t.Fatal("New() returned nil")
19+
}
20+
}
21+
22+
func TestClient_Run_NoURL_Error(t *testing.T) {
23+
t.Setenv("CTX_CAPABILITY_URL", "")
24+
c := capabilityclient.New()
25+
_, err := c.Run(context.Background(), "ai.generate", map[string]string{"prompt": "hi"})
26+
if err == nil {
27+
t.Fatal("expected error when CTX_CAPABILITY_URL is not set")
28+
}
29+
if !strings.Contains(err.Error(), "CTX_CAPABILITY_URL") {
30+
t.Errorf("error should mention CTX_CAPABILITY_URL, got: %v", err)
31+
}
32+
}
33+
34+
func TestClient_Run_Success(t *testing.T) {
35+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
36+
w.Header().Set("Content-Type", "application/json")
37+
w.Write([]byte(`{"output":"hello"}`)) //nolint:errcheck
38+
}))
39+
defer srv.Close()
40+
t.Setenv("CTX_CAPABILITY_URL", srv.URL)
41+
42+
c := capabilityclient.New()
43+
out, err := c.Run(context.Background(), "ai.generate", map[string]string{"prompt": "hello"})
44+
if err != nil {
45+
t.Fatalf("unexpected error: %v", err)
46+
}
47+
var result map[string]string
48+
if err := json.Unmarshal(out, &result); err != nil {
49+
t.Fatalf("unmarshal output: %v", err)
50+
}
51+
if result["output"] != "hello" {
52+
t.Errorf("output = %q; want %q", result["output"], "hello")
53+
}
54+
}
55+
56+
func TestClient_Run_NonOK(t *testing.T) {
57+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
58+
http.Error(w, "something went wrong", http.StatusInternalServerError)
59+
}))
60+
defer srv.Close()
61+
t.Setenv("CTX_CAPABILITY_URL", srv.URL)
62+
63+
c := capabilityclient.New()
64+
_, err := c.Run(context.Background(), "ai.generate", map[string]string{"prompt": "hello"})
65+
if err == nil {
66+
t.Fatal("expected error, got nil")
67+
}
68+
var runErr *capabilityclient.RunError
69+
if !errors.As(err, &runErr) {
70+
t.Fatalf("error type = %T; want *RunError", err)
71+
}
72+
if runErr.Capability != "ai.generate" {
73+
t.Errorf("Capability = %q; want %q", runErr.Capability, "ai.generate")
74+
}
75+
if !strings.Contains(runErr.Stderr, "something went wrong") {
76+
t.Errorf("Stderr = %q; want to contain 'something went wrong'", runErr.Stderr)
77+
}
78+
}
79+
80+
func TestClient_Run_SendsToken(t *testing.T) {
81+
const token = "secret-token"
82+
var gotAuth string
83+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
84+
gotAuth = r.Header.Get("Authorization")
85+
w.Header().Set("Content-Type", "application/json")
86+
w.Write([]byte(`{}`)) //nolint:errcheck
87+
}))
88+
defer srv.Close()
89+
t.Setenv("CTX_CAPABILITY_URL", srv.URL)
90+
t.Setenv("CTX_CAPABILITY_TOKEN", token)
91+
92+
capabilityclient.New().Run(context.Background(), "ai.generate", nil) //nolint:errcheck
93+
if gotAuth != "Bearer "+token {
94+
t.Errorf("Authorization = %q; want %q", gotAuth, "Bearer "+token)
95+
}
96+
}
97+
98+
func TestClient_Run_MarshalInput(t *testing.T) {
99+
var gotBody []byte
100+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
101+
gotBody, _ = io.ReadAll(r.Body)
102+
w.Header().Set("Content-Type", "application/json")
103+
w.Write([]byte(`{"output":"ok"}`)) //nolint:errcheck
104+
}))
105+
defer srv.Close()
106+
t.Setenv("CTX_CAPABILITY_URL", srv.URL)
107+
108+
type myInput struct {
109+
Prompt string `json:"prompt"`
110+
Count int `json:"count"`
111+
}
112+
capabilityclient.New().Run(context.Background(), "test.cap", myInput{Prompt: "test", Count: 42}) //nolint:errcheck
113+
114+
var envelope struct {
115+
Input struct {
116+
Prompt string `json:"prompt"`
117+
Count int `json:"count"`
118+
} `json:"input"`
119+
}
120+
if err := json.Unmarshal(gotBody, &envelope); err != nil {
121+
t.Fatalf("unmarshal request body: %v", err)
122+
}
123+
if envelope.Input.Prompt != "test" || envelope.Input.Count != 42 {
124+
t.Errorf("input = %+v; want {Prompt:test Count:42}", envelope.Input)
125+
}
126+
}
127+
128+
func TestRunError_Error_WithStderr(t *testing.T) {
129+
err := &capabilityclient.RunError{
130+
Capability: "ai.generate",
131+
Cause: errors.New("exit status 1"),
132+
Stderr: "something failed",
133+
}
134+
msg := err.Error()
135+
if !strings.Contains(msg, "ai.generate") {
136+
t.Errorf("Error() missing capability name: %q", msg)
137+
}
138+
if !strings.Contains(msg, "something failed") {
139+
t.Errorf("Error() missing stderr: %q", msg)
140+
}
141+
}
142+
143+
func TestRunError_Error_WithoutStderr(t *testing.T) {
144+
err := &capabilityclient.RunError{
145+
Capability: "ai.generate",
146+
Cause: errors.New("exit status 1"),
147+
}
148+
if !strings.Contains(err.Error(), "ai.generate") {
149+
t.Errorf("Error() missing capability name: %q", err.Error())
150+
}
151+
}
152+
153+
func TestRunError_Unwrap(t *testing.T) {
154+
cause := errors.New("root cause")
155+
runErr := &capabilityclient.RunError{Capability: "ai.generate", Cause: cause}
156+
if !errors.Is(runErr, cause) {
157+
t.Error("errors.Is failed — Unwrap not working")
158+
}
159+
}

capabilityclient/generate.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package capabilityclient
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"strings"
8+
)
9+
10+
type generateInput struct {
11+
Prompt string `json:"prompt"`
12+
}
13+
14+
type generateOutput struct {
15+
Output string `json:"output"`
16+
}
17+
18+
// Generate calls the built-in ai.generate capability with the given prompt and
19+
// returns the trimmed text output.
20+
//
21+
// This is the typed equivalent of:
22+
//
23+
// ctx run ai.generate --input '{"prompt":"..."}'
24+
func (c *Client) Generate(ctx context.Context, prompt string) (string, error) {
25+
if prompt == "" {
26+
return "", fmt.Errorf("capability-client: Generate: prompt must not be empty")
27+
}
28+
raw, err := c.Run(ctx, "ai.generate", generateInput{Prompt: prompt})
29+
if err != nil {
30+
return "", err
31+
}
32+
var out generateOutput
33+
if err := json.Unmarshal(raw, &out); err != nil {
34+
return "", fmt.Errorf("capability-client: Generate: decode response: %w", err)
35+
}
36+
return strings.TrimSpace(out.Output), nil
37+
}

0 commit comments

Comments
 (0)