Skip to content

Commit 8f2a251

Browse files
author
Alex Skatell
committed
feat(local): native zero-MCP path — sync contacts/conversations/messages, add deal brief
Extend the local SQLite mirror to cover the full agent working set, not just pipelines + opportunities, and add the first compound command on top. internal/sync: - syncContacts: paginated /contacts/search → contacts table - syncConversations: /conversations/search → conversations table - syncMessages: per-conversation /conversations/{id}/messages → messages - relax TestSyncAll count assertion; entity coverage moved to sync_entities_test internal/commands: - topline local deal brief --opportunity-id OPP --messages N single-call snapshot: opportunity + stage + pipeline + contact + N most recent messages + open tasks + recent notes (all local SQL, zero network) - wired into local subcommand dispatcher + help This unblocks unanswered/orphans/followup-queue/hygiene since all four need contacts+messages local. TOPLINE_QUERY_TOKEN and hosted MCP not required for any of this surface. Tests: go test ./... clean.
1 parent a7ddb5c commit 8f2a251

8 files changed

Lines changed: 1634 additions & 8 deletions

File tree

README.md

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
Agent-native command line interface for Topline OS.
44

5-
`os-cli` is the Printing Press-style companion to [`Topline-com/os-mcp`](https://github.com/Topline-com/os-mcp): the MCP exposes safe CRM actions to agents; this CLI gives operators and agents fast, composable muscle memory for CRM work.
5+
`os-cli` is a single static binary that runs **standalone**: one Private
6+
Integration Token, one location ID, no MCP server install, no second token,
7+
no second hostname. Analytics commands query a local SQLite mirror of your
8+
CRM — synced with `topline local sync` and queried with `topline local sql`.
9+
The legacy hosted `query` family that delegated to `os-mcp` still works for
10+
back-compat, but it is no longer required for any first-class workflow.
611

712
## Why this exists
813

@@ -141,7 +146,27 @@ hidden tables are rejected) and caps results at 5,000 rows. Use this for
141146
Streamlined-style analytics questions where a relational scan beats paginated
142147
REST fan-out.
143148

144-
Local SQLite foundation:
149+
Native (recommended) — local SQLite mirror, one token, zero MCP:
150+
151+
```bash
152+
topline local sync # pulls pipelines + opportunities into ~/.topline/state.db
153+
topline local status # row counts + last sync timestamp
154+
topline local sql --sql 'SELECT status, COUNT(*) AS n FROM opportunities GROUP BY status'
155+
topline local pipeline snapshot --pipeline-id PIPE # open count + value per stage (local)
156+
topline local pipeline stale --days 14 # open opportunities untouched 14d+ (local)
157+
```
158+
159+
The `local` family uses only `TOPLINE_PIT` + `TOPLINE_LOCATION_ID`. No
160+
`TOPLINE_QUERY_TOKEN`, no hosted MCP, no separate connect flow. The mirror
161+
is a plain SQLite file at `~/.topline/state.db` (override with `--db PATH`
162+
or `TOPLINE_DB`). Schema lives in `internal/sync/schema.go` and ships with
163+
the binary.
164+
165+
This is the path under active development — compound commands
166+
(`bottleneck`, `orphans`, `unanswered`, `health`, `deal brief`) land on top
167+
of the local mirror in subsequent phases.
168+
169+
Legacy SQLite scaffolding (kept for back-compat):
145170

