fitvete-food-cli 1.3.0 → 1.6.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.
Files changed (3) hide show
  1. package/README.md +32 -3
  2. package/index.js +116 -20
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -38,14 +38,22 @@ export FITVETE_API_KEY=fv_live_your_key
38
38
  | `recipe-diets`, `recipe-meal-types`, `recipe-cuisines` | Supported filter values. |
39
39
  | `recipe <id> [--servings N]` | Full recipe detail (ingredients, steps, nutrition); optional servings scaler. |
40
40
  | `drinks <query...> [--number N]` | Search drinks/cocktails. |
41
- | `score <name...>\|--barcode CODE [--goals ... --diet ... --allergies ... --avoid ... --explain]` | Personalized 0–100 health score + grade. |
41
+ | `score <name...>\|--barcode CODE\|--id UUID [--goals ... --diet ... --allergies ... --avoid ... --explain]` | Personalized 0–100 health score + grade. |
42
42
  | `analyze-ingredients <text...>` | Additives, allergens, NOVA level, diet flags. |
43
43
  | `parse <text...>` | Free-text meal → structured items + nutrition (NLP). |
44
- | `alternatives <name...>\|--barcode CODE [--number N]` | Healthier swaps, ranked. |
44
+ | `alternatives <name...>\|--barcode CODE\|--id UUID [--number N]` | Healthier swaps, ranked. |
45
45
  | `foods-autocomplete <query...> [--number N]` | Food name typeahead. |
46
46
  | `barcode <code>` | Look up a packaged food by UPC/EAN barcode. |
47
47
  | `photo <image-path>` | Estimate meal nutrition from a JPEG, PNG, or WebP image. |
48
48
  | `label <image-path>` | Parse a Nutrition Facts label from a JPEG, PNG, or WebP image. |
49
+ | `identify '<json>'` | Unified barcode, text, photo or label identification. |
50
+ | `foods-search <query...>` | Alias for food search. |
51
+ | `food <uuid>` | Read a reference food or one of your private foods. |
52
+ | `create-food '<json>'` | Create a private food with explicit 100 g macros. |
53
+ | `log '<json>'` | Log food_id + grams; server computes nutrition. |
54
+ | `scan <uuid>` | Read your latest scan result (0 points). |
55
+ | `scan-feedback <uuid> '<json>'` | Save a complete correction and expected revision (0 points). |
56
+ | `delete-scan <uuid>` | Delete your scan memory (0 points). |
49
57
  | `tools` | Print a JSON tool manifest for AI agents (function-calling). |
50
58
  | `help`, `version` | Help / version. |
51
59
 
@@ -54,8 +62,12 @@ export FITVETE_API_KEY=fv_live_your_key
54
62
  - `--key <key>` — API key (overrides `FITVETE_API_KEY`).
55
63
  - `--number, -n <N>` — result count for search commands (1–25).
56
64
  - `--base <url>` — API base URL (overrides `FITVETE_API_BASE`).
65
+ - `--timeout <ms>` — request timeout in milliseconds (overrides `FITVETE_API_TIMEOUT`; default `60000`, `none` disables it).
57
66
  - `--pretty` — human-readable table instead of JSON.
58
67
  - `--json` — force JSON (default).
68
+ - `--version, -v` — print the CLI version.
69
+
70
+ > `photo` and `label` images must be JPEG, PNG, or WebP and at most 5 MB.
59
71
 
60
72
  ### Exit codes
61
73
 
@@ -80,7 +92,7 @@ The CLI is designed to be driven by autonomous agents:
80
92
  - Output is pure JSON — no prompts, spinners, or colour codes.
81
93
  - Failures return a non-zero exit code and a JSON error body.
82
94
  - `fitvete-food tools` prints a function-calling manifest describing every command
83
- and its parameters, so a model can discover the available functions at runtime.
95
+ and its method/path and parameters, so a model can discover the available functions at runtime.
84
96
 
