incremnt 0.1.5 → 0.1.8

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 CHANGED
@@ -12,7 +12,7 @@ npm install -g incremnt
12
12
 
13
13
  ### Hosted sync (recommended)
14
14
 
15
- After connecting with Apple in the iOS app (Settings > Workout Sync), your workouts sync automatically. To access them from the CLI:
15
+ After connecting with Apple in the iOS app (Settings > Cloud Sync), your workouts sync automatically. To access them from the CLI:
16
16
 
17
17
  ```bash
18
18
  incremnt login
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "incremnt",
3
- "version": "0.1.5",
3
+ "version": "0.1.8",
4
4
  "description": "Command-line tool for querying your incremnt strength training data",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
8
- "incremnt": "./src/index.js"
8
+ "incremnt": "./src/index.js",
9
+ "incremnt-mcp": "./src/mcp.js"
9
10
  },
10
11
  "files": [
11
12
  "src/"
@@ -15,6 +16,8 @@
15
16
  "dev:sync-fixture": "node ./scripts/dev-sync-fixture-server.js"
16
17
  },
17
18
  "dependencies": {
18
- "chalk": "^5.6.2"
19
+ "@modelcontextprotocol/sdk": "^1.12.1",
20
+ "chalk": "^5.6.2",
21
+ "zod": "^3.24.0"
19
22
  }
20
23
  }
package/src/contract.js CHANGED
@@ -1,9 +1,10 @@
1
1
  export const contractVersion = 1;
2
2
 
3
3
  export const capabilities = {
4
- readOnly: true,
4
+ readOnly: false,
5
5
  localSnapshots: true,
6
6
  remoteReads: true,
7
+ remoteWrites: true,
7
8
  remoteAuthShell: true,
8
9
  remoteBootstrap: true
9
10
  };
@@ -82,11 +83,92 @@ export const commandSchema = [
82
83
  id: 'records',
83
84
  description: 'Personal records',
84
85
  options: []
86
+ },
87
+ {
88
+ command: 'goals list',
89
+ id: 'goals-list',
90
+ description: 'List strength plans and lift goals',
91
+ options: []
92
+ },
93
+ {
94
+ command: 'goals show',
95
+ id: 'goals-show',
96
+ description: 'Show strength plan goal details',
97
+ usage: 'goals show --id <plan-id>',
98
+ options: [
99
+ { name: 'id', type: 'string', required: true, description: 'Plan ID' }
100
+ ]
85
101
  }
86
102
  ];
87
103
 
104
+ export const writeCommandSchema = [
105
+ {
106
+ command: 'programs propose',
107
+ id: 'programs-propose',
108
+ description: 'Submit a program proposal',
109
+ usage: 'programs propose --file <file>',
110
+ options: [
111
+ { name: 'file', type: 'string', required: true, description: 'Path to proposal JSON file' }
112
+ ]
113
+ },
114
+ {
115
+ command: 'programs proposals',
116
+ id: 'programs-proposals',
117
+ description: 'List program proposals',
118
+ options: [
119
+ { name: 'status', type: 'string', required: false, description: 'Filter by status (pending, accepted, dismissed)' }
120
+ ]
121
+ },
122
+ {
123
+ command: 'programs proposal dismiss',
124
+ id: 'proposal-dismiss',
125
+ description: 'Dismiss a program proposal',
126
+ usage: 'programs proposal dismiss --id <id>',
127
+ options: [
128
+ { name: 'id', type: 'string', required: true, description: 'Proposal ID' }
129
+ ]
130
+ }
131
+ ];
132
+
133
+ export const writeCommands = new Set(writeCommandSchema.map((c) => c.id));
134
+
135
+ export const proposalSchema = {
136
+ description: 'JSON structure for programs propose --file. Weights are omitted — iOS calculates from history.',
137
+ required: ['name', 'equipmentTier', 'days'],
138
+ properties: {
139
+ name: { type: 'string', description: 'Program name' },
140
+ equipmentTier: { type: 'string', enum: ['fullGym', 'benchDumbbells', 'dumbbellsOnly', 'bodyweightOnly'] },
141
+ trainingWeekdays: { type: 'array', items: { type: 'integer', minimum: 0, maximum: 6 }, description: 'Days of week (0=Sun). Optional.' },
142
+ rationale: { type: 'string', description: 'Why this program was suggested. Optional.' },
143
+ days: {
144
+ type: 'array',
145
+ minItems: 1,
146
+ items: {
147
+ required: ['title', 'exercises'],
148
+ properties: {
149
+ title: { type: 'string', description: 'Day name, e.g. "Upper A"' },
150
+ exercises: {
151
+ type: 'array',
152
+ minItems: 1,
153
+ items: {
154
+ required: ['name', 'sets', 'reps'],
155
+ properties: {
156
+ name: { type: 'string', description: 'Exercise name (use canonical names from records or exercises history)' },
157
+ muscleGroup: { type: 'string', description: 'e.g. "Chest", "Back". Optional.' },
158
+ sets: { type: 'integer', minimum: 1 },
159
+ reps: { type: 'integer', minimum: 1 }
160
+ }
161
+ }
162
+ }
163
+ }
164
+ }
165
+ }
166
+ }
167
+ };
168
+
88
169
  export const officialCommands = [
89
170
  ...commandSchema.map((c) => c.usage ?? c.command),
171
+ ...writeCommandSchema.map((c) => c.usage ?? c.command),
90
172
  'status',
91
173
  'contract',
92
174
  'login',
package/src/format.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import chalk from 'chalk';
2
- import { commandSchema } from './contract.js';
2
+ import { commandSchema, writeCommandSchema } from './contract.js';
3
3
 
4
4
  const shortMonths = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
5
5
  const shortDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
@@ -366,6 +366,48 @@ function formatWhyDidThisChange(payload) {
366
366
  return lines.join('\n');
367
367
  }
368
368
 
369
+ function formatProposalCreated(payload) {
370
+ if (!payload) return 'No proposal data.';
371
+ const lines = [
372
+ header('PROPOSAL CREATED'),
373
+ '',
374
+ keyValue('ID', payload.id),
375
+ keyValue('Status', payload.status),
376
+ keyValue('Program', payload.proposal?.name ?? 'Untitled'),
377
+ keyValue('Days', String(payload.proposal?.days?.length ?? 0))
378
+ ];
379
+ return lines.join('\n');
380
+ }
381
+
382
+ function formatProposalsList(payload) {
383
+ if (!Array.isArray(payload) || payload.length === 0) {
384
+ return 'No proposals found.';
385
+ }
386
+
387
+ const statusBadge = (status) => {
388
+ if (status === 'pending') return chalk.yellow(status);
389
+ if (status === 'accepted') return chalk.green(status);
390
+ if (status === 'dismissed') return chalk.dim(status);
391
+ return status;
392
+ };
393
+
394
+ const lines = [header('PROGRAM PROPOSALS'), ''];
395
+
396
+ for (const p of payload) {
397
+ const name = p.proposal?.name ?? 'Untitled';
398
+ const days = p.proposal?.days?.length ?? 0;
399
+ const date = formatShortDate(p.createdAt);
400
+ lines.push(` ${statusBadge(p.status)} ${chalk.bold(name)}${dimDot()}${chalk.dim(`${days} days`)}${dimDot()}${chalk.dim(date)}${dimDot()}${chalk.dim(p.id)}`);
401
+ }
402
+
403
+ return lines.join('\n');
404
+ }
405
+
406
+ function formatProposalDismissed(payload) {
407
+ if (!payload) return 'Proposal not found.';
408
+ return ` Proposal ${chalk.bold(payload.id)} dismissed.`;
409
+ }
410
+
369
411
  // --- Main export ---
370
412
 
371
413
  export function formatHelp() {
@@ -380,6 +422,9 @@ export function formatHelp() {
380
422
  header('COMMANDS'),
381
423
  ...commandSchema.map((c) => cmd(c.usage ?? c.command, c.description)),
382
424
  '',
425
+ header('WRITE COMMANDS'),
426
+ ...writeCommandSchema.map((c) => cmd(c.usage ?? c.command, c.description)),
427
+ '',
383
428
  header('AUTH'),
384
429
  cmd('login', 'Sign in (opens browser)'),
385
430
  cmd('login --base-url <url>', 'Sign in to a specific server'),
@@ -413,7 +458,10 @@ export function formatPretty(command, payload) {
413
458
  'program-list': formatProgramList,
414
459
  'program-detail': formatProgramDetail,
415
460
  'planned-vs-actual': formatPlannedVsActual,
416
- 'why-did-this-change': formatWhyDidThisChange
461
+ 'why-did-this-change': formatWhyDidThisChange,
462
+ 'programs-propose': formatProposalCreated,
463
+ 'programs-proposals': formatProposalsList,
464
+ 'proposal-dismiss': formatProposalDismissed
417
465
  }[command];
418
466
 
419
467
  return formatter ? formatter(payload) : null;
package/src/lib.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import { spawn } from 'node:child_process';
3
- import { capabilities, commandSchema, contractVersion, officialCommands, readCommands } from './contract.js';
3
+ import { capabilities, commandSchema, contractVersion, officialCommands, proposalSchema, readCommands, writeCommands, writeCommandSchema } from './contract.js';
4
4
  import {
5
5
  bootstrapSessionFromRemoteBaseUrl,
6
6
  bootstrapSessionFromRemoteBaseUrlWithDeviceFlow,
@@ -54,12 +54,16 @@ export async function runCli(argv, stdout, stderr) {
54
54
  const { command, options } = parseArgs(argv);
55
55
  const normalizedCommand = ({
56
56
  ...Object.fromEntries(commandSchema.map((c) => [c.command, c.id])),
57
+ ...Object.fromEntries(writeCommandSchema.map((c) => [c.command, c.id])),
57
58
  insights: 'session-insights',
58
59
  history: 'exercise-history',
59
60
  prs: 'records',
60
61
  program: 'program-summary',
61
62
  compare: 'planned-vs-actual',
62
- explain: 'why-did-this-change'
63
+ explain: 'why-did-this-change',
64
+ propose: 'programs-propose',
65
+ proposals: 'programs-proposals',
66
+ dismiss: 'proposal-dismiss'
63
67
  })[command] ?? command;
64
68
 
65
69
  if (!command || options.help) {
@@ -109,7 +113,9 @@ export async function runCli(argv, stdout, stderr) {
109
113
  binary: 'incremnt',
110
114
  capabilities,
111
115
  officialCommands,
112
- schema: commandSchema
116
+ schema: commandSchema,
117
+ writeSchema: writeCommandSchema,
118
+ proposalSchema
113
119
  }, null, 2)}\n`);