146171
```bash
147172
topline sync init --db topline.db
@@ -185,13 +210,14 @@ Parity command scaffolding exists for the public MCP action surface:
185210

186211
Agent-native foundations included now:
187212

213+
- `local sync` — REST → local SQLite mirror (pipelines, opportunities); idempotent, paginated
214+
- `local sql` — read-only SQL against the local mirror
215+
- `local pipeline snapshot` — open count + value per stage, locally computed
216+
- `local pipeline stale --days N` — open opportunities untouched N days, locally computed
217+
- `local status` — row counts + last sync timestamp
188218
- `pipeline audit` with recent conversation scan + parallel message/task joins
189-
- `query schema|catalog|explain|sql` against the hosted MCP warehouse HTTP API
190-
- `sync init`
191-
- `--agent`
192-
- `--mask-pii`
193-
- compact JSON output
194-
- SQLite schema for CRM mirror tables
219+
- `query schema|catalog|explain|sql` — back-compat hosted MCP warehouse surface
220+
- `--agent`, `--mask-pii`, compact JSON output
195221

196222
## Design direction
197223

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package commands
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"database/sql"
7+
"encoding/json"
8+
"path/filepath"
9+
"testing"
10+
11+
_ "modernc.org/sqlite"
12+
13+
localsync "github.com/Topline-com/os-cli/internal/sync"
14+
)
15+
16+
func TestLocalDealBrief(t *testing.T) {
17+
dbPath := filepath.Join(t.TempDir(), "state.db")
18+
if err := localsync.InitDB(dbPath); err != nil {
19+
t.Fatalf("init: %v", err)
20+
}
21+
db, err := sql.Open("sqlite", dbPath)
22+
if err != nil {
23+
t.Fatalf("open: %v", err)
24+
}
25+
defer db.Close()
26+
ctx := context.Background()
27+
28+
exec := func(q string, args ...any) {
29+
t.Helper()
30+
if _, err := db.ExecContext(ctx, q, args...); err != nil {
31+
t.Fatalf("exec %q: %v", q, err)
32+
}
33+
}
34+
exec(`INSERT INTO pipelines(id,name,raw_json) VALUES('P1','Sales','{}')`)
35+
exec(`INSERT INTO pipeline_stages(id,pipeline_id,name,raw_json) VALUES('STG1','P1','Proposal Sent','{}')`)
36+
exec(`INSERT INTO contacts(id,name,email,phone,raw_json,updated_at) VALUES('C1','Jane Doe','jane@x.com','+1','{}','2026-05-12T00:00:00Z')`)
37+
exec(`INSERT INTO opportunities(id,contact_id,pipeline_id,pipeline_stage_id,name,status,monetary_value,raw_json,updated_at)
38+
VALUES('OPP1','C1','P1','STG1','Acme renewal','open',5000.0,'{}','2026-05-13T00:00:00Z')`)
39+
exec(`INSERT INTO conversations(id,contact_id,raw_json,updated_at) VALUES('CONV1','C1','{}','2026-05-13T00:00:00Z')`)
40+
exec(`INSERT INTO messages(id,conversation_id,contact_id,type,direction,created_at,raw_json)
41+
VALUES('M1','CONV1','C1','TYPE_SMS','inbound','2026-05-13T00:00:00Z','{"body":"hi"}')`)
42+
exec(`INSERT INTO messages(id,conversation_id,contact_id,type,direction,created_at,raw_json)
43+
VALUES('M2','CONV1','C1','TYPE_SMS','outbound','2026-05-13T00:05:00Z','{"body":"reply"}')`)
44+
exec(`INSERT INTO tasks(id,contact_id,title,due_at,completed,raw_json)
45+
VALUES('T1','C1','Follow up','2026-05-15T00:00:00Z',0,'{}')`)
46+
exec(`INSERT INTO notes(id,contact_id,body,created_at,raw_json)
47+
VALUES('N1','C1','warm lead','2026-05-10T00:00:00Z','{}')`)
48+
49+
var buf bytes.Buffer
50+
if err := runLocalDealBrief([]string{"--db", dbPath, "--opportunity-id", "OPP1"}, &buf, globalOptions{}); err != nil {
51+
t.Fatalf("deal brief: %v", err)
52+
}
53+
var out map[string]any
54+
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
55+
t.Fatalf("decode: %v\n%s", err, buf.String())
56+
}
57+
if out["view"] != "deal_brief" || out["found"] != true {
58+
t.Fatalf("bad header: %v", out)
59+
}
60+
opp, _ := out["opportunity"].(map[string]any)
61+
if opp["name"] != "Acme renewal" || opp["stage"] != "Proposal Sent" || opp["pipeline"] != "Sales" {
62+
t.Fatalf("bad opportunity: %v", opp)
63+
}
64+
contact, _ := out["contact"].(map[string]any)
65+
if contact["name"] != "Jane Doe" || contact["email"] != "jane@x.com" {
66+
t.Fatalf("bad contact: %v", contact)
67+
}
68+
messages, _ := out["messages"].([]any)
69+
if len(messages) != 2 {
70+
t.Fatalf("expected 2 messages, got %d", len(messages))
71+
}
72+
first, _ := messages[0].(map[string]any)
73+
if first["id"] != "M2" {
74+
t.Fatalf("expected M2 first (DESC by created_at), got %v", first["id"])
75+
}
76+
tasks, _ := out["tasks"].([]any)
77+
if len(tasks) != 1 {
78+
t.Fatalf("expected 1 task, got %d", len(tasks))
79+
}
80+
notes, _ := out["notes"].([]any)
81+
if len(notes) != 1 {
82+
t.Fatalf("expected 1 note, got %d", len(notes))
83+
}
84+
counts, _ := out["counts"].(map[string]any)
85+
if counts["messages"].(float64) != 2 {
86+
t.Fatalf("bad counts: %v", counts)
87+
}
88+
}
89+
90+
func TestLocalDealBrief_NotFound(t *testing.T) {
91+
dbPath := filepath.Join(t.TempDir(), "state.db")
92+
if err := localsync.InitDB(dbPath); err != nil {
93+
t.Fatalf("init: %v", err)
94+
}
95+
var buf bytes.Buffer
96+
if err := runLocalDealBrief([]string{"--db", dbPath, "--opportunity-id", "DOES_NOT_EXIST"}, &buf, globalOptions{}); err != nil {
97+
t.Fatalf("brief: %v", err)
98+
}
99+
var out map[string]any
100+
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
101+
t.Fatalf("decode: %v\n%s", err, buf.String())
102+
}
103+
if out["found"] != false {
104+
t.Fatalf("expected found=false, got %v", out)
105+
}
106+
}

internal/commands/execute.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ func Execute(args []string, stdout, stderr io.Writer) error {
4747
if len(rest) >= 1 && rest[0] == "query" {
4848
return runQueryCommand(rest[1:], stdout, globals)
4949
}
50+
if len(rest) >= 1 && rest[0] == "local" {
51+
return runLocalCommand(rest[1:], stdout, globals)
52+
}
5053
if len(rest) >= 2 && rest[0] == "sync" && rest[1] == "init" {
5154
flags, err := parseFlags(rest[2:])
5255
if err != nil {
@@ -238,6 +241,8 @@ func printHelp(w io.Writer) {
238241
_, _ = fmt.Fprintln(w, "\nAgent-native commands:")
239242
_, _ = fmt.Fprintln(w, " pipeline audit --pipeline-id PIPE --since YYYY-MM-DD|this-week-et [--concurrency 8] [--skip-activity]")
240243
_, _ = fmt.Fprintln(w, " query schema | catalog | explain --tables a,b | sql --sql SELECT...")
244+
_, _ = fmt.Fprintln(w, " local sync # one-token native sync into ~/.topline/state.db")
245+
_, _ = fmt.Fprintln(w, " local status | sql --sql ... | pipeline snapshot | pipeline stale --days 14")
241246
_, _ = fmt.Fprintln(w, " sync init --db topline.db")
242247
_, _ = fmt.Fprintln(w, "\nParity commands:")
243248
for _, cmd := range commands {

0 commit comments

Comments
 (0)