fitvete-food-cli 1.4.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 +28 -3
  2. package/index.js +69 -10
  3. package/package.json +1 -1
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
 
@@ -84,7 +92,7 @@ The CLI is designed to be driven by autonomous agents:
84
92
  - Output is pure JSON — no prompts, spinners, or colour codes.
85
93
  - Failures return a non-zero exit code and a JSON error body.
86
94
  - `fitvete-food tools` prints a function-calling manifest describing every command
87
- 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.
88
96
 
89
97
  ```bash
90
98
  fitvete-food tools # → { "tools": [ { "name": "fitvete_search_foods", "input_schema": {...} }, ... ] }
@@ -105,6 +113,23 @@ const run = (cmd, args) => new Promise((res, rej) =>
105
113
 
106
114
  The same key (`FITVETE_API_KEY`) and JSON contract apply.
107
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
+
108
133
  ## License
109
134
 
110
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.4.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,18 +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
  ],
183
200
  validate: (a, o) => {
184
- if (!o.barcode && a.join(" ").trim() === "") fail(2, "score needs a food name or --barcode CODE");
201
+ if ([o.barcode, o.id, a.join(" ").trim()].filter(Boolean).length !== 1) fail(2, "score needs exactly one food name, --barcode or --id");
185
202
  },
186
203
  path: () => "/v1/score",
187
204
  jsonBody: (a, o) => ({
188
- 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(" ") },
189
206
  profile: buildProfile(o),
190
207
  explain: o.explain != null,
191
208
  }),
@@ -211,13 +228,13 @@ const COMMANDS = {
211
228
  usage: "alternatives <name...>|--barcode CODE [--number N]",
212
229
  optionalArgs: true,
213
230
  args: [{ name: "name", type: "string", required: false, description: "Food name (or use --barcode)." }],
214
- 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)." }],
215
232
  validate: (a, o) => {
216
- if (!o.barcode && a.join(" ").trim() === "") fail(2, "alternatives needs a food name or --barcode CODE");
233
+ if ([o.barcode, o.id, a.join(" ").trim()].filter(Boolean).length !== 1) fail(2, "alternatives needs exactly one food name, --barcode or --id");
217
234
  },
218
235
  path: (a, o) => {
219
236
  const p = new URLSearchParams();
220
- 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(" "));
221
238
  if (o.number) p.set("number", String(o.number));
222
239
  return `/v1/alternatives?${p.toString()}`;
223
240
  },
@@ -257,8 +274,13 @@ function resolveTimeout(flag) {
257
274
  return n;
258
275
  }
259
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
+ }
260
281
  function fail(code, msg) {
261
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");
262
284
  process.exit(code);
263
285
  }
264
286
 
@@ -341,6 +363,37 @@ function helpText() {
341
363
  return lines.join("\n");
342
364
  }
343
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
+
344
397
  // AI-agent tool manifest: every command as a function-calling tool definition.
345
398
  function toolManifest() {
346
399
  const tools = Object.entries(COMMANDS).map(([name, c]) => {
@@ -350,10 +403,12 @@ function toolManifest() {
350
403
  properties[a.name] = { type: a.type === "integer" ? "integer" : "string", description: a.description };
351
404
  if (a.required) required.push(a.name);
352
405
  }
353
- 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 };
354
407
  return {
355
408
  name: `fitvete_${name.replace(/-/g, "_")}`,
356
409
  command: name,
410
+ method: c.method || (c.jsonBody ? "POST" : "GET"),
411
+ path: COMMAND_ROUTES[name],
357
412
  description: c.summary,
358
413
  cli: `fitvete-food ${c.usage}`,
359
414
  input_schema: { type: "object", properties, required },
@@ -389,12 +444,16 @@ async function request(spec, args, opts) {
389
444
  };
390
445
  if (spec.jsonBody) {
391
446
  init.headers["content-type"] = "application/json";
392
- 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);
393
450
  } else if (spec.imageArg) {
394
451
  const filePath = args[0];
395
452
  let bytes;
396
453
  try {
454
+ if ((await stat(filePath)).size > 5 * 1024 * 1024) fail(2, "image exceeds 5 MB");
397
455
  bytes = await readFile(filePath);
456
+ if (bytes.length > 5 * 1024 * 1024) fail(2, "image exceeds 5 MB");
398
457
  } catch (e) {
399
458
  fail(2, `cannot read image file: ${e.message}`);
400
459
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fitvete-food-cli",
3
- "version": "1.4.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
6
  "fitvete-food": "index.js",