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.
- package/README.md +28 -3
- package/index.js +69 -10
- 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.
|
|
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: "
|
|
198
|
+
{ name: "explain", type: "boolean", description: "Add a natural-language summary (--explain)." },
|
|
182
199
|
],
|
|
183
200
|
validate: (a, o) => {
|
|
184
|
-
if (
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
}
|