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
13 changes: 13 additions & 0 deletions cli/cmd/cmd_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cmd

import (
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
)
Expand Down Expand Up @@ -29,3 +31,14 @@ func setFlag(t *testing.T, target *string, value string) {

t.Cleanup(func() { *target = prev })
}

// withFakeRemote starts a fake remote API on a test server, points the
// CLI's --addr flag at it, and registers cleanup. Callers that need an
// API key on the wire should setFlag(&flagAPIKey, ...) separately.
func withFakeRemote(t *testing.T, handler http.Handler) {
t.Helper()

srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
setFlag(t, &flagAddr, srv.URL)
}
82 changes: 36 additions & 46 deletions cli/cmd/drain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package cmd
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -62,8 +61,19 @@ func TestDrainDryRunJSONFormat(t *testing.T) {
func TestDrainPushesUnits(t *testing.T) {
testSetup(t)

// Propose locally first (no remote configured).
propose := NewProposeCmd()
propose.SetArgs([]string{
"--summary", "push-me",
"--detail", "d",
"--action", "a",
"--domain", "test",
})
require.NoError(t, propose.Execute())

// Point at remote, then drain.
var pushCount int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
withFakeRemote(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/knowledge" && r.Method == "POST" {
pushCount++
w.Header().Set("Content-Type", "application/json")
Expand All @@ -75,20 +85,6 @@ func TestDrainPushesUnits(t *testing.T) {

w.WriteHeader(http.StatusServiceUnavailable)
}))
defer srv.Close()

// Propose locally first (no remote configured).
propose := NewProposeCmd()
propose.SetArgs([]string{
"--summary", "push-me",
"--detail", "d",
"--action", "a",
"--domain", "test",
})
require.NoError(t, propose.Execute())

// Point at remote, then drain.
setFlag(t, &flagAddr, srv.URL)

drain := NewDrainCmd()
var buf bytes.Buffer
Expand All @@ -101,19 +97,6 @@ func TestDrainPushesUnits(t *testing.T) {
func TestDrainJSONFormat(t *testing.T) {
testSetup(t)

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/knowledge" && r.Method == "POST" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":"ku_00000000000000000000000000000001","version":1,"domains":["test"],"insight":{"summary":"s","detail":"d","action":"a"},"context":{"languages":[],"frameworks":[],"pattern":""},"evidence":{"confidence":0.5,"confirmations":1},"tier":"local","flags":[]}`))

return
}

w.WriteHeader(http.StatusServiceUnavailable)
}))
defer srv.Close()

// Propose locally first (no remote configured).
propose := NewProposeCmd()
propose.SetArgs([]string{
Expand All @@ -125,7 +108,17 @@ func TestDrainJSONFormat(t *testing.T) {
require.NoError(t, propose.Execute())

// Point at remote, then drain.
setFlag(t, &flagAddr, srv.URL)
withFakeRemote(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/knowledge" && r.Method == "POST" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":"ku_00000000000000000000000000000001","version":1,"domains":["test"],"insight":{"summary":"s","detail":"d","action":"a"},"context":{"languages":[],"frameworks":[],"pattern":""},"evidence":{"confidence":0.5,"confirmations":1},"tier":"local","flags":[]}`))

return
}

w.WriteHeader(http.StatusServiceUnavailable)
}))

drain := NewDrainCmd()
var buf bytes.Buffer
Expand Down Expand Up @@ -161,21 +154,6 @@ func TestDrainAddrFromEnv(t *testing.T) {
func TestDrainAddrFlagOverridesEnv(t *testing.T) {
testSetup(t)

var pushCount int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/knowledge" && r.Method == "POST" {
pushCount++
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":"ku_00000000000000000000000000000001","version":1,"domains":["test"],"insight":{"summary":"s","detail":"d","action":"a"},"context":{"languages":[],"frameworks":[],"pattern":""},"evidence":{"confidence":0.5,"confirmations":1},"tier":"local","flags":[]}`))

return
}

w.WriteHeader(http.StatusServiceUnavailable)
}))
defer srv.Close()

// Propose locally first (no remote configured).
propose := NewProposeCmd()
propose.SetArgs([]string{
Expand All @@ -188,7 +166,19 @@ func TestDrainAddrFlagOverridesEnv(t *testing.T) {

// Env says one thing, but the flag (simulating --addr) says another.
t.Setenv(envVarAddr, "http://env-addr:8742")
setFlag(t, &flagAddr, srv.URL)
var pushCount int
withFakeRemote(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/knowledge" && r.Method == "POST" {
pushCount++
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":"ku_00000000000000000000000000000001","version":1,"domains":["test"],"insight":{"summary":"s","detail":"d","action":"a"},"context":{"languages":[],"frameworks":[],"pattern":""},"evidence":{"confidence":0.5,"confirmations":1},"tier":"local","flags":[]}`))

return
}

w.WriteHeader(http.StatusServiceUnavailable)
}))

