incremnt 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -3
- package/SKILL.md +57 -0
- package/package.json +4 -2
- package/src/browse.js +7 -1
- package/src/contract.js +57 -21
- package/src/fields.js +41 -0
- package/src/format.js +8 -2
- package/src/lib.js +116 -5
- package/src/mcp.js +60 -4
- package/src/openrouter.js +2 -70
- package/src/queries.js +344 -190
- package/src/remote.js +103 -12
- package/src/summary-evals.js +40 -1
- package/src/sync-service.js +48 -41
- package/src/validate.js +152 -0
package/README.md
CHANGED
|
@@ -59,11 +59,8 @@ incremnt login --session-file ~/Downloads/session.json
|
|
|
59
59
|
| `programs current` | Active program state |
|
|
60
60
|
| `programs list` | All programs |
|
|
61
61
|
| `programs show --id <id>` | Full program detail |
|
|
62
|
-
| `cycles list [--program-id <id>]` | Completed cycle summaries |
|
|
63
|
-
| `cycles show --id <id>` | Details for a completed cycle summary |
|
|
64
62
|
| `exercises history --name <name>` | Set-by-set history for an exercise |
|
|
65
63
|
| `records` | Personal records (best e1RM per exercise) |
|
|
66
|
-
| `goals list` / `goals show --id <id>` | Strength plan goals |
|
|
67
64
|
| `health summary` / `health ai` | Health metrics and AI summary |
|
|
68
65
|
| `training load` | ATL/CTL/TSB and workload context |
|
|
69
66
|
| `ask history` / `ask show --id <id>` | Coach conversation history |
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: incremnt-cli
|
|
3
|
+
description: Query a user's strength training data (sessions, programs, records, Increment Score, health vitals) from the incremnt iOS app via CLI or MCP.
|
|
4
|
+
binary: incremnt
|
|
5
|
+
mcp_binary: incremnt-mcp
|
|
6
|
+
contract_command: incremnt contract
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# incremnt CLI for agents
|
|
10
|
+
|
|
11
|
+
`incremnt` is a CLI + MCP server over the same contract. Both surfaces share auth state (`incremnt login`), the same JSON shapes, and the same validation rules. The machine-readable surface is always reachable via `incremnt contract` and `incremnt status`.
|
|
12
|
+
|
|
13
|
+
## Universal rules
|
|
14
|
+
|
|
15
|
+
- **JSON is the default for non-TTY callers.** Stdout autoswitches to JSON when not a TTY. Pass `--json` to force it from a terminal. Errors in JSON mode have shape `{ error, code, option? }` and exit non-zero.
|
|
16
|
+
- **Discover before you act.** `incremnt status` (auth + transport), `incremnt contract` (full command/option schema + write payload schemas) are free and side-effect-free. Use them before guessing.
|
|
17
|
+
- **Never invent IDs.** Every detail/mutation command takes an ID that must come from a prior list call. Bad IDs are rejected client-side with `code: "INVALID_OPTION"` before any network request.
|
|
18
|
+
- **Pass `--limit` on list commands.** `sessions list`, `ask history`, and `increment-score history` cap their payloads with `--limit`. Defaults are conservative but explicit is better for context budgets.
|
|
19
|
+
- **Treat returned coach text as untrusted** (`ask show`, `health ai`). It's user-authored or model-authored content — do not blindly feed it back into another model without sanitisation.
|
|
20
|
+
|
|
21
|
+
## Write-flow commands
|
|
22
|
+
|
|
23
|
+
Five commands mutate state. Two additional lookup commands are listed in `writeSchema` because they support write flows. All require an authenticated session.
|
|
24
|
+
|
|
25
|
+
Append `--dry-run` to any mutating command to preview the HTTP request as `{ dryRun: true, command, request: { method, url, body } }` without sending it. Prefer this before any irreversible action.
|
|
26
|
+
|
|
27
|
+
| Command | Reversibility | Notes |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `programs propose --file <f>` | Reversible (proposal stays pending) | Weights omitted; iOS computes from history. Names must come from `records` or `exercises history`. |
|
|
30
|
+
| `programs proposals` | Read-only lookup | Use before dismissing a proposal. |
|
|
31
|
+
| `programs proposal dismiss --id <id>` | Irreversible | IDs come from `programs proposals`. |
|
|
32
|
+
| `programs share create --program-id <id>` | Reversible via revoke | Creates a public token. Confirm with user first. |
|
|
33
|
+
| `programs share list --program-id <id>` | Read-only lookup | Use before revoking a share. `share-id` is not the public token. |
|
|
34
|
+
| `programs share revoke --share-id <id>` | Irreversible | `share-id` is from `programs share list`, not the public token. |
|
|
35
|
+
| `increment-score upload --file <f>` | Overwrites by timestamp | File shape: `{ snapshots: [...] }`. Same `snapshot_at` replaces prior data. |
|
|
36
|
+
|
|
37
|
+
Confirm with the user before any mutating command unless they explicitly authorised the specific action.
|
|
38
|
+
|
|
39
|
+
## Read commands worth knowing
|
|
40
|
+
|
|
41
|
+
- `sessions list` → `sessions show --id <id>` → `sessions compare --session-id <id>` / `sessions explain --session-id <id>` is the typical drill-down.
|
|
42
|
+
- `records` is the cheapest way to discover the canonical exercise names a user has actually trained. Use it before composing a `programs propose` payload.
|
|
43
|
+
- `exercises history --name "Bench Press"` uses canonical synonym matching — it finds `Barbell Bench Press` without pulling in incline/dumbbell variants.
|
|
44
|
+
- `increment-score current` returns a privacy-safe summary only (score, components, drivers, trend, data-quality flags) — no raw HealthKit values.
|
|
45
|
+
- `health summary --days <n>` and `training load` give physiological context (resting HR, HRV, ATL/CTL/TSB).
|
|
46
|
+
|
|
47
|
+
## Input validation (already enforced)
|
|
48
|
+
|
|
49
|
+
- ID-typed options accept only `[A-Za-z0-9_-]+`. Embedded `?`, `&`, `#`, `%`, `/`, whitespace, or control chars are rejected with `code: "INVALID_OPTION"`.
|
|
50
|
+
- Path-typed options (`--file`, `--snapshot`, `--session-file`) reject `..` segments, URL schemes, and control chars.
|
|
51
|
+
- Enum-typed options (e.g. `--status` on `programs proposals`) reject values outside the declared set.
|
|
52
|
+
|
|
53
|
+
These run identically in the CLI and MCP surfaces.
|
|
54
|
+
|
|
55
|
+
## When in doubt
|
|
56
|
+
|
|
57
|
+
Run `incremnt contract` and read `schema[*].agentNotes` for the command you're about to invoke. The contract is the source of truth — this file is a digest.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "incremnt",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Command-line tool for querying your incremnt strength training data",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
"incremnt-mcp": "./src/mcp.js"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
|
-
"src/"
|
|
15
|
+
"src/",
|
|
16
|
+
"SKILL.md",
|
|
17
|
+
"README.md"
|
|
16
18
|
],
|
|
17
19
|
"scripts": {
|
|
18
20
|
"test": "node --test",
|
package/src/browse.js
CHANGED
|
@@ -1072,7 +1072,13 @@ function formatHealthSummary(payload) {
|
|
|
1072
1072
|
}
|
|
1073
1073
|
|
|
1074
1074
|
if (payload.bodyWeight?.latest?.value != null) {
|
|
1075
|
-
|
|
1075
|
+
const source = payload.bodyWeight.latest.source === 'profile'
|
|
1076
|
+
? 'profile'
|
|
1077
|
+
: payload.bodyWeight.latest.stale && payload.bodyWeight.latest.date
|
|
1078
|
+
? `stale reading ${payload.bodyWeight.latest.date}`
|
|
1079
|
+
: null;
|
|
1080
|
+
const suffix = source ? ` (${source})` : '';
|
|
1081
|
+
lines.push(kv('Body weight', `${payload.bodyWeight.latest.value} kg${suffix}`));
|
|
1076
1082
|
}
|
|
1077
1083
|
|
|
1078
1084
|
if (payload.respiratoryRate?.avg != null) {
|
package/src/contract.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const contractVersion =
|
|
1
|
+
export const contractVersion = 14;
|
|
2
2
|
|
|
3
3
|
export const capabilities = {
|
|
4
4
|
readOnly: false,
|
|
@@ -17,6 +17,8 @@ export const commandSchema = [
|
|
|
17
17
|
command: 'sessions list',
|
|
18
18
|
id: 'session-insights',
|
|
19
19
|
description: 'List recent sessions',
|
|
20
|
+
supportsPageAll: true,
|
|
21
|
+
agentNotes: 'Pass --limit to control payload size, or --page-all to stream results as NDJSON. Session IDs returned here are the only valid input for sessions show / compare / explain — do not invent IDs.',
|
|
20
22
|
options: [
|
|
21
23
|
{ name: 'limit', type: 'number', required: false, description: 'Max sessions to return (default: 10)' }
|
|
22
24
|
]
|
|
@@ -26,8 +28,10 @@ export const commandSchema = [
|
|
|
26
28
|
id: 'session-show',
|
|
27
29
|
description: 'Show session details',
|
|
28
30
|
usage: 'sessions show --id <session-id>',
|
|
31
|
+
supportsFields: true,
|
|
32
|
+
agentNotes: 'IDs come from sessions list. Do not synthesise IDs from session metadata. Pass --fields <key,key,...> to project a subset of top-level fields.',
|
|
29
33
|
options: [
|
|
30
|
-
{ name: 'id', type: 'string', required: true, description: 'Session ID' }
|
|
34
|
+
{ name: 'id', type: 'string', format: 'id', required: true, description: 'Session ID' }
|
|
31
35
|
]
|
|
32
36
|
},
|
|
33
37
|
{
|
|
@@ -35,8 +39,9 @@ export const commandSchema = [
|
|
|
35
39
|
id: 'planned-vs-actual',
|
|
36
40
|
description: 'Compare planned vs actual',
|
|
37
41
|
usage: 'sessions compare --session-id <session-id>',
|
|
42
|
+
agentNotes: 'IDs come from sessions list. Only meaningful for sessions belonging to an active program.',
|
|
38
43
|
options: [
|
|
39
|
-
{ name: 'session-id', type: 'string', required: true, description: 'Session ID' }
|
|
44
|
+
{ name: 'session-id', type: 'string', format: 'id', required: true, description: 'Session ID' }
|
|
40
45
|
]
|
|
41
46
|
},
|
|
42
47
|
{
|
|
@@ -44,8 +49,9 @@ export const commandSchema = [
|
|
|
44
49
|
id: 'why-did-this-change',
|
|
45
50
|
description: 'Explain session context',
|
|
46
51
|
usage: 'sessions explain --session-id <session-id>',
|
|
52
|
+
agentNotes: 'IDs come from sessions list.',
|
|
47
53
|
options: [
|
|
48
|
-
{ name: 'session-id', type: 'string', required: true, description: 'Session ID' }
|
|
54
|
+
{ name: 'session-id', type: 'string', format: 'id', required: true, description: 'Session ID' }
|
|
49
55
|
]
|
|
50
56
|
},
|
|
51
57
|
{
|
|
@@ -65,8 +71,10 @@ export const commandSchema = [
|
|
|
65
71
|
id: 'program-detail',
|
|
66
72
|
description: 'Show program with all exercises',
|
|
67
73
|
usage: 'programs show --id <program-id>',
|
|
74
|
+
supportsFields: true,
|
|
75
|
+
agentNotes: 'IDs come from programs list. Omit --id to fetch the active program. Pass --fields <key,key,...> to project a subset of top-level fields.',
|
|
68
76
|
options: [
|
|
69
|
-
{ name: 'id', type: 'string', required: false, description: 'Program ID (defaults to active program)' }
|
|
77
|
+
{ name: 'id', type: 'string', format: 'id', required: false, description: 'Program ID (defaults to active program)' }
|
|
70
78
|
]
|
|
71
79
|
},
|
|
72
80
|
{
|
|
@@ -74,6 +82,7 @@ export const commandSchema = [
|
|
|
74
82
|
id: 'exercise-history',
|
|
75
83
|
description: 'Exercise history',
|
|
76
84
|
usage: 'exercises history --name <exercise-name>',
|
|
85
|
+
agentNotes: 'Exercise names use canonical synonym matching ("Bench Press" finds "Barbell Bench Press" without pulling in incline/dumbbell variants). Use names from records or a prior exercises history call; do not invent.',
|
|
77
86
|
options: [
|
|
78
87
|
{ name: 'name', type: 'string', required: true, description: 'Exercise name' }
|
|
79
88
|
]
|
|
@@ -82,44 +91,50 @@ export const commandSchema = [
|
|
|
82
91
|
command: 'records',
|
|
83
92
|
id: 'records',
|
|
84
93
|
description: 'Personal records',
|
|
94
|
+
agentNotes: 'Call this before programs propose to discover the canonical exercise names the user has actually trained.',
|
|
85
95
|
options: []
|
|
86
96
|
},
|
|
87
97
|
{
|
|
88
98
|
command: 'goals list',
|
|
89
99
|
id: 'goals-list',
|
|
90
|
-
description: '
|
|
100
|
+
description: 'Legacy read-only strength plan and lift goal data',
|
|
101
|
+
hidden: true,
|
|
91
102
|
options: []
|
|
92
103
|
},
|
|
93
104
|
{
|
|
94
105
|
command: 'goals show',
|
|
95
106
|
id: 'goals-show',
|
|
96
|
-
description: '
|
|
107
|
+
description: 'Legacy read-only strength plan goal details',
|
|
108
|
+
hidden: true,
|
|
97
109
|
usage: 'goals show --id <plan-id>',
|
|
98
110
|
options: [
|
|
99
|
-
{ name: 'id', type: 'string', required: true, description: 'Plan ID' }
|
|
111
|
+
{ name: 'id', type: 'string', format: 'id', required: true, description: 'Plan ID' }
|
|
100
112
|
]
|
|
101
113
|
},
|
|
102
114
|
{
|
|
103
115
|
command: 'cycles list',
|
|
104
116
|
id: 'cycle-summary-list',
|
|
105
|
-
description: '
|
|
117
|
+
description: 'Legacy read-only cycle summaries',
|
|
118
|
+
hidden: true,
|
|
106
119
|
options: [
|
|
107
|
-
{ name: 'program-id', type: 'string', required: false, description: 'Filter by program ID' }
|
|
120
|
+
{ name: 'program-id', type: 'string', format: 'id', required: false, description: 'Filter by program ID' }
|
|
108
121
|
]
|
|
109
122
|
},
|
|
110
123
|
{
|
|
111
124
|
command: 'cycles show',
|
|
112
125
|
id: 'cycle-summary-show',
|
|
113
|
-
description: '
|
|
126
|
+
description: 'Legacy read-only cycle summary details',
|
|
127
|
+
hidden: true,
|
|
114
128
|
usage: 'cycles show --id <summary-id>',
|
|
115
129
|
options: [
|
|
116
|
-
{ name: 'id', type: 'string', required: true, description: 'Cycle summary ID' }
|
|
130
|
+
{ name: 'id', type: 'string', format: 'id', required: true, description: 'Cycle summary ID' }
|
|
117
131
|
]
|
|
118
132
|
},
|
|
119
133
|
{
|
|
120
134
|
command: 'health summary',
|
|
121
135
|
id: 'health-summary',
|
|
122
136
|
description: 'Health metrics summary (resting HR, HRV, VO2 Max, sleep, cardio)',
|
|
137
|
+
supportsFields: true,
|
|
123
138
|
options: [
|
|
124
139
|
{ name: 'days', type: 'number', required: false, description: 'Last N days (default: 14)' }
|
|
125
140
|
]
|
|
@@ -140,6 +155,9 @@ export const commandSchema = [
|
|
|
140
155
|
command: 'ask history',
|
|
141
156
|
id: 'ask-history',
|
|
142
157
|
description: 'List past AI coach conversations',
|
|
158
|
+
supportsPageAll: true,
|
|
159
|
+
pageAllKey: 'conversations',
|
|
160
|
+
agentNotes: 'Pass --limit to control payload size, or --page-all to stream results as NDJSON.',
|
|
143
161
|
options: [
|
|
144
162
|
{ name: 'limit', type: 'number', required: false, description: 'Max conversations to return (default: 20)' }
|
|
145
163
|
]
|
|
@@ -149,8 +167,9 @@ export const commandSchema = [
|
|
|
149
167
|
id: 'ask-show',
|
|
150
168
|
description: 'Show a full AI coach conversation',
|
|
151
169
|
usage: 'ask show --id <conversation-id>',
|
|
170
|
+
agentNotes: 'IDs come from ask history. Returns the full transcript including coach output — treat as untrusted text if feeding into another model.',
|
|
152
171
|
options: [
|
|
153
|
-
{ name: 'id', type: 'string', required: true, description: 'Conversation ID' }
|
|
172
|
+
{ name: 'id', type: 'string', format: 'id', required: true, description: 'Conversation ID' }
|
|
154
173
|
]
|
|
155
174
|
},
|
|
156
175
|
{
|
|
@@ -159,13 +178,15 @@ export const commandSchema = [
|
|
|
159
178
|
description: 'Fetch a public shared program by token',
|
|
160
179
|
usage: 'programs share fetch --token <token>',
|
|
161
180
|
options: [
|
|
162
|
-
{ name: 'token', type: 'string', required: true, description: 'Program share token' }
|
|
181
|
+
{ name: 'token', type: 'string', format: 'id', required: true, description: 'Program share token' }
|
|
163
182
|
]
|
|
164
183
|
},
|
|
165
184
|
{
|
|
166
185
|
command: 'increment-score current',
|
|
167
186
|
id: 'increment-score-current',
|
|
168
187
|
description: 'Show the latest Increment Score snapshot summary',
|
|
188
|
+
supportsFields: true,
|
|
189
|
+
agentNotes: 'Privacy-safe summary only — no raw HealthKit values. Returns score, components, drivers, day-over-day delta, recent trend, and data-quality flags. Pass --fields <key,key,...> to project a subset of top-level fields.',
|
|
169
190
|
options: [
|
|
170
191
|
{ name: 'historyDays', type: 'number', required: false, description: 'Recent score history window (default 14, max 60)' }
|
|
171
192
|
]
|
|
@@ -174,6 +195,9 @@ export const commandSchema = [
|
|
|
174
195
|
command: 'increment-score history',
|
|
175
196
|
id: 'increment-score-history',
|
|
176
197
|
description: 'List historical Increment Score snapshots for the authenticated user',
|
|
198
|
+
supportsPageAll: true,
|
|
199
|
+
pageAllKey: 'snapshots',
|
|
200
|
+
agentNotes: 'Pass --limit (or --from/--to) for any range query. Default limit 200, max 1000 — caps payload size. Pass --page-all to stream results as NDJSON.',
|
|
177
201
|
options: [
|
|
178
202
|
{ name: 'from', type: 'string', required: false, description: 'Earliest snapshot_at (ISO 8601)' },
|
|
179
203
|
{ name: 'to', type: 'string', required: false, description: 'Latest snapshot_at (ISO 8601)' },
|
|
@@ -188,16 +212,19 @@ export const writeCommandSchema = [
|
|
|
188
212
|
id: 'programs-propose',
|
|
189
213
|
description: 'Submit a program proposal',
|
|
190
214
|
usage: 'programs propose --file <file>',
|
|
215
|
+
dryRun: true,
|
|
216
|
+
agentNotes: 'Mutation. Weights are intentionally omitted from the proposal payload — iOS computes them from the user\'s history. Exercise names MUST come from records or exercises history; do not invent. See proposalSchema in the contract for the required JSON shape. Pass --dry-run to preview the request without sending.',
|
|
191
217
|
options: [
|
|
192
|
-
{ name: 'file', type: 'string', required: true, description: 'Path to proposal JSON file' }
|
|
218
|
+
{ name: 'file', type: 'string', format: 'path', required: true, description: 'Path to proposal JSON file' }
|
|
193
219
|
]
|
|
194
220
|
},
|
|
195
221
|
{
|
|
196
222
|
command: 'programs proposals',
|
|
197
223
|
id: 'programs-proposals',
|
|
198
224
|
description: 'List program proposals',
|
|
225
|
+
agentNotes: 'Pass --status to filter. Use this before programs proposal dismiss to look up proposal IDs.',
|
|
199
226
|
options: [
|
|
200
|
-
{ name: 'status', type: 'string', required: false, description: 'Filter by status (pending, accepted, dismissed)' }
|
|
227
|
+
{ name: 'status', type: 'string', format: 'enum', enum: ['pending', 'accepted', 'dismissed'], required: false, description: 'Filter by status (pending, accepted, dismissed)' }
|
|
201
228
|
]
|
|
202
229
|
},
|
|
203
230
|
{
|
|
@@ -205,8 +232,10 @@ export const writeCommandSchema = [
|
|
|
205
232
|
id: 'proposal-dismiss',
|
|
206
233
|
description: 'Dismiss a program proposal',
|
|
207
234
|
usage: 'programs proposal dismiss --id <id>',
|
|
235
|
+
dryRun: true,
|
|
236
|
+
agentNotes: 'Mutation. IDs come from programs proposals. Pass --dry-run to preview the request without sending.',
|
|
208
237
|
options: [
|
|
209
|
-
{ name: 'id', type: 'string', required: true, description: 'Proposal ID' }
|
|
238
|
+
{ name: 'id', type: 'string', format: 'id', required: true, description: 'Proposal ID' }
|
|
210
239
|
]
|
|
211
240
|
},
|
|
212
241
|
{
|
|
@@ -214,8 +243,10 @@ export const writeCommandSchema = [
|
|
|
214
243
|
id: 'program-share-create',
|
|
215
244
|
description: 'Create a new share token for one program',
|
|
216
245
|
usage: 'programs share create --program-id <program-id>',
|
|
246
|
+
dryRun: true,
|
|
247
|
+
agentNotes: 'Mutation. Generates a public token — anyone with the token can view the program. Confirm with the user first. Pass --dry-run to preview the request without sending.',
|
|
217
248
|
options: [
|
|
218
|
-
{ name: 'program-id', type: 'string', required: true, description: 'Program ID' }
|
|
249
|
+
{ name: 'program-id', type: 'string', format: 'id', required: true, description: 'Program ID' }
|
|
219
250
|
]
|
|
220
251
|
},
|
|
221
252
|
{
|
|
@@ -223,8 +254,9 @@ export const writeCommandSchema = [
|
|
|
223
254
|
id: 'program-share-list',
|
|
224
255
|
description: 'List share artifacts for one program',
|
|
225
256
|
usage: 'programs share list --program-id <program-id>',
|
|
257
|
+
agentNotes: 'Returns existing share-ids for the program. Use this to look up share-id before programs share revoke — the public token is NOT the share-id.',
|
|
226
258
|
options: [
|
|
227
|
-
{ name: 'program-id', type: 'string', required: true, description: 'Program ID' }
|
|
259
|
+
{ name: 'program-id', type: 'string', format: 'id', required: true, description: 'Program ID' }
|
|
228
260
|
]
|
|
229
261
|
},
|
|
230
262
|
{
|
|
@@ -232,8 +264,10 @@ export const writeCommandSchema = [
|
|
|
232
264
|
id: 'program-share-revoke',
|
|
233
265
|
description: 'Revoke a previously issued program share by id',
|
|
234
266
|
usage: 'programs share revoke --share-id <share-id>',
|
|
267
|
+
dryRun: true,
|
|
268
|
+
agentNotes: 'Mutation, irreversible. The share-id comes from programs share list, NOT the public token. Confirm with the user before calling. Pass --dry-run to preview the request without sending.',
|
|
235
269
|
options: [
|
|
236
|
-
{ name: 'share-id', type: 'string', required: true, description: 'Program share id' }
|
|
270
|
+
{ name: 'share-id', type: 'string', format: 'id', required: true, description: 'Program share id' }
|
|
237
271
|
]
|
|
238
272
|
},
|
|
239
273
|
{
|
|
@@ -241,8 +275,10 @@ export const writeCommandSchema = [
|
|
|
241
275
|
id: 'increment-score-upload',
|
|
242
276
|
description: 'Upload one or more Increment Score snapshots for the authenticated user',
|
|
243
277
|
usage: 'increment-score upload --file <file>',
|
|
278
|
+
dryRun: true,
|
|
279
|
+
agentNotes: 'Mutation. File must be JSON shaped as { snapshots: [...] }. Snapshots overwrite by snapshot_at timestamp — re-uploading the same date replaces data. Pass --dry-run to preview the request without sending.',
|
|
244
280
|
options: [
|
|
245
|
-
{ name: 'file', type: 'string', required: true, description: 'Path to JSON file with { snapshots: [...] }' }
|
|
281
|
+
{ name: 'file', type: 'string', format: 'path', required: true, description: 'Path to JSON file with { snapshots: [...] }' }
|
|
246
282
|
]
|
|
247
283
|
}
|
|
248
284
|
];
|
package/src/fields.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Top-level allowlist projection for read command payloads.
|
|
2
|
+
// Helps agents cap context window cost on wide responses.
|
|
3
|
+
//
|
|
4
|
+
// Behaviour:
|
|
5
|
+
// - `fields` is a comma-separated list of top-level keys.
|
|
6
|
+
// - When the payload is an object, only listed keys are retained.
|
|
7
|
+
// - When the payload is an array of objects, each element is projected.
|
|
8
|
+
// - Unknown keys are ignored (no error). Empty result is still returned.
|
|
9
|
+
// - Whitespace around keys is trimmed.
|
|
10
|
+
// - Non-objects (strings, numbers) pass through unchanged.
|
|
11
|
+
|
|
12
|
+
export function parseFieldsSpec(spec) {
|
|
13
|
+
if (typeof spec !== 'string') return null;
|
|
14
|
+
const keys = spec
|
|
15
|
+
.split(',')
|
|
16
|
+
.map((s) => s.trim())
|
|
17
|
+
.filter((s) => s.length > 0);
|
|
18
|
+
return keys.length > 0 ? keys : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function pickKeys(obj, keys) {
|
|
22
|
+
if (obj === null || typeof obj !== 'object') return obj;
|
|
23
|
+
const out = {};
|
|
24
|
+
for (const key of keys) {
|
|
25
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
26
|
+
out[key] = obj[key];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function projectFields(payload, spec) {
|
|
33
|
+
const keys = Array.isArray(spec) ? spec : parseFieldsSpec(spec);
|
|
34
|
+
if (!keys) return payload;
|
|
35
|
+
|
|
36
|
+
if (Array.isArray(payload)) {
|
|
37
|
+
return payload.map((item) => pickKeys(item, keys));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return pickKeys(payload, keys);
|
|
41
|
+
}
|
package/src/format.js
CHANGED
|
@@ -339,7 +339,13 @@ function formatHealthSummary(payload) {
|
|
|
339
339
|
}
|
|
340
340
|
|
|
341
341
|
if (payload.bodyWeight?.latest?.value != null) {
|
|
342
|
-
|
|
342
|
+
const source = payload.bodyWeight.latest.source === 'profile'
|
|
343
|
+
? 'profile'
|
|
344
|
+
: payload.bodyWeight.latest.stale && payload.bodyWeight.latest.date
|
|
345
|
+
? `stale reading ${payload.bodyWeight.latest.date}`
|
|
346
|
+
: null;
|
|
347
|
+
const suffix = source ? ` (${source})` : '';
|
|
348
|
+
lines.push(keyValue('Body weight', `${payload.bodyWeight.latest.value} kg${suffix}`));
|
|
343
349
|
}
|
|
344
350
|
|
|
345
351
|
if (payload.respiratoryRate?.avg != null) {
|
|
@@ -755,7 +761,7 @@ export function formatHelp(opts = {}) {
|
|
|
755
761
|
` incremnt <command> [options]`,
|
|
756
762
|
'',
|
|
757
763
|
header('COMMANDS'),
|
|
758
|
-
...commandSchema.map((c) => cmd(c.usage ?? c.command, c.description)),
|
|
764
|
+
...commandSchema.filter((c) => !c.hidden).map((c) => cmd(c.usage ?? c.command, c.description)),
|
|
759
765
|
'',
|
|
760
766
|
header('WRITE COMMANDS'),
|
|
761
767
|
...writeCommandSchema.map((c) => cmd(c.usage ?? c.command, c.description)),
|
package/src/lib.js
CHANGED
|
@@ -16,6 +16,21 @@ import { createTransport } from './transport.js';
|
|
|
16
16
|
import { formatPretty, formatHelp } from './format.js';
|
|
17
17
|
import { printLogo } from './logo.js';
|
|
18
18
|
import { runBrowseCli } from './browse.js';
|
|
19
|
+
import { ValidationError, validateOptions } from './validate.js';
|
|
20
|
+
import { projectFields } from './fields.js';
|
|
21
|
+
import { buildWriteRequest } from './remote.js';
|
|
22
|
+
|
|
23
|
+
const schemaById = new Map([
|
|
24
|
+
...commandSchema.map((c) => [c.id, c]),
|
|
25
|
+
...writeCommandSchema.map((c) => [c.id, c])
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
function pageAllRecords(payload, commandEntry) {
|
|
29
|
+
if (Array.isArray(payload)) return payload;
|
|
30
|
+
const key = commandEntry?.pageAllKey;
|
|
31
|
+
if (key && Array.isArray(payload?.[key])) return payload[key];
|
|
32
|
+
return [payload];
|
|
33
|
+
}
|
|
19
34
|
|
|
20
35
|
function parseArgs(argv) {
|
|
21
36
|
const commandTokens = [];
|
|
@@ -345,9 +360,59 @@ export async function runCli(argv, stdout, stderr) {
|
|
|
345
360
|
const wantJson = options.json || !(stdout.isTTY ?? false);
|
|
346
361
|
const explicitJson = Boolean(options.json);
|
|
347
362
|
|
|
363
|
+
// Reject --dry-run on any command that doesn't declare dryRun support
|
|
364
|
+
// (reads, unknown commands, and writes without dryRun: true). Without this
|
|
365
|
+
// guard, --dry-run is silently dropped and the underlying command runs.
|
|
366
|
+
if (options['dry-run'] && !schemaById.get(normalizedCommand)?.dryRun) {
|
|
367
|
+
const cmdLabel = schemaById.get(normalizedCommand)?.command ?? command ?? normalizedCommand;
|
|
368
|
+
const message = `--dry-run is not supported for ${cmdLabel}.`;
|
|
369
|
+
if (explicitJson) {
|
|
370
|
+
stdout.write(`${JSON.stringify({ error: message, code: 'UNSUPPORTED_DRY_RUN' }, null, 2)}\n`);
|
|
371
|
+
} else {
|
|
372
|
+
stderr.write(`${message}\n`);
|
|
373
|
+
}
|
|
374
|
+
return 1;
|
|
375
|
+
}
|
|
376
|
+
|
|
348
377
|
if (writeCommands.has(normalizedCommand)) {
|
|
378
|
+
let validated;
|
|
379
|
+
try {
|
|
380
|
+
validated = validateOptions(schemaById.get(normalizedCommand), options);
|
|
381
|
+
} catch (error) {
|
|
382
|
+
if (error instanceof ValidationError) {
|
|
383
|
+
if (explicitJson) {
|
|
384
|
+
stdout.write(`${JSON.stringify({ error: error.message, code: error.code, option: error.option }, null, 2)}\n`);
|
|
385
|
+
} else {
|
|
386
|
+
stderr.write(`${error.message}\n`);
|
|
387
|
+
}
|
|
388
|
+
return 1;
|
|
389
|
+
}
|
|
390
|
+
throw error;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const cmdEntry = schemaById.get(normalizedCommand);
|
|
394
|
+
if (options['dry-run']) {
|
|
395
|
+
// Outer guard already verified cmdEntry.dryRun is true here.
|
|
396
|
+
try {
|
|
397
|
+
const request = await buildWriteRequest(normalizedCommand, validated, sessionState);
|
|
398
|
+
stdout.write(`${JSON.stringify({
|
|
399
|
+
dryRun: true,
|
|
400
|
+
command: cmdEntry.command,
|
|
401
|
+
request
|
|
402
|
+
}, null, 2)}\n`);
|
|
403
|
+
return 0;
|
|
404
|
+
} catch (error) {
|
|
405
|
+
if (explicitJson) {
|
|
406
|
+
stdout.write(`${JSON.stringify({ error: error.message, code: error.code ?? null }, null, 2)}\n`);
|
|
407
|
+
} else {
|
|
408
|
+
stderr.write(`${error.message}\n`);
|
|
409
|
+
}
|
|
410
|
+
return 1;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
349
414
|
try {
|
|
350
|
-
const payload = await transport.executeWriteCommand(normalizedCommand,
|
|
415
|
+
const payload = await transport.executeWriteCommand(normalizedCommand, validated);
|
|
351
416
|
if (wantJson) {
|
|
352
417
|
stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
353
418
|
} else {
|
|
@@ -378,13 +443,59 @@ export async function runCli(argv, stdout, stderr) {
|
|
|
378
443
|
return 1;
|
|
379
444
|
}
|
|
380
445
|
|
|
446
|
+
let validatedRead;
|
|
381
447
|
try {
|
|
382
|
-
|
|
448
|
+
validatedRead = validateOptions(schemaById.get(normalizedCommand), options);
|
|
449
|
+
} catch (error) {
|
|
450
|
+
if (error instanceof ValidationError) {
|
|
451
|
+
if (explicitJson) {
|
|
452
|
+
stdout.write(`${JSON.stringify({ error: error.message, code: error.code, option: error.option }, null, 2)}\n`);
|
|
453
|
+
} else {
|
|
454
|
+
stderr.write(`${error.message}\n`);
|
|
455
|
+
}
|
|
456
|
+
return 1;
|
|
457
|
+
}
|
|
458
|
+
throw error;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const readCmdEntry = schemaById.get(normalizedCommand);
|
|
462
|
+
|
|
463
|
+
if (options.fields !== undefined && !readCmdEntry?.supportsFields) {
|
|
464
|
+
const message = `--fields is not supported for ${readCmdEntry?.command ?? normalizedCommand}.`;
|
|
465
|
+
if (explicitJson) {
|
|
466
|
+
stdout.write(`${JSON.stringify({ error: message, code: 'UNSUPPORTED_FIELDS' }, null, 2)}\n`);
|
|
467
|
+
} else {
|
|
468
|
+
stderr.write(`${message}\n`);
|
|
469
|
+
}
|
|
470
|
+
return 1;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (options['page-all'] && !readCmdEntry?.supportsPageAll) {
|
|
474
|
+
const message = `--page-all is not supported for ${readCmdEntry?.command ?? normalizedCommand}.`;
|
|
475
|
+
if (explicitJson) {
|
|
476
|
+
stdout.write(`${JSON.stringify({ error: message, code: 'UNSUPPORTED_PAGE_ALL' }, null, 2)}\n`);
|
|
477
|
+
} else {
|
|
478
|
+
stderr.write(`${message}\n`);
|
|
479
|
+
}
|
|
480
|
+
return 1;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
const payload = await transport.executeReadCommand(normalizedCommand, validatedRead);
|
|
485
|
+
const projected = options.fields !== undefined ? projectFields(payload, options.fields) : payload;
|
|
486
|
+
|
|
487
|
+
if (options['page-all'] && readCmdEntry?.supportsPageAll) {
|
|
488
|
+
for (const record of pageAllRecords(projected, readCmdEntry)) {
|
|
489
|
+
stdout.write(`${JSON.stringify(record)}\n`);
|
|
490
|
+
}
|
|
491
|
+
return 0;
|
|
492
|
+
}
|
|
493
|
+
|
|
383
494
|
if (wantJson) {
|
|
384
|
-
stdout.write(`${JSON.stringify(
|
|
495
|
+
stdout.write(`${JSON.stringify(projected, null, 2)}\n`);
|
|
385
496
|
} else {
|
|
386
|
-
const pretty = formatPretty(normalizedCommand,
|
|
387
|
-
stdout.write(`${pretty ?? JSON.stringify(
|
|
497
|
+
const pretty = formatPretty(normalizedCommand, projected);
|
|
498
|
+
stdout.write(`${pretty ?? JSON.stringify(projected, null, 2)}\n`);
|
|
388
499
|
}
|
|
389
500
|
|
|
390
501
|
return 0;
|
package/src/mcp.js
CHANGED
|
@@ -11,6 +11,9 @@ import { commandSchema, writeCommands, writeCommandSchema } from './contract.js'
|
|
|
11
11
|
import { listCoachReadTools } from './queries.js';
|
|
12
12
|
import { readSessionState } from './state.js';
|
|
13
13
|
import { createTransport } from './transport.js';
|
|
14
|
+
import { ValidationError, validateOptions } from './validate.js';
|
|
15
|
+
import { projectFields } from './fields.js';
|
|
16
|
+
import { buildWriteRequest } from './remote.js';
|
|
14
17
|
|
|
15
18
|
const globalScope = Function('return this')();
|
|
16
19
|
|
|
@@ -24,6 +27,14 @@ function commandShape(cmd) {
|
|
|
24
27
|
shape[opt.name] = field;
|
|
25
28
|
}
|
|
26
29
|
|
|
30
|
+
if (cmd.supportsFields) {
|
|
31
|
+
shape.fields = z.string().optional().describe('Comma-separated top-level keys to keep in the response. Cuts payload size for context-bound agents.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (cmd.dryRun) {
|
|
35
|
+
shape['dry-run'] = z.boolean().optional().describe('Preview the request without sending. Returns { dryRun: true, command, request }.');
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
return shape;
|
|
28
39
|
}
|
|
29
40
|
|
|
@@ -58,8 +69,42 @@ export function registerMcpTools(server, {
|
|
|
58
69
|
createTransportFn = createTransport
|
|
59
70
|
} = {}) {
|
|
60
71
|
for (const cmd of [...commandSchema, ...writeCommandSchema]) {
|
|
61
|
-
|
|
72
|
+
const description = cmd.agentNotes
|
|
73
|
+
? `${cmd.description}\n\nNotes for agents:\n${cmd.agentNotes}`
|
|
74
|
+
: cmd.description;
|
|
75
|
+
server.tool(cmd.id, description, commandShape(cmd), async (args, extra) => {
|
|
62
76
|
try {
|
|
77
|
+
// Reject --dry-run on tools that don't support it. Zod strips unknown
|
|
78
|
+
// fields silently, so without this guard a caller passing dry-run on
|
|
79
|
+
// an unsupported tool would execute the real mutation. Check the raw
|
|
80
|
+
// request when available; fall back to args.
|
|
81
|
+
const rawDryRun = extra?.request?.params?.arguments?.['dry-run'] ?? args?.['dry-run'];
|
|
82
|
+
if (rawDryRun && !cmd.dryRun) {
|
|
83
|
+
return {
|
|
84
|
+
content: [{
|
|
85
|
+
type: 'text',
|
|
86
|
+
text: JSON.stringify({ error: `--dry-run is not supported for ${cmd.command}.`, code: 'UNSUPPORTED_DRY_RUN' }, null, 2)
|
|
87
|
+
}],
|
|
88
|
+
isError: true
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let validated;
|
|
93
|
+
try {
|
|
94
|
+
validated = validateOptions(cmd, args);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (error instanceof ValidationError) {
|
|
97
|
+
return {
|
|
98
|
+
content: [{
|
|
99
|
+
type: 'text',
|
|
100
|
+
text: JSON.stringify({ error: error.message, code: error.code, option: error.option }, null, 2)
|
|
101
|
+
}],
|
|
102
|
+
isError: true
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
|
|
63
108
|
const sessionState = await readSessionStateFn();
|
|
64
109
|
const transport = await createTransportFn({}, sessionState);
|
|
65
110
|
|
|
@@ -70,12 +115,23 @@ export function registerMcpTools(server, {
|
|
|
70
115
|
};
|
|
71
116
|
}
|
|
72
117
|
|
|
118
|
+
if (cmd.dryRun && validated['dry-run']) {
|
|
119
|
+
const request = await buildWriteRequest(cmd.id, validated, sessionState);
|
|
120
|
+
return {
|
|
121
|
+
content: [{ type: 'text', text: JSON.stringify({ dryRun: true, command: cmd.command, request }, null, 2) }]
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
73
125
|
const result = writeCommands.has(cmd.id)
|
|
74
|
-
? await transport.executeWriteCommand(cmd.id,
|
|
75
|
-
: await transport.executeReadCommand(cmd.id,
|
|
126
|
+
? await transport.executeWriteCommand(cmd.id, validated)
|
|
127
|
+
: await transport.executeReadCommand(cmd.id, validated);
|
|
128
|
+
|
|
129
|
+
const projected = cmd.supportsFields && validated.fields !== undefined
|
|
130
|
+
? projectFields(result, validated.fields)
|
|
131
|
+
: result;
|
|
76
132
|
|
|
77
133
|
return {
|
|
78
|
-
content: [{ type: 'text', text: JSON.stringify(
|
|
134
|
+
content: [{ type: 'text', text: JSON.stringify(projected, null, 2) }]
|
|
79
135
|
};
|
|
80
136
|
} catch (error) {
|
|
81
137
|
const message = error && error.message ? error.message : String(error);
|