114
120
  return 0;
115
121
  }
@@ -263,6 +269,28 @@ export async function runCli(argv, stdout, stderr) {
263
269
  const wantJson = options.json || !(stdout.isTTY ?? false);
264
270
  const explicitJson = Boolean(options.json);
265
271
 
272
+ if (writeCommands.has(normalizedCommand)) {
273
+ try {
274
+ const payload = await transport.executeWriteCommand(normalizedCommand, options);
275
+ if (wantJson) {
276
+ stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
277
+ } else {
278
+ const pretty = formatPretty(normalizedCommand, payload);
279
+ stdout.write(`${pretty ?? JSON.stringify(payload, null, 2)}\n`);
280
+ }
281
+
282
+ return 0;
283
+ } catch (error) {
284
+ if (explicitJson) {
285
+ stdout.write(`${JSON.stringify({ error: error.message, code: error.code ?? null }, null, 2)}\n`);
286
+ } else {
287
+ stderr.write(`${error.message}\n`);
288
+ }
289
+
290
+ return 1;
291
+ }
292
+ }
293
+
266
294
  if (!readCommands.has(normalizedCommand)) {
267
295
  const message = `Unknown command: ${command}`;
268
296
  if (explicitJson) {
package/src/mcp.js ADDED
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { z } from 'zod';
6
+ import { commandSchema } from './contract.js';
7
+ import { readSessionState } from './state.js';
8
+ import { createTransport } from './transport.js';
9
+
10
+ const server = new McpServer({
11
+ name: 'incremnt',
12
+ version: '0.1.0'
13
+ });
14
+
15
+ for (const cmd of commandSchema) {
16
+ const shape = {};
17
+
18
+ for (const opt of cmd.options) {
19
+ let field = opt.type === 'number' ? z.number() : z.string();
20
+ if (!opt.required) field = field.optional();
21
+ field = field.describe(opt.description);
22
+ shape[opt.name] = field;
23
+ }
24
+
25
+ server.tool(cmd.id, cmd.description, shape, async (args) => {
26
+ try {
27
+ const sessionState = await readSessionState();
28
+ const transport = await createTransport({}, sessionState);
29
+
30
+ if (transport.expired) {
31
+ return {
32
+ content: [{ type: 'text', text: 'Session expired. Run `incremnt login` to re-authenticate.' }],
33
+ isError: true
34
+ };
35
+ }
36
+
37
+ const result = await transport.executeReadCommand(cmd.id, args);
38
+ return {
39
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
40
+ };
41
+ } catch (error) {
42
+ const message = error?.message ?? String(error);
43
+
44
+ if (error?.code === 'SNAPSHOT_NOT_FOUND') {
45
+ return {
46
+ content: [{ type: 'text', text: 'Not logged in. Run `incremnt login` first.' }],
47
+ isError: true
48
+ };
49
+ }
50
+
51
+ return {
52
+ content: [{ type: 'text', text: message }],
53
+ isError: true
54
+ };
55
+ }
56
+ });
57
+ }
58
+
59
+ const transport = new StdioServerTransport();
60
+ await server.connect(transport);
@@ -0,0 +1,193 @@
1
+ const DEFAULT_MODEL = 'meta-llama/llama-3.1-8b-instruct:free';
2
+ const DEFAULT_TIMEOUT_MS = 15_000;
3
+ const DEFAULT_MAX_TOKENS = 300;
4
+
5
+ const SYSTEM_PROMPT = `You are a strength coach writing a brief note after reviewing a trainee's week of training. Write 2-3 short paragraphs separated by blank lines.
6
+
7
+ Rules:
8
+ - Reference specific exercises, weights, and reps from the data. Use numbers, not vague praise.
9
+ - If there are PRs, mention them matter-of-factly. Do not celebrate or inflate them.
10
+ - Note what went well and what lagged, with specifics.
11
+ - End with one concrete suggestion for next week. Be specific about which exercise and what to change.
12
+ - Write like a real person texting a training partner — short sentences, no filler, no cheerleading.
13
+ - Never use words like: impressive, crushed, solid, demonstrating, significant, incredible, fantastic, highlighting.
14
+ - Never use -ing clauses that add fake depth ("indicating progressive overload", "demonstrating strength gains").
15
+ - No bullet points or lists.`;
16
+
17
+ export async function generateCoachingSummary(cycleContext, { apiKey, model, timeoutMs } = {}) {
18
+ const resolvedModel = model || DEFAULT_MODEL;
19
+ const resolvedTimeout = timeoutMs || DEFAULT_TIMEOUT_MS;
20
+
21
+ const controller = new AbortController();
22
+ const timer = setTimeout(() => controller.abort(), resolvedTimeout);
23
+
24
+ try {
25
+ const userContent = formatCycleContext(cycleContext);
26
+
27
+ const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
28
+ method: 'POST',
29
+ headers: {
30
+ 'Authorization': `Bearer ${apiKey}`,
31
+ 'Content-Type': 'application/json',
32
+ 'HTTP-Referer': 'https://incremnt.app',
33
+ 'X-Title': 'incremnt'
34
+ },
35
+ body: JSON.stringify({
36
+ model: resolvedModel,
37
+ messages: [
38
+ { role: 'system', content: SYSTEM_PROMPT },
39
+ { role: 'user', content: userContent }
40
+ ],
41
+ max_tokens: DEFAULT_MAX_TOKENS,
42
+ temperature: 0.7
43
+ }),
44
+ signal: controller.signal
45
+ });
46
+
47
+ if (!response.ok) {
48
+ const text = await response.text().catch(() => '');
49
+ throw new Error(`OpenRouter API error ${response.status}: ${text}`);
50
+ }
51
+
52
+ const data = await response.json();
53
+ const content = data.choices?.[0]?.message?.content;
54
+ if (!content) {
55
+ throw new Error('No content in OpenRouter response');
56
+ }
57
+
58
+ return content.trim();
59
+ } finally {
60
+ clearTimeout(timer);
61
+ }
62
+ }
63
+
64
+ function formatCycleContext(ctx) {
65
+ const lines = [
66
+ `Program: ${ctx.programName}, Week ${ctx.cycleNumber}, ${ctx.totalSessions} session(s).`
67
+ ];
68
+
69
+ for (const session of ctx.sessions) {
70
+ const parts = [session.dayName || 'Session'];
71
+ if (session.totalVolume) parts.push(`${session.totalVolume} kg volume`);
72
+ if (session.effortScore) parts.push(`effort ${session.effortScore}/10`);
73
+ lines.push(parts.join(', '));
74
+
75
+ for (const ex of session.exercises) {
76
+ const setPart = ex.plannedSets != null
77
+ ? `${ex.completedSets}/${ex.plannedSets} sets`
78
+ : `${ex.completedSets} sets`;
79
+ const topPart = ex.topSet
80
+ ? ` (top: ${ex.topSet.weight}×${ex.topSet.reps})`
81
+ : '';
82
+ lines.push(` ${ex.exerciseName}: ${setPart}${topPart}`);
83
+ }
84
+ }
85
+
86
+ if (ctx.prsThisCycle.length > 0) {
87
+ lines.push('PRs this week:');
88
+ for (const pr of ctx.prsThisCycle) {
89
+ lines.push(` ${pr.exerciseName}: ${pr.weight}×${pr.reps} (e1RM ${pr.estimatedOneRM})`);
90
+ }
91
+ }
92
+
93
+ if (ctx.goalProgress) {
94
+ lines.push('Goal progress:');
95
+ for (const g of ctx.goalProgress) {
96
+ lines.push(` ${g.exerciseName}: ${g.progressPercent ?? '?'}% toward ${g.targetE1RM} e1RM`);
97
+ }
98
+ }
99
+
100
+ return lines.join('\n');
101
+ }
102
+
103
+ export const WORKOUT_COACH_PROMPT = `You are a strength coach writing a brief note after reviewing one training session. Write 2-3 short paragraphs separated by blank lines.
104
+
105
+ Rules:
106
+ - Reference specific exercises, weights, and reps from the data. Use numbers, not vague praise.
107
+ - If there are PRs, mention them matter-of-factly. Do not celebrate or inflate them.
108
+ - Compare volume or effort to recent sessions if the data is there. Just state what changed.
109
+ - End with one concrete suggestion for next session. Be specific about which exercise and what to try.
110
+ - Write like a real person texting a training partner — short sentences, no filler, no cheerleading.
111
+ - Never use words like: impressive, crushed, solid, demonstrating, significant, incredible, fantastic, highlighting.
112
+ - Never use -ing clauses that add fake depth ("indicating progressive overload", "demonstrating strength gains").
113
+ - No bullet points or lists.`;
114
+
115
+ export async function generateWorkoutCoachingSummary(workoutContext, { apiKey, model, timeoutMs } = {}) {
116
+ const resolvedModel = model || DEFAULT_MODEL;
117
+ const resolvedTimeout = timeoutMs || DEFAULT_TIMEOUT_MS;
118
+
119
+ const controller = new AbortController();
120
+ const timer = setTimeout(() => controller.abort(), resolvedTimeout);
121
+
122
+ try {
123
+ const userContent = formatWorkoutContext(workoutContext);
124
+
125
+ const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
126
+ method: 'POST',
127
+ headers: {
128
+ 'Authorization': `Bearer ${apiKey}`,
129
+ 'Content-Type': 'application/json',
130
+ 'HTTP-Referer': 'https://incremnt.app',
131
+ 'X-Title': 'incremnt'
132
+ },
133
+ body: JSON.stringify({
134
+ model: resolvedModel,
135
+ messages: [
136
+ { role: 'system', content: WORKOUT_COACH_PROMPT },
137
+ { role: 'user', content: userContent }
138
+ ],
139
+ max_tokens: DEFAULT_MAX_TOKENS,
140
+ temperature: 0.7
141
+ }),
142
+ signal: controller.signal
143
+ });
144
+
145
+ if (!response.ok) {
146
+ const text = await response.text().catch(() => '');
147
+ throw new Error(`OpenRouter API error ${response.status}: ${text}`);
148
+ }
149
+
150
+ const data = await response.json();
151
+ const content = data.choices?.[0]?.message?.content;
152
+ if (!content) {
153
+ throw new Error('No content in OpenRouter response');
154
+ }
155
+
156
+ return content.trim();
157
+ } finally {
158
+ clearTimeout(timer);
159
+ }
160
+ }
161
+
162
+ export function formatWorkoutContext(ctx) {
163
+ const lines = [
164
+ `Session: ${ctx.dayName}, ${ctx.sessionDate}, ${ctx.totalVolume} kg total volume.`
165
+ ];
166
+
167
+ if (ctx.effortScore) {
168
+ lines.push(`Effort rating: ${ctx.effortScore}/10.`);
169
+ }
170
+
171
+ lines.push('Exercises:');
172
+ for (const ex of ctx.exercises) {
173
+ const topPart = ex.topSet ? ` (top: ${ex.topSet.weight}×${ex.topSet.reps})` : '';
174
+ lines.push(` ${ex.exerciseName}: ${ex.completedSets} sets${topPart}`);
175
+ }
176
+
177
+ if (ctx.prs.length > 0) {
178
+ lines.push('PRs this session:');
179
+ for (const pr of ctx.prs) {
180
+ lines.push(` ${pr.exerciseName}: ${pr.weight}×${pr.reps} (e1RM ${pr.estimatedOneRM})`);
181
+ }
182
+ }
183
+
184
+ if (ctx.recentComparisons.length > 0) {
185
+ lines.push('Recent same-day sessions for comparison:');
186
+ for (const comp of ctx.recentComparisons) {
187
+ const effort = comp.effortScore ? `, effort ${comp.effortScore}/10` : '';
188
+ lines.push(` ${comp.date}: ${comp.totalVolume} kg volume${effort}`);
189
+ }
190
+ }
191
+
192
+ return lines.join('\n');
193
+ }