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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ Go module: github.com/pdurlej/things-cloud-sdk
Preferred CLI: things-cloud-cli
Compat CLI: things-cli
MCP server: things-mcp
Latest release: v0.2.3
Latest release: v0.2.4
Focus: safe writes, stable JSON, MCP tools, persistent sync
Maintainer: https://github.com/pdurlej
Co-authors: Piotr Durlej + OpenAI Codex
```

## What You Can Do
Expand Down Expand Up @@ -111,8 +111,8 @@ go install github.com/pdurlej/things-cloud-sdk/cmd/things-mcp@latest
Use a pinned tag for reproducible agent environments:

```bash
go install github.com/pdurlej/things-cloud-sdk/cmd/things-cloud-cli@v0.2.3
go install github.com/pdurlej/things-cloud-sdk/cmd/things-mcp@v0.2.3
go install github.com/pdurlej/things-cloud-sdk/cmd/things-cloud-cli@v0.2.4
go install github.com/pdurlej/things-cloud-sdk/cmd/things-mcp@v0.2.4
```

Use the SDK from Go:
Expand Down
2 changes: 1 addition & 1 deletion cmd/things-mcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
)

const protocolVersion = "2025-06-18"
const serverVersion = "0.2.3"
const serverVersion = "0.2.4"

type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
Expand Down
10 changes: 5 additions & 5 deletions docs/integrations/openclaw-publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ Publish the skill:
clawhub skill publish ./skills/things-cloud \
--slug things-cloud \
--name "Things Cloud" \
--version 0.1.1 \
--version 0.1.2 \
--tags latest \
--changelog "Document OpenClaw, Codex, and Claude Code support via MCP with CLI fallback." \
--changelog "Pin v0.2.4 binaries with fail-closed HTTP parsing and serialized persistent sync." \
--clawscan-note "This skill installs and calls the maintained things-cloud-cli and things-mcp Go binaries. It requires Things Cloud credentials through THINGS_USERNAME plus THINGS_TOKEN or THINGS_PASSWORD. Agent writes should use dry-run before execution."
```

Expand Down Expand Up @@ -99,7 +99,7 @@ Things Cloud.
## Links

- SDK/CLI/MCP: https://github.com/pdurlej/things-cloud-sdk
- Release: https://github.com/pdurlej/things-cloud-sdk/releases/tag/v0.2.3
- Release: https://github.com/pdurlej/things-cloud-sdk/releases/tag/v0.2.4
- Skill: https://github.com/pdurlej/things-cloud-sdk/tree/main/skills/things-cloud

## Capabilities
Expand All @@ -115,8 +115,8 @@ Things Cloud.
## Setup

```bash
go install github.com/pdurlej/things-cloud-sdk/cmd/things-cloud-cli@v0.2.3
go install github.com/pdurlej/things-cloud-sdk/cmd/things-mcp@v0.2.3
go install github.com/pdurlej/things-cloud-sdk/cmd/things-cloud-cli@v0.2.4
go install github.com/pdurlej/things-cloud-sdk/cmd/things-mcp@v0.2.4
export THINGS_USERNAME="you@example.com"
export THINGS_TOKEN="your-things-cloud-password-or-token-alias"
```
Expand Down
2 changes: 1 addition & 1 deletion histories.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ func (h *History) Write(items ...Identifiable) error {
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("write failed: %s", resp.Status)
return fmt.Errorf("Write failed: %s", resp.Status)
}
rs, err := io.ReadAll(resp.Body)
if err != nil {
Expand Down
13 changes: 13 additions & 0 deletions histories_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

Expand Down Expand Up @@ -149,6 +150,18 @@ func TestHistory_WriteRejectsInvalidHistoryID(t *testing.T) {
}
}

func TestHistory_WritePreservesErrorPrefix(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusConflict)
}))
defer server.Close()

err := New(server.URL, "test@example.com", "secret").HistoryWithID("history-id").Write()
if err == nil || !strings.HasPrefix(err.Error(), "Write failed:") {
t.Fatalf("Write error = %v, want legacy prefix", err)
}
}