drain := NewDrainCmd()
var buf bytes.Buffer
Expand Down
4 changes: 4 additions & 0 deletions cli/cmd/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ func NewQueryCmd() *cobra.Command {
return err
}

for _, w := range qr.Warnings {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "warning: %s\n", w)
}

if format == "json" {
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", jsonIndent)
Expand Down
27 changes: 27 additions & 0 deletions cli/cmd/query_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"bytes"
"encoding/json"
"net/http"
"testing"

"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -127,6 +128,32 @@ func TestQueryUnsupportedFormat(t *testing.T) {
require.Error(t, query.Execute())
}

func TestQueryPrintsRemoteWarningsToStderr(t *testing.T) {
testSetup(t)

// Fake remote that returns a bare JSON array instead of the {data: [...]}
// envelope. The SDK should fail to decode and surface a warning, which
// the CLI must print to stderr.
withFakeRemote(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("[]"))
}))

query := NewQueryCmd()
var out, errBuf bytes.Buffer
query.SetOut(&out)
query.SetErr(&errBuf)
query.SetArgs([]string{"--domain", "api"})
require.NoError(t, query.Execute())

// The warning must be specifically about the decode failure; a generic
// "warning:" line could come from any future warning source and would
// mask a regression where the decode error stops surfacing.
stderr := errBuf.String()
require.Contains(t, stderr, "warning:")
require.Contains(t, stderr, "decoding")
}