85
97
  ```bash
86
98
  fitvete-food tools # → { "tools": [ { "name": "fitvete_search_foods", "input_schema": {...} }, ... ] }
@@ -101,6 +113,23 @@ const run = (cmd, args) => new Promise((res, rej) =>
101
113
 
102
114
  The same key (`FITVETE_API_KEY`) and JSON contract apply.
103
115
 
116
+ ## Storage and charging
117
+
118
+ Private foods/logs require a direct developer key and belong only to its account.
119
+ Scan memory expires after 30 days; management still uses burst limits at 0 points.
120
+ Complete corrections stay private unless explicit contribution consent is supplied.
121
+ Creating foods/logs is not idempotent; do not retry an uncertain creation automatically.
122
+
123
+ Successful authorization reserves the route's points, including cache hits and
124
+ later handler errors. Invalid image format/size is checked before reservation;
125
+ authentication, quota and burst rejection cost zero. `X-RateLimit-Cost` reports the
126
+ actual reserved cost. See the [current contract](https://fitvete.com/api/docs.html#limits).
127
+
128
+ Text/photo identification supplies names and estimated portions; the database
129
+ supplies nutrition. A partial text resolution returns null meal totals plus
130
+ `unresolved_items` and `resolved_totals`. Missing measurements are never filled in.
131
+ The CLI never uploads an image larger than 5 MB.
132
+
104
133
  ## License
105
134
 
106
135
  MIT
package/index.js CHANGED
@@ -17,14 +17,30 @@
17
17
  //
18
18
  // Exit codes: 0 ok · 1 request/HTTP error · 2 usage error · 3 auth/limit.
19
19
 
20
- import { readFile } from "node:fs/promises";
20
+ import { readFile, stat } from "node:fs/promises";
21
21
  import { basename } from "node:path";
22
22
 
23
- const VERSION = "1.3.0";
23
+ const VERSION = "1.6.0";
24
24
  const DEFAULT_BASE = "https://auth.fitvete.com/functions/v1/food-api";
25
25
 
26
26
  // ---- command registry (drives dispatch, --help, and the agent tool manifest) ----
27
27
  const COMMANDS = {
28
+ identify: {summary:"Identify barcode, text, photo or label through one endpoint.",usage:"identify '<JSON>'",args:[{name:"input",type:"string",required:true,description:"JSON type plus barcode/text/image_base64 and content_type."}],options:[],path:()=>"/v1/identify",jsonBody:(a)=>parseJSONArgument(a[0])},
29
+ food: {summary:"Retrieve a reference or private food by UUID.",usage:"food <id>",args:[{name:"id",type:"string",required:true,description:"Food UUID."}],options:[],path:(a)=>`/v1/foods/${enc(a[0])}`},
30
+ "create-food": {summary:"Save private per-100g food nutrition.",usage:"create-food '<JSON>'",args:[{name:"food",type:"string",required:true,description:"JSON name, nutrition_basis=100g and complete calorie/macro values."}],options:[],path:()=>"/v1/foods",jsonBody:(a)=>parseJSONArgument(a[0])},
31
+ log: {summary:"Log grams of a reference or private food.",usage:"log '<JSON>'",args:[{name:"log",type:"string",required:true,description:"JSON food_id, grams and optional logged_at."}],options:[],path:()=>"/v1/logs",jsonBody:(a)=>parseJSONArgument(a[0])},
32
+ "foods-search": {summary:"Search through the foods resource alias.",usage:"foods-search <query...>",args:[{name:"query",type:"string",required:true,description:"Food name."}],options:[{name:"number",type:"integer",description:"1–25 results."}],path:(a,o)=>`/v1/foods/search?query=${enc(a.join(" "))}${o.number ? `&number=${o.number}` : ""}`},
33
+ scan: {
34
+ summary: "Read your private saved scan.", usage: "scan <id>", args: [{name: "id", type: "string", required: true, description: "Scan UUID."}], options: [], path: (a) => `/v1/scans/${enc(a[0])}`,
35
+ },
36
+ "delete-scan": {
37
+ summary: "Delete your private saved scan.", usage: "delete-scan <id>", args: [{name: "id", type: "string", required: true, description: "Scan UUID."}], options: [], method: "DELETE", path: (a) => `/v1/scans/${enc(a[0])}`,
38
+ },
39
+ "scan-feedback": {
40
+ summary: "Correct your scan with complete totals; contribution defaults to private.", usage: "scan-feedback <id> '<JSON>'",
41
+ args: [{name: "id", type: "string", required: true, description: "Scan UUID."}, {name: "feedback", type: "string", required: true, description: "JSON with scan_revision and correction; consent must be explicit."}], options: [],
42
+ path: (a) => `/v1/scans/${enc(a[0])}/feedback`, jsonBody: (a) => parseJSONArgument(a[1]),
43
+ },
28
44
  "search-foods": {
29
45
  summary: "Search foods by name; returns calories, macros, and confidence.",
30
46
  usage: "search-foods <query...> [--number N]",
@@ -174,15 +190,19 @@ const COMMANDS = {
174
190
  args: [{ name: "name", type: "string", required: false, description: "Food name (or use --barcode)." }],
175
191
  options: [
176
192
  { name: "barcode", type: "string", description: "Look up by UPC/EAN instead of name." },
193
+ { name: "id", type: "string", description: "Reference food UUID, exclusive with name/barcode." },
177
194
  { name: "goals", type: "string", description: "Comma-separated, e.g. build muscle,low sugar." },
178
195
  { name: "diet", type: "string", description: "e.g. vegan, vegetarian, keto, gluten_free." },
179
196
  { name: "allergies", type: "string", description: "Comma-separated allergens." },
180
197
  { name: "avoid", type: "string", description: "Comma-separated ingredients to avoid." },
181
- { name: "explain", type: "string", description: "Add a natural-language summary (--explain)." },
198
+ { name: "explain", type: "boolean", description: "Add a natural-language summary (--explain)." },
182
199
  ],
200
+ validate: (a, o) => {
201
+ if ([o.barcode, o.id, a.join(" ").trim()].filter(Boolean).length !== 1) fail(2, "score needs exactly one food name, --barcode or --id");
202
+ },
183
203
  path: () => "/v1/score",
184
204
  jsonBody: (a, o) => ({
185
- food: o.barcode ? { barcode: String(o.barcode) } : { name: a.join(" ") },
205
+ food: o.barcode ? { barcode: String(o.barcode) } : o.id ? {id: String(o.id)} : { name: a.join(" ") },
186
206
  profile: buildProfile(o),
187
207
  explain: o.explain != null,
188
208
  }),
@@ -208,10 +228,13 @@ const COMMANDS = {
208
228
  usage: "alternatives <name...>|--barcode CODE [--number N]",
209
229
  optionalArgs: true,
210
230
  args: [{ name: "name", type: "string", required: false, description: "Food name (or use --barcode)." }],
211
- options: [{ name: "barcode", type: "string", description: "Look up by UPC/EAN." }, { name: "number", type: "integer", description: "Max swaps, 1-10 (default 5)." }],
231
+ options: [{name: "id", type: "string", description: "Food UUID."}, { name: "barcode", type: "string", description: "Look up by UPC/EAN." }, { name: "number", type: "integer", description: "Max swaps, 1-10 (default 5)." }],
232
+ validate: (a, o) => {
233
+ if ([o.barcode, o.id, a.join(" ").trim()].filter(Boolean).length !== 1) fail(2, "alternatives needs exactly one food name, --barcode or --id");
234
+ },
212
235
  path: (a, o) => {
213
236
  const p = new URLSearchParams();
214
- if (o.barcode) p.set("barcode", String(o.barcode)); else p.set("name", a.join(" "));
237
+ if (o.barcode) p.set("barcode", String(o.barcode)); else if (o.id) p.set("id", String(o.id)); else p.set("name", a.join(" "));
215
238
  if (o.number) p.set("number", String(o.number));
216
239
  return `/v1/alternatives?${p.toString()}`;
217
240
  },
@@ -238,11 +261,32 @@ function buildProfile(o) {
238
261
 
239
262
  const enc = (s) => encodeURIComponent(s);
240
263
 
264
+ const DEFAULT_TIMEOUT_MS = 60000;
265
+
266
+ // Resolve the request timeout (ms) from --timeout, then FITVETE_API_TIMEOUT, then
267
+ // the default. `0` (or "none") disables the timeout for long-running AI calls.
268
+ function resolveTimeout(flag) {
269
+ const raw = flag != null ? flag : process.env.FITVETE_API_TIMEOUT;
270
+ if (raw == null || raw === "") return DEFAULT_TIMEOUT_MS;
271
+ if (String(raw).toLowerCase() === "none") return 0;
272
+ const n = Number(raw);
273
+ if (!Number.isFinite(n) || n < 0) fail(2, "--timeout must be a non-negative number of milliseconds (or 'none')");
274
+ return n;
275
+ }
276
+
277
+ function parseJSONArgument(value) {
278
+ try { const parsed = JSON.parse(value); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(); return parsed; }
279
+ catch { fail(2, "a valid JSON object argument is required"); }
280
+ }
241
281
  function fail(code, msg) {
242
282
  process.stderr.write(`fitvete-food: ${msg}\n`);
283
+ process.stdout.write(JSON.stringify({status: "failure", code: code === 3 ? 401 : code === 2 ? 400 : 502, message: msg}) + "\n");
243
284
  process.exit(code);
244
285
  }
245
286
 
287
+ // Flags that never take a value (must not swallow the following positional).
288
+ const BOOLEAN_FLAGS = new Set(["pretty", "json", "help", "version", "explain"]);
289
+
246
290
  // ---- argv parsing: positionals + --flags (--key, --base, --number, --pretty, --json) ----
247
291
  function parseArgs(argv) {
248
292
  const pos = [];
@@ -258,12 +302,17 @@ function parseArgs(argv) {
258
302
  else if (a.startsWith("--key=")) opts.key = a.slice(6);
259
303
  else if (a.startsWith("--base=")) opts.base = a.slice(7);
260
304
  else if (a === "-h" || a === "--help") opts.help = true;
305
+ else if (a === "-v" || a === "--version") opts.version = true;
261
306
  // Generic --flag value / --flag=value (recipe filters etc.), kebab -> camelCase.
262
307
  else if (a.startsWith("--")) {
263
308
  const eq = a.indexOf("=");
264
309
  const rawKey = eq >= 0 ? a.slice(2, eq) : a.slice(2);
265
- const val = eq >= 0 ? a.slice(eq + 1) : (argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true");
266
- opts[rawKey.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = val;
310
+ const key = rawKey.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
311
+ // Boolean flags never consume the next token; everyone else takes --flag value.
312
+ const val = eq >= 0
313
+ ? a.slice(eq + 1)
314
+ : (!BOOLEAN_FLAGS.has(key) && argv[i + 1] != null && !argv[i + 1].startsWith("--") ? argv[++i] : "true");
315
+ opts[key] = val;
267
316
  }
268
317
  else if (a.startsWith("-") && a !== "-") fail(2, `unknown option: ${a}`);
269
318
  else pos.push(a);
@@ -285,18 +334,22 @@ function helpText() {
285
334
  "",
286
335
  "Commands:",
287
336
  ];
288
- for (const [name, c] of Object.entries(COMMANDS)) lines.push(` ${name.padEnd(14)} ${c.summary}`);
337
+ const names = [...Object.keys(COMMANDS), "tools", "help", "version"];
338
+ const w = Math.max(...names.map((n) => n.length));
339
+ for (const [name, c] of Object.entries(COMMANDS)) lines.push(` ${name.padEnd(w)} ${c.summary}`);
289
340
  lines.push(
290
- " tools Print a JSON tool manifest for AI agents (function-calling).",
291
- " help Show this help.",
292
- " version Print the CLI version.",
341
+ ` ${"tools".padEnd(w)} Print a JSON tool manifest for AI agents (function-calling).`,
342
+ ` ${"help".padEnd(w)} Show this help.`,
343
+ ` ${"version".padEnd(w)} Print the CLI version.`,
293
344
  "",
294
345
  "Options:",
295
- " --key <key> API key (overrides FITVETE_API_KEY).",
296
- " --number, -n Max results for search commands (1-25).",
297
- " --base <url> API base URL (overrides FITVETE_API_BASE).",
298
- " --pretty Human-readable table instead of JSON.",
299
- " --json Force JSON output (default).",
346
+ " --key <key> API key (overrides FITVETE_API_KEY).",
347
+ " --number, -n Max results for search commands (1-25).",
348
+ " --base <url> API base URL (overrides FITVETE_API_BASE).",
349
+ " --timeout <ms> Request timeout in ms (default 60000; 'none' to disable).",
350
+ " --pretty Human-readable table instead of JSON.",
351
+ " --json Force JSON output (default).",
352
+ " --version, -v Print the CLI version.",
300
353
  "",
301
354
  "Auth: set FITVETE_API_KEY or pass --key. Get a free key at https://fitvete.com/api/",
302
355
  "",
@@ -310,6 +363,37 @@ function helpText() {
310
363
  return lines.join("\n");
311
364
  }
312
365
 
366
+ const COMMAND_ROUTES = {
367
+ "identify": "/v1/identify",
368
+ "food": "/v1/foods/{id}",
369
+ "create-food": "/v1/foods",
370
+ "log": "/v1/logs",
371
+ "foods-search": "/v1/foods/search",
372
+ "scan": "/v1/scans/{id}",
373
+ "delete-scan": "/v1/scans/{id}",
374
+ "scan-feedback": "/v1/scans/{id}/feedback",
375
+ "search-foods": "/v1/search-foods",
376
+ "compute": "/v1/compute-nutrition",
377
+ "recipes": "/v1/recipes/search",
378
+ "recipe-random": "/v1/recipes/random",
379
+ "recipe-autocomplete": "/v1/recipes/autocomplete",
380
+ "recipe-similar": "/v1/recipes/{id}/similar",
381
+ "recipes-by-nutrients": "/v1/recipes/by-nutrients",
382
+ "recipe-diets": "/v1/recipes/diets",
383
+ "recipe-meal-types": "/v1/recipes/meal-types",
384
+ "recipe-cuisines": "/v1/recipes/cuisines",
385
+ "recipe": "/v1/recipes/{id}",
386
+ "drinks": "/v1/drinks/search",
387
+ "barcode": "/v1/barcode/{code}",
388
+ "photo": "/v1/nutrition-from-photo",
389
+ "label": "/v1/nutrition-label",
390
+ "score": "/v1/score",
391
+ "analyze-ingredients": "/v1/analyze-ingredients",
392
+ "parse": "/v1/parse",
393
+ "alternatives": "/v1/alternatives",
394
+ "foods-autocomplete": "/v1/foods/autocomplete"
395
+ };
396
+
313
397
  // AI-agent tool manifest: every command as a function-calling tool definition.
314
398
  function toolManifest() {
315
399
  const tools = Object.entries(COMMANDS).map(([name, c]) => {
@@ -319,10 +403,12 @@ function toolManifest() {
319
403
  properties[a.name] = { type: a.type === "integer" ? "integer" : "string", description: a.description };
320
404
  if (a.required) required.push(a.name);
321
405
  }
322
- for (const o of c.options) properties[o.name] = { type: o.type === "integer" ? "integer" : "string", description: o.description };
406
+ for (const o of c.options) properties[o.name] = { type: o.type, description: o.description };
323
407
  return {
324
408
  name: `fitvete_${name.replace(/-/g, "_")}`,
325
409
  command: name,
410
+ method: c.method || (c.jsonBody ? "POST" : "GET"),
411
+ path: COMMAND_ROUTES[name],
326
412
  description: c.summary,
327
413
  cli: `fitvete-food ${c.usage}`,
328
414
  input_schema: { type: "object", properties, required },
@@ -358,12 +444,16 @@ async function request(spec, args, opts) {
358
444
  };
359
445
  if (spec.jsonBody) {
360
446
  init.headers["content-type"] = "application/json";
361
- init.body = JSON.stringify(spec.jsonBody(args, opts));
447
+ const payload = spec.jsonBody(args, opts);
448
+ if (typeof payload.image_base64 === "string" && Buffer.byteLength(payload.image_base64, "base64") > 5 * 1024 * 1024) fail(2, "image exceeds 5 MB");
449
+ init.body = JSON.stringify(payload);
362
450
  } else if (spec.imageArg) {
363
451
  const filePath = args[0];
364
452
  let bytes;
365
453
  try {
454
+ if ((await stat(filePath)).size > 5 * 1024 * 1024) fail(2, "image exceeds 5 MB");
366
455
  bytes = await readFile(filePath);
456
+ if (bytes.length > 5 * 1024 * 1024) fail(2, "image exceeds 5 MB");
367
457
  } catch (e) {
368
458
  fail(2, `cannot read image file: ${e.message}`);
369
459
  }
@@ -371,10 +461,15 @@ async function request(spec, args, opts) {
371
461
  form.append("image", new Blob([bytes], { type: mimeType(filePath) }), basename(filePath));
372
462
  init.body = form;
373
463
  }
464
+ const timeoutMs = resolveTimeout(opts.timeout);
465
+ if (timeoutMs) init.signal = AbortSignal.timeout(timeoutMs);
374
466
  let res;
375
467
  try {
376
468
  res = await fetch(base + path, init);
377
469
  } catch (e) {
470
+ if (e && (e.name === "TimeoutError" || e.name === "AbortError")) {
471
+ fail(1, `request timed out after ${timeoutMs}ms (raise it with --timeout or FITVETE_API_TIMEOUT)`);
472
+ }
378
473
  fail(1, `network error: ${e.message}`);
379
474
  }
380
475
  const text = await res.text();
@@ -426,13 +521,14 @@ function pretty(command, data) {
426
521
  const { pos, opts } = parseArgs(process.argv.slice(2));
427
522
  const command = pos.shift();
428
523
 
524
+ if (opts.version || command === "version") { process.stdout.write(VERSION + "\n"); process.exit(0); }
429
525
  if (!command || command === "help" || opts.help) { process.stdout.write(helpText() + "\n"); process.exit(0); }
430
- if (command === "version" || command === "--version" || command === "-v") { process.stdout.write(VERSION + "\n"); process.exit(0); }
431
526
  if (command === "tools") { process.stdout.write(JSON.stringify(toolManifest(), null, 2) + "\n"); process.exit(0); }
432
527
 
433
528
  const spec = COMMANDS[command];
434
529
  if (!spec) fail(2, `unknown command: ${command}. Run 'fitvete-food help'.`);
435
530
  if (pos.length === 0 && !spec.optionalArgs) fail(2, `usage: fitvete-food ${spec.usage}`);
531
+ if (spec.validate) spec.validate(pos, opts);
436
532
 
437
533
  const data = await request(spec, pos, opts);
438
534
  process.stdout.write((opts.pretty ? pretty(command, data) : JSON.stringify(data)) + "\n");
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "fitvete-food-cli",
3
- "version": "1.3.0",
3
+ "version": "1.6.0",
4
4
  "description": "CLI for the FitVete Food & Nutrition API — clean JSON output for scripts and AI agents.",
5
5
  "bin": {
6
- "fitvete-food": "index.js"
6
+ "fitvete-food": "index.js",
7
+ "fitvete-food-cli": "index.js"
7
8
  },
8
9
  "type": "module",
9
10
  "engines": {