func TestHistoryOperationsRejectMalformedJSON(t *testing.T) {
tests := []struct {
name string
Expand Down
21 changes: 16 additions & 5 deletions items.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,13 @@ type ItemsOptions struct {
// Note that if a item was changed multiple times it will be present multiple times in the result too.
func (h *History) Items(opts ItemsOptions) ([]Item, bool, error) {
req, err := http.NewRequest("GET", fmt.Sprintf("/version/1/history/%s/items", h.ID), nil)
if err != nil {
return nil, false, err
}

values := req.URL.Query()
values.Set("start-index", strconv.Itoa(opts.StartIndex))
req.URL.RawQuery = values.Encode()

if err != nil {
return nil, false, err
}
resp, err := h.Client.do(req)
if err != nil {
return nil, false, err
Expand All @@ -64,12 +63,24 @@ func (h *History) Items(opts ItemsOptions) ([]Item, bool, error) {
}
var v itemsResponse
if err := json.Unmarshal(bs, &v); err != nil {
return nil, false, err
return nil, false, fmt.Errorf("decoding items response: %w", err)
}
if v.Items == nil {
return nil, false, fmt.Errorf("items response is missing items")
}
if len(v.Items) == 0 && opts.StartIndex < v.CurrentItemIndex {
return nil, false, fmt.Errorf("items response stalled at index %d before server index %d", opts.StartIndex, v.CurrentItemIndex)
}
var items = []Item{}
for offset, m := range v.Items {
if len(m) == 0 {
return nil, false, fmt.Errorf("items response contains an empty batch at index %d", opts.StartIndex+offset)
}
serverIndex := opts.StartIndex + offset
for id, item := range m {
if id == "" || item.Kind == "" {
return nil, false, fmt.Errorf("items response contains an invalid item at index %d", serverIndex)
}
item.UUID = id
item.ServerIndex = serverIndex
item.HasServerIndex = true
Expand Down
69 changes: 69 additions & 0 deletions items_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,73 @@ func TestHistory_Items(t *testing.T) {
t.Errorf("task-c ServerIndex = %d, want 11", indexByUUID["task-c"])
}
})

t.Run("InvalidHistoryID", func(t *testing.T) {
c := New("https://example.com", "test@example.com", "secret")
h := c.HistoryWithID("invalid\nvalue")
if _, _, err := h.Items(ItemsOptions{}); err == nil {
t.Fatal("Items succeeded with an invalid history ID")
}
})

t.Run("RejectsMalformedPages", func(t *testing.T) {
tests := []struct {
name string
body string
}{
{"missing items", `{"current-item-index":0,"schema":301}`},
{"stalled page", `{"items":[],"current-item-index":2,"schema":301}`},
{"empty batch", `{"items":[{}],"current-item-index":1,"schema":301}`},
{"missing kind", `{"items":[{"task-a":{"t":0,"p":{}}}],"current-item-index":1,"schema":301}`},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, tt.body)
}))
defer server.Close()

h := New(server.URL, "test@example.com", "secret").HistoryWithID("history-id")
if _, _, err := h.Items(ItemsOptions{}); err == nil {
t.Fatal("Items accepted a malformed page")
}
})
}
})

t.Run("AllowsEmptyCaughtUpPage", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"items":[],"current-item-index":4,"schema":301}`)
}))
defer server.Close()

h := New(server.URL, "test@example.com", "secret").HistoryWithID("history-id")
items, more, err := h.Items(ItemsOptions{StartIndex: 4})
if err != nil {
t.Fatalf("Items rejected a valid caught-up page: %v", err)
}
if len(items) != 0 || more {
t.Fatalf("Items returned items=%d more=%v, want empty and caught up", len(items), more)
}
})

t.Run("AcceptsDeletionAndTombstoneKinds", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"items":[{"deleted-task":{"e":"Task6","t":2,"p":{}}},{"tombstone":{"e":"Tombstone2","t":0,"p":{"do":"deleted-task"}}}],"current-item-index":2,"schema":301}`)
}))
defer server.Close()

h := New(server.URL, "test@example.com", "secret").HistoryWithID("history-id")
items, _, err := h.Items(ItemsOptions{})
if err != nil {
t.Fatalf("Items rejected valid deletion kinds: %v", err)
}
if len(items) != 2 || items[0].Kind != ItemKindTask || items[1].Kind != ItemKindTombstone {
t.Fatalf("Items returned unexpected deletion kinds: %#v", items)
}
})
}
10 changes: 5 additions & 5 deletions skills/things-cloud/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: things-cloud
description: Manage Things 3 tasks through Things Cloud using the maintained things-cloud-sdk CLI and MCP server, with dry-run safety for agent writes.
version: 0.1.1
version: 0.1.2
metadata:
openclaw:
requires:
Expand All @@ -27,11 +27,11 @@ metadata:
description: Optional path to the CLI read-state cache.
install:
- kind: go
package: github.com/pdurlej/things-cloud-sdk/cmd/things-cloud-cli@v0.2.3
package: github.com/pdurlej/things-cloud-sdk/cmd/things-cloud-cli@v0.2.4
bins:
- things-cloud-cli
- kind: go
package: github.com/pdurlej/things-cloud-sdk/cmd/things-mcp@v0.2.3
package: github.com/pdurlej/things-cloud-sdk/cmd/things-mcp@v0.2.4
bins:
- things-mcp
homepage: https://github.com/pdurlej/things-cloud-sdk
Expand All @@ -53,8 +53,8 @@ when the user asks for explicit shell commands.
Install the maintained CLI and MCP server:

```bash
go install github.com/pdurlej/things-cloud-sdk/cmd/things-cloud-cli@v0.2.3
go install github.com/pdurlej/things-cloud-sdk/cmd/things-mcp@v0.2.3
go install github.com/pdurlej/things-cloud-sdk/cmd/things-cloud-cli@v0.2.4
go install github.com/pdurlej/things-cloud-sdk/cmd/things-mcp@v0.2.4
```

Set credentials:
Expand Down
14 changes: 8 additions & 6 deletions sync/process.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@ func (s *Syncer) processItems(items []things.Item, baseIndex int) ([]Change, err
}
defer tx.Rollback() // No-op if committed

// Store original db, swap in transaction
origDB := s.db
s.db = tx
defer func() { s.db = origDB }()
batch := &Syncer{
rawDB: s.rawDB,
db: tx,
client: s.client,
history: s.history,
}

var allChanges []Change

Expand All @@ -38,15 +40,15 @@ func (s *Syncer) processItems(items []things.Item, baseIndex int) ([]Change, err
}
ts := time.Now()

changes, err := s.processItem(item, serverIndex, ts)
changes, err := batch.processItem(item, serverIndex, ts)
if err != nil {
return nil, fmt.Errorf("processing item %s: %w", item.UUID, err)
}

// Log each change
for _, change := range changes {
payload, _ := json.Marshal(item.P)
if err := s.logChange(serverIndex, change, string(payload)); err != nil {
if err := batch.logChange(serverIndex, change, string(payload)); err != nil {
return nil, fmt.Errorf("logging change: %w", err)
}
}
Expand Down
5 changes: 5 additions & 0 deletions sync/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package sync
import (
"database/sql"
"strings"
"sync"
"time"

things "github.com/pdurlej/things-cloud-sdk"
Expand All @@ -29,6 +30,7 @@ type Syncer struct {
db dbExecutor // current executor (db or tx)
client *things.Client
history *things.History
syncMu sync.Mutex
}

type syncOptions struct {
Expand Down Expand Up @@ -93,6 +95,9 @@ func (s *Syncer) QuickSync() ([]Change, error) {
}

func (s *Syncer) syncWithOptions(opts syncOptions) ([]Change, error) {
s.syncMu.Lock()
defer s.syncMu.Unlock()

// Get current sync state first
storedHistoryID, startIndex, err := s.getSyncState()
if err != nil {
Expand Down
6 changes: 4 additions & 2 deletions verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ type VerifyResponse struct {
// Verify checks that the provided API credentials are valid.
func (c *Client) Verify() (*VerifyResponse, error) {
req, err := http.NewRequest("GET", fmt.Sprintf("/version/1/account/%s", c.EMail), nil)
req.Header.Set("Authorization", fmt.Sprintf("Password %s", c.password))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Password %s", c.password))
resp, err := c.do(req)
if err != nil {
return nil, err
Expand All @@ -48,6 +48,8 @@ func (c *Client) Verify() (*VerifyResponse, error) {
if err != nil {
return nil, err
}
json.Unmarshal(bs, &v)
if err := json.Unmarshal(bs, &v); err != nil {
return nil, fmt.Errorf("decoding verify response: %w", err)
}
return &v, nil
}
21 changes: 21 additions & 0 deletions verify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package thingscloud

import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
)

Expand Down Expand Up @@ -32,4 +34,23 @@ func TestClient_Verify(t *testing.T) {
t.Error("Expected Verification to fail, but didn't")
}
})

t.Run("InvalidEmail", func(t *testing.T) {
c := New("https://example.com", "invalid\nemail", "secret")
if _, err := c.Verify(); err == nil {
t.Fatal("Verify succeeded with an invalid email")
}
})

t.Run("MalformedJSON", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("not-json"))
}))
defer server.Close()

if _, err := New(server.URL, "test@example.com", "secret").Verify(); err == nil {
t.Fatal("Verify accepted malformed JSON")
}
})
}
Loading