func TestQueryPatternFlag(t *testing.T) {
testSetup(t)

Expand Down
2 changes: 1 addition & 1 deletion cli/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,4 @@ require (

// Monorepo: the SDK is consumed locally. Re-pin to a published version
// when the SDK is released alongside the CLI.
//replace github.com/mozilla-ai/cq/sdk/go => ../sdk/go
replace github.com/mozilla-ai/cq/sdk/go => ../sdk/go
2 changes: 0 additions & 2 deletions cli/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mozilla-ai/cq/schema v0.0.1 h1:jPoZAYbU7/b3R3CEghmwuG0A3NrqczWsMkIWjXeFI94=
github.com/mozilla-ai/cq/schema v0.0.1/go.mod h1:trKUR0o8hGYkQ26XAb3HFVHcQOct7lP/cS8beS5v2eE=
github.com/mozilla-ai/cq/sdk/go v0.7.0 h1:JJO2tjqEHSv0zrSQwUwYhjE1XJIS9s82J4nElsI4Glg=
github.com/mozilla-ai/cq/sdk/go v0.7.0/go.mod h1:0lsVnbeeJS76z9Q2AYMBndYPhuYDtZdovLAAvmZDA74=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
Expand Down
48 changes: 48 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,3 +458,51 @@ The ecosystem convergence on MCP and Agent Skills means cq does not need to conv
Non-Claude-Code hosts (Cursor, Windsurf, OpenCode) are installed via a host-agnostic Python installer at `scripts/install/`. It is a stdlib-only uv-managed project whose CLI (`python -m cq_install install --target <host>`) resolves a per-host target directory, writes the host-specific MCP config, and installs the shared skill commons to `~/.agents/skills/cq/` (or a project-scoped equivalent). Adding a new host is a single file under `scripts/install/src/cq_install/hosts/`: the primitive library in `common.py` (merge-not-replace JSON, hook entry, manifest-tracked file copies, markdown blocks) handles the shared mechanics. Claude Code remains on its own native marketplace via a thin wrapper host that shells out to `claude plugin marketplace`.

> **Domain scope:** The initial implementation targets coding agents — the domain where agent tooling is most mature and adoption is fastest. The underlying mechanism (structured knowledge sharing via MCP with tiered trust) generalizes to arbitrary domains: DevOps, security, data engineering, and beyond.

---

## 6. HTTP API Conventions

Reference for endpoint authors and SDK implementers. The HTTP surface follows two response-shape conventions for list-returning endpoints and a small set of cross-cutting rules.

### List vs Page

Every list-returning endpoint emits one of two shapes. The model name suffix tells the caller which one to expect.

**`FooList`** — unpaginated. The response is the whole set or a server-clamped top-N. Callers treat it as "what you got, full stop."

```json
{
"data": [Foo, Foo, ...]
}
```

**`FooPage`** — cursor-paginated. The response is a slice of an ordered stream, with an opaque token to fetch the next slice.

```json
{
"data": [Foo, Foo, ...],
"next_cursor": "opaque-token-or-null"
}
```

Callers paginate by passing `next_cursor` back as a query parameter on the next request. `next_cursor: null` (or omitted) means end of stream. The cursor is server-encoded and opaque to clients; clients must not parse or construct it.

### Why cursor over offset

Cursor (a.k.a. keyset) pagination anchors to a position in the sort order rather than a numeric offset. Concurrent inserts or deletes in the filtered set do not cause skips or duplicates between page fetches. This matters most for mutable filtered streams like review queues or recent-activity feeds; for stable browse sets either shape works, but `Page` is the more honest contract once a list is large enough that any client might want to paginate it. Offset pagination is not used.

### Rules

- **`data` is always the root key for the items**, in both `List` and `Page`. Consumers always start with the same first level of JSON regardless of which shape they hit. There is no third shape.
- **Suffix discipline.** A model name ending in `List` means unpaginated; ending in `Page` means cursor-paginated. The suffix is the contract; do not invent new ones.
- **No `count`, no `total`, no `offset` in either envelope.** A `count` field that equals `len(data)` carries no information and pre-commits the field name to a meaning that conflicts with a future "total-matches-before-limit" value. If a caller genuinely needs the total count of matches, expose it as a separate endpoint or a dedicated `/count` sub-resource rather than smuggling it into the list response.
- **JSON wire fields stay snake_case** in every language. Class and type names follow each language's native idiom; the wire shape does not.
- **Class naming across languages.** Python (Pydantic models) and TypeScript use `ApiKeyList`, `KnowledgeUnitList`, etc., with camelCase initialisms — this keeps wire-facing types symmetric across the two languages. Go uses `APIKeyList`, `KnowledgeUnitList` with all-caps initialisms per standard Go convention. All three names serialize to and from the same JSON.

### Today

- `GET /api/v1/knowledge` returns `KnowledgeUnitList`.
- `GET /api/v1/users/me/api-keys` returns `ApiKeyList`.

No cursor-paginated endpoints exist yet; the first one to land will follow the `FooPage` shape above.
9 changes: 7 additions & 2 deletions sdk/go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -378,12 +378,17 @@ func (c *Client) Query(ctx context.Context, params QueryParams) (QueryResult, er

normalised := params
normalised.Limit = limit
remoteResults := c.remote.query(ctx, normalised)
remoteResults, remoteErr := c.remote.query(ctx, normalised)

warnings := storeResult.Warnings
if remoteErr != nil {
warnings = append(warnings, remoteErr)
}

return QueryResult{
Units: mergeResults(localResults, remoteResults, limit),
Source: SourceRemote,
Warnings: storeResult.Warnings,
Warnings: warnings,
}, nil
}

Expand Down
24 changes: 22 additions & 2 deletions sdk/go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,9 @@ func TestQueryMergesLocalAndRemote(t *testing.T) {
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]map[string]any{testRemoteKUJSON("ku_00000000000000000000000000000003")})
_ = json.NewEncoder(w).Encode(map[string]any{
"data": []map[string]any{testRemoteKUJSON("ku_00000000000000000000000000000003")},
})
}))
ctx := context.Background()

Expand Down Expand Up @@ -470,7 +472,9 @@ func TestQuerySourceRemoteWhenOnlyRemoteReturnsResults(t *testing.T) {

c := newTestClientWithRemote(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]map[string]any{testRemoteKUJSON("ku_00000000000000000000000000000005")})
_ = json.NewEncoder(w).Encode(map[string]any{
"data": []map[string]any{testRemoteKUJSON("ku_00000000000000000000000000000005")},
})
}))

qr, err := c.Query(context.Background(), QueryParams{Domains: []string{"api"}})
Expand All @@ -489,6 +493,22 @@ func TestQuerySourceRemoteWhenRemoteFails(t *testing.T) {
require.NoError(t, err)
require.Empty(t, qr.Units)
require.Equal(t, SourceRemote, qr.Source)
require.NotEmpty(t, qr.Warnings, "remote failure should surface as a warning")
}

func TestQueryWarnsOnRemoteDecodeFailure(t *testing.T) {

c := newTestClientWithRemote(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Bare array; the SDK now expects the {data: [...]} envelope.
_, _ = w.Write([]byte(`[{"id":"ku_00000000000000000000000000000099"}]`))
}))

qr, err := c.Query(context.Background(), QueryParams{Domains: []string{"api"}})
require.NoError(t, err)
require.Empty(t, qr.Units)
require.NotEmpty(t, qr.Warnings)
require.Contains(t, qr.Warnings[0].Error(), "decoding")
}

func TestConfirmLocalUnit(t *testing.T) {
Expand Down
Loading
Loading