fitvete-food-cli 1.3.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 +106 -0
- package/index.js +438 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# fitvete-food-cli
|
|
2
|
+
|
|
3
|
+
Command-line interface for the [FitVete Food & Nutrition API](https://fitvete.com/api/).
|
|
4
|
+
Zero dependencies (Node 18+, built-in `fetch`). Prints clean JSON to stdout, so it
|
|
5
|
+
pipes into `jq` and is trivial for AI agents to parse. Use `--pretty` for humans.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# install globally from npm
|
|
11
|
+
npm install -g fitvete-food-cli
|
|
12
|
+
fitvete-food search-foods chicken
|
|
13
|
+
|
|
14
|
+
# or run without installing
|
|
15
|
+
npx fitvete-food-cli search-foods chicken
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Authenticate
|
|
19
|
+
|
|
20
|
+
Get a free key at <https://fitvete.com/api/>, then:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
export FITVETE_API_KEY=fv_live_your_key
|
|
24
|
+
# or pass --key on any command
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Commands
|
|
28
|
+
|
|
29
|
+
| Command | Description |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| `search-foods <query...> [--number N]` | Search foods; calories, macros, confidence. |
|
|
32
|
+
| `compute <ingredient...>` | Full nutrient breakdown for an ingredient list. |
|
|
33
|
+
| `recipes [query...] [filters]` | Search recipes by name + filters (`--diet --cuisine --type --intolerances --include-ingredients --exclude-ingredients --max-ready-time --min-calories --max-calories --min-protein --sort --offset --number`). |
|
|
34
|
+
| `recipe-random [--number N] [--tags ...]` | Random recipes. |
|
|
35
|
+
| `recipe-autocomplete <query...> [--number N]` | Recipe title suggestions. |
|
|
36
|
+
| `recipes-by-nutrients [--min-protein N --max-calories N ...]` | Recipes matching nutrient ranges. |
|
|
37
|
+
| `recipe-similar <id> [--number N]` | Recipes similar to an id. |
|
|
38
|
+
| `recipe-diets`, `recipe-meal-types`, `recipe-cuisines` | Supported filter values. |
|
|
39
|
+
| `recipe <id> [--servings N]` | Full recipe detail (ingredients, steps, nutrition); optional servings scaler. |
|
|
40
|
+
| `drinks <query...> [--number N]` | Search drinks/cocktails. |
|
|
41
|
+
| `score <name...>\|--barcode CODE [--goals ... --diet ... --allergies ... --avoid ... --explain]` | Personalized 0–100 health score + grade. |
|
|
42
|
+
| `analyze-ingredients <text...>` | Additives, allergens, NOVA level, diet flags. |
|
|
43
|
+
| `parse <text...>` | Free-text meal → structured items + nutrition (NLP). |
|
|
44
|
+
| `alternatives <name...>\|--barcode CODE [--number N]` | Healthier swaps, ranked. |
|
|
45
|
+
| `foods-autocomplete <query...> [--number N]` | Food name typeahead. |
|
|
46
|
+
| `barcode <code>` | Look up a packaged food by UPC/EAN barcode. |
|
|
47
|
+
| `photo <image-path>` | Estimate meal nutrition from a JPEG, PNG, or WebP image. |
|
|
48
|
+
| `label <image-path>` | Parse a Nutrition Facts label from a JPEG, PNG, or WebP image. |
|
|
49
|
+
| `tools` | Print a JSON tool manifest for AI agents (function-calling). |
|
|
50
|
+
| `help`, `version` | Help / version. |
|
|
51
|
+
|
|
52
|
+
### Options
|
|
53
|
+
|
|
54
|
+
- `--key <key>` — API key (overrides `FITVETE_API_KEY`).
|
|
55
|
+
- `--number, -n <N>` — result count for search commands (1–25).
|
|
56
|
+
- `--base <url>` — API base URL (overrides `FITVETE_API_BASE`).
|
|
57
|
+
- `--pretty` — human-readable table instead of JSON.
|
|
58
|
+
- `--json` — force JSON (default).
|
|
59
|
+
|
|
60
|
+
### Exit codes
|
|
61
|
+
|
|
62
|
+
`0` ok · `1` request/HTTP error · `2` usage error · `3` auth or rate-limit.
|
|
63
|
+
|
|
64
|
+
## Examples
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
fitvete-food search-foods "greek yogurt" --number 3
|
|
68
|
+
fitvete-food compute "1 cup rice" "2 eggs" | jq '.calories'
|
|
69
|
+
fitvete-food recipe 715538 | jq '.title'
|
|
70
|
+
fitvete-food drinks margarita --pretty
|
|
71
|
+
fitvete-food barcode 012345678905
|
|
72
|
+
fitvete-food photo ./meal.jpg
|
|
73
|
+
fitvete-food label ./nutrition-label.jpg | jq '.nutrients[] | select(.name=="Sodium")'
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Using it from an AI agent
|
|
77
|
+
|
|
78
|
+
The CLI is designed to be driven by autonomous agents:
|
|
79
|
+
|
|
80
|
+
- Output is pure JSON — no prompts, spinners, or colour codes.
|
|
81
|
+
- Failures return a non-zero exit code and a JSON error body.
|
|
82
|
+
- `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.
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
fitvete-food tools # → { "tools": [ { "name": "fitvete_search_foods", "input_schema": {...} }, ... ] }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### As an MCP tool
|
|
90
|
+
|
|
91
|
+
Expose the CLI to any MCP client (Claude Desktop, Cursor, …) by running it from a
|
|
92
|
+
shell tool, or wrap each command as an MCP tool. Minimal stdio wrapper sketch:
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
// mcp-server.js (pseudocode) — map each manifest tool to a CLI call
|
|
96
|
+
import { execFile } from "node:child_process";
|
|
97
|
+
const run = (cmd, args) => new Promise((res, rej) =>
|
|
98
|
+
execFile("fitvete-food", [cmd, ...args], (e, out) => (e ? rej(e) : res(JSON.parse(out)))));
|
|
99
|
+
// tool "fitvete_search_foods" -> run("search-foods", [query, "--number", String(n)])
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The same key (`FITVETE_API_KEY`) and JSON contract apply.
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// FitVete Food & Nutrition API — command-line interface.
|
|
3
|
+
//
|
|
4
|
+
// Zero dependencies (Node 18+, built-in fetch). Prints clean JSON to stdout so it
|
|
5
|
+
// pipes into jq and is trivial for AI agents to parse; human tables via --pretty.
|
|
6
|
+
//
|
|
7
|
+
// export FITVETE_API_KEY=fv_live_...
|
|
8
|
+
// fitvete-food search-foods chicken --number 5
|
|
9
|
+
// fitvete-food compute "1 cup rice" "2 eggs"
|
|
10
|
+
// fitvete-food recipes pasta
|
|
11
|
+
// fitvete-food recipe 715538
|
|
12
|
+
// fitvete-food drinks margarita
|
|
13
|
+
// fitvete-food barcode 012345678905
|
|
14
|
+
// fitvete-food photo ./meal.jpg
|
|
15
|
+
// fitvete-food label ./nutrition-label.jpg
|
|
16
|
+
// fitvete-food tools # function-calling manifest for AI agents
|
|
17
|
+
//
|
|
18
|
+
// Exit codes: 0 ok · 1 request/HTTP error · 2 usage error · 3 auth/limit.
|
|
19
|
+
|
|
20
|
+
import { readFile } from "node:fs/promises";
|
|
21
|
+
import { basename } from "node:path";
|
|
22
|
+
|
|
23
|
+
const VERSION = "1.3.0";
|
|
24
|
+
const DEFAULT_BASE = "https://auth.fitvete.com/functions/v1/food-api";
|
|
25
|
+
|
|
26
|
+
// ---- command registry (drives dispatch, --help, and the agent tool manifest) ----
|
|
27
|
+
const COMMANDS = {
|
|
28
|
+
"search-foods": {
|
|
29
|
+
summary: "Search foods by name; returns calories, macros, and confidence.",
|
|
30
|
+
usage: "search-foods <query...> [--number N]",
|
|
31
|
+
args: [{ name: "query", type: "string", required: true, description: "Food name, e.g. chicken." }],
|
|
32
|
+
options: [{ name: "number", type: "integer", description: "Max results, 1-25 (default 10)." }],
|
|
33
|
+
path: (a, o) => `/v1/search-foods?query=${enc(a.join(" "))}${o.number ? `&number=${o.number}` : ""}`,
|
|
34
|
+
},
|
|
35
|
+
compute: {
|
|
36
|
+
summary: "Full nutrient breakdown (macros + 25+ micronutrients) for an ingredient list.",
|
|
37
|
+
usage: 'compute <ingredient...> e.g. compute "1 cup rice" "2 eggs"',
|
|
38
|
+
args: [{ name: "ingredients", type: "string", required: true, description: "One or more ingredients (each a separate arg)." }],
|
|
39
|
+
options: [],
|
|
40
|
+
path: (a) => `/v1/compute-nutrition?ingredients=${enc(a.join("\n"))}`,
|
|
41
|
+
},
|
|
42
|
+
recipes: {
|
|
43
|
+
summary: "Search recipes by name and filters (diet, cuisine, calories, …).",
|
|
44
|
+
usage: "recipes [query...] [--diet --cuisine --type --intolerances --include-ingredients --exclude-ingredients --max-ready-time --min-calories --max-calories --min-protein --sort --offset --number]",
|
|
45
|
+
optionalArgs: true,
|
|
46
|
+
args: [{ name: "query", type: "string", required: false, description: "Recipe name or keywords." }],
|
|
47
|
+
options: [
|
|
48
|
+
{ name: "diet", type: "string", description: "e.g. vegetarian, vegan, ketogenic, paleo." },
|
|
49
|
+
{ name: "cuisine", type: "string", description: "e.g. italian, mexican, asian." },
|
|
50
|
+
{ name: "type", type: "string", description: "Meal type, e.g. breakfast, dessert, main course." },
|
|
51
|
+
{ name: "intolerances", type: "string", description: "Comma-separated, e.g. gluten,dairy." },
|
|
52
|
+
{ name: "includeIngredients", type: "string", description: "Comma-separated must-include." },
|
|
53
|
+
{ name: "excludeIngredients", type: "string", description: "Comma-separated must-exclude." },
|
|
54
|
+
{ name: "maxReadyTime", type: "integer", description: "Max minutes to prepare." },
|
|
55
|
+
{ name: "minCalories", type: "integer", description: "Calorie floor." },
|
|
56
|
+
{ name: "maxCalories", type: "integer", description: "Calorie ceiling." },
|
|
57
|
+
{ name: "minProtein", type: "integer", description: "Protein floor (g)." },
|
|
58
|
+
{ name: "sort", type: "string", description: "e.g. popularity, calories, time, protein." },
|
|
59
|
+
{ name: "offset", type: "integer", description: "Pagination offset." },
|
|
60
|
+
{ name: "number", type: "integer", description: "Max results, 1-25 (default 10)." },
|
|
61
|
+
],
|
|
62
|
+
path: (a, o) => {
|
|
63
|
+
const p = new URLSearchParams();
|
|
64
|
+
if (a.length) p.set("query", a.join(" "));
|
|
65
|
+
for (const k of ["diet", "cuisine", "type", "mealType", "intolerances", "includeIngredients", "excludeIngredients", "maxReadyTime", "minCalories", "maxCalories", "minProtein", "sort", "offset", "number"]) {
|
|
66
|
+
if (o[k] != null) p.set(k, String(o[k]));
|
|
67
|
+
}
|
|
68
|
+
return `/v1/recipes/search?${p.toString()}`;
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
"recipe-random": {
|
|
72
|
+
summary: "Get random recipes.",
|
|
73
|
+
usage: "recipe-random [--number N] [--tags TAGS]",
|
|
74
|
+
optionalArgs: true,
|
|
75
|
+
args: [],
|
|
76
|
+
options: [{ name: "number", type: "integer", description: "How many, 1-25 (default 5)." }, { name: "tags", type: "string", description: "Comma-separated, e.g. vegetarian,dessert." }],
|
|
77
|
+
path: (_a, o) => {
|
|
78
|
+
const p = new URLSearchParams();
|
|
79
|
+
if (o.number) p.set("number", String(o.number));
|
|
80
|
+
if (o.tags) p.set("tags", String(o.tags));
|
|
81
|
+
return `/v1/recipes/random?${p.toString()}`;
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
"recipe-autocomplete": {
|
|
85
|
+
summary: "Autocomplete recipe titles.",
|
|
86
|
+
usage: "recipe-autocomplete <query...> [--number N]",
|
|
87
|
+
args: [{ name: "query", type: "string", required: true, description: "Partial recipe name." }],
|
|
88
|
+
options: [{ name: "number", type: "integer", description: "Max suggestions, 1-25 (default 10)." }],
|
|
89
|
+
path: (a, o) => `/v1/recipes/autocomplete?query=${enc(a.join(" "))}${o.number ? `&number=${o.number}` : ""}`,
|
|
90
|
+
},
|
|
91
|
+
"recipe-similar": {
|
|
92
|
+
summary: "Find recipes similar to an id.",
|
|
93
|
+
usage: "recipe-similar <id> [--number N]",
|
|
94
|
+
args: [{ name: "id", type: "integer", required: true, description: "Recipe id." }],
|
|
95
|
+
options: [{ name: "number", type: "integer", description: "Max results, 1-25 (default 5)." }],
|
|
96
|
+
path: (a, o) => {
|
|
97
|
+
const id = Number(a[0]);
|
|
98
|
+
if (!Number.isFinite(id) || id <= 0) fail(2, "recipe-similar requires a numeric id");
|
|
99
|
+
return `/v1/recipes/${id}/similar${o.number ? `?number=${o.number}` : ""}`;
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
"recipes-by-nutrients": {
|
|
103
|
+
summary: "Find recipes by nutrient ranges.",
|
|
104
|
+
usage: "recipes-by-nutrients [--min-protein N --max-calories N --min-carbs N …] [--number N]",
|
|
105
|
+
optionalArgs: true,
|
|
106
|
+
args: [],
|
|
107
|
+
options: [
|
|
108
|
+
{ name: "minCalories", type: "integer", description: "Calorie floor." }, { name: "maxCalories", type: "integer", description: "Calorie ceiling." },
|
|
109
|
+
{ name: "minProtein", type: "integer", description: "Protein floor (g)." }, { name: "maxProtein", type: "integer", description: "Protein ceiling (g)." },
|
|
110
|
+
{ name: "minCarbs", type: "integer", description: "Carb floor (g)." }, { name: "maxCarbs", type: "integer", description: "Carb ceiling (g)." },
|
|
111
|
+
{ name: "minFat", type: "integer", description: "Fat floor (g)." }, { name: "maxFat", type: "integer", description: "Fat ceiling (g)." },
|
|
112
|
+
{ name: "number", type: "integer", description: "Max results, 1-25 (default 10)." },
|
|
113
|
+
],
|
|
114
|
+
path: (_a, o) => {
|
|
115
|
+
const p = new URLSearchParams();
|
|
116
|
+
for (const k of ["minCalories", "maxCalories", "minProtein", "maxProtein", "minCarbs", "maxCarbs", "minFat", "maxFat", "number"]) if (o[k] != null) p.set(k, String(o[k]));
|
|
117
|
+
return `/v1/recipes/by-nutrients?${p.toString()}`;
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
"recipe-diets": { summary: "List supported diet filters.", usage: "recipe-diets", optionalArgs: true, args: [], options: [], path: () => "/v1/recipes/diets" },
|
|
121
|
+
"recipe-meal-types": { summary: "List supported meal types.", usage: "recipe-meal-types", optionalArgs: true, args: [], options: [], path: () => "/v1/recipes/meal-types" },
|
|
122
|
+
"recipe-cuisines": { summary: "List supported cuisines.", usage: "recipe-cuisines", optionalArgs: true, args: [], options: [], path: () => "/v1/recipes/cuisines" },
|
|
123
|
+
recipe: {
|
|
124
|
+
summary: "Get full recipe detail by id (ingredients, steps, nutrition).",
|
|
125
|
+
usage: "recipe <id> [--servings N]",
|
|
126
|
+
args: [{ name: "id", type: "integer", required: true, description: "Recipe id, e.g. 715538." }],
|
|
127
|
+
options: [{ name: "servings", type: "integer", description: "Scale nutrition to this many servings." }],
|
|
128
|
+
path: (a, o) => {
|
|
129
|
+
const id = Number(a[0]);
|
|
130
|
+
if (!Number.isFinite(id) || id <= 0) fail(2, "recipe requires a numeric id, e.g. recipe 715538");
|
|
131
|
+
return `/v1/recipes/${id}${o.servings ? `?servings=${o.servings}` : ""}`;
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
drinks: {
|
|
135
|
+
summary: "Search drinks/cocktails by name.",
|
|
136
|
+
usage: "drinks <query...> [--number N]",
|
|
137
|
+
args: [{ name: "query", type: "string", required: true, description: "Drink name, e.g. margarita." }],
|
|
138
|
+
options: [{ name: "number", type: "integer", description: "Max results, 1-25 (default 10)." }],
|
|
139
|
+
path: (a, o) => `/v1/drinks/search?query=${enc(a.join(" "))}${o.number ? `&number=${o.number}` : ""}`,
|
|
140
|
+
},
|
|
141
|
+
barcode: {
|
|
142
|
+
summary: "Look up a packaged food by barcode.",
|
|
143
|
+
usage: "barcode <code>",
|
|
144
|
+
args: [{ name: "code", type: "string", required: true, description: "Numeric UPC/EAN barcode." }],
|
|
145
|
+
options: [],
|
|
146
|
+
path: (a) => {
|
|
147
|
+
const code = String(a[0] || "").replace(/[^0-9]/g, "").slice(0, 18);
|
|
148
|
+
if (!code) fail(2, "barcode requires a numeric UPC/EAN code");
|
|
149
|
+
return `/v1/barcode/${code}`;
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
photo: {
|
|
153
|
+
summary: "Estimate meal nutrition from a local image file.",
|
|
154
|
+
usage: "photo <image-path>",
|
|
155
|
+
args: [{ name: "image_path", type: "string", required: true, description: "JPEG, PNG, or WebP meal image path." }],
|
|
156
|
+
options: [],
|
|
157
|
+
method: "POST",
|
|
158
|
+
imageArg: true,
|
|
159
|
+
path: () => "/v1/nutrition-from-photo",
|
|
160
|
+
},
|
|
161
|
+
label: {
|
|
162
|
+
summary: "Parse a Nutrition Facts label from a local image file.",
|
|
163
|
+
usage: "label <image-path>",
|
|
164
|
+
args: [{ name: "image_path", type: "string", required: true, description: "JPEG, PNG, or WebP label image path." }],
|
|
165
|
+
options: [],
|
|
166
|
+
method: "POST",
|
|
167
|
+
imageArg: true,
|
|
168
|
+
path: () => "/v1/nutrition-label",
|
|
169
|
+
},
|
|
170
|
+
score: {
|
|
171
|
+
summary: "Personalized health score (0-100 + grade A-F) for a food.",
|
|
172
|
+
usage: 'score <name...>|--barcode CODE [--goals "build muscle,low sugar"] [--diet vegan] [--allergies milk,peanut] [--avoid palm oil] [--explain]',
|
|
173
|
+
optionalArgs: true,
|
|
174
|
+
args: [{ name: "name", type: "string", required: false, description: "Food name (or use --barcode)." }],
|
|
175
|
+
options: [
|
|
176
|
+
{ name: "barcode", type: "string", description: "Look up by UPC/EAN instead of name." },
|
|
177
|
+
{ name: "goals", type: "string", description: "Comma-separated, e.g. build muscle,low sugar." },
|
|
178
|
+
{ name: "diet", type: "string", description: "e.g. vegan, vegetarian, keto, gluten_free." },
|
|
179
|
+
{ name: "allergies", type: "string", description: "Comma-separated allergens." },
|
|
180
|
+
{ name: "avoid", type: "string", description: "Comma-separated ingredients to avoid." },
|
|
181
|
+
{ name: "explain", type: "string", description: "Add a natural-language summary (--explain)." },
|
|
182
|
+
],
|
|
183
|
+
path: () => "/v1/score",
|
|
184
|
+
jsonBody: (a, o) => ({
|
|
185
|
+
food: o.barcode ? { barcode: String(o.barcode) } : { name: a.join(" ") },
|
|
186
|
+
profile: buildProfile(o),
|
|
187
|
+
explain: o.explain != null,
|
|
188
|
+
}),
|
|
189
|
+
},
|
|
190
|
+
"analyze-ingredients": {
|
|
191
|
+
summary: "Additives, allergens, NOVA level, and diet flags for an ingredient list.",
|
|
192
|
+
usage: 'analyze-ingredients <ingredient text...>',
|
|
193
|
+
args: [{ name: "ingredients", type: "string", required: true, description: "Comma-separated ingredient list." }],
|
|
194
|
+
options: [],
|
|
195
|
+
path: () => "/v1/analyze-ingredients",
|
|
196
|
+
jsonBody: (a) => ({ ingredients: a.join(" ") }),
|
|
197
|
+
},
|
|
198
|
+
parse: {
|
|
199
|
+
summary: "Parse a free-text meal into structured items + nutrition (NLP).",
|
|
200
|
+
usage: 'parse <text...> e.g. parse "2 eggs and a slice of toast"',
|
|
201
|
+
args: [{ name: "text", type: "string", required: true, description: "Free-text meal description." }],
|
|
202
|
+
options: [],
|
|
203
|
+
path: () => "/v1/parse",
|
|
204
|
+
jsonBody: (a) => ({ text: a.join(" ") }),
|
|
205
|
+
},
|
|
206
|
+
alternatives: {
|
|
207
|
+
summary: "Healthier swaps for a food, ranked by score.",
|
|
208
|
+
usage: "alternatives <name...>|--barcode CODE [--number N]",
|
|
209
|
+
optionalArgs: true,
|
|
210
|
+
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)." }],
|
|
212
|
+
path: (a, o) => {
|
|
213
|
+
const p = new URLSearchParams();
|
|
214
|
+
if (o.barcode) p.set("barcode", String(o.barcode)); else p.set("name", a.join(" "));
|
|
215
|
+
if (o.number) p.set("number", String(o.number));
|
|
216
|
+
return `/v1/alternatives?${p.toString()}`;
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
"foods-autocomplete": {
|
|
220
|
+
summary: "Fast food name typeahead.",
|
|
221
|
+
usage: "foods-autocomplete <query...> [--number N]",
|
|
222
|
+
args: [{ name: "query", type: "string", required: true, description: "Partial food name." }],
|
|
223
|
+
options: [{ name: "number", type: "integer", description: "Max suggestions, 1-25 (default 10)." }],
|
|
224
|
+
path: (a, o) => `/v1/foods/autocomplete?query=${enc(a.join(" "))}${o.number ? `&number=${o.number}` : ""}`,
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// Build a score profile from CLI options (comma-separated lists).
|
|
229
|
+
function buildProfile(o) {
|
|
230
|
+
const list = (v) => (v ? String(v).split(",").map((s) => s.trim()).filter(Boolean) : []);
|
|
231
|
+
const p = {};
|
|
232
|
+
if (o.goals) p.goals = list(o.goals);
|
|
233
|
+
if (o.diet) p.diet = String(o.diet);
|
|
234
|
+
if (o.allergies) p.allergies = list(o.allergies);
|
|
235
|
+
if (o.avoid) p.avoid = list(o.avoid);
|
|
236
|
+
return p;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const enc = (s) => encodeURIComponent(s);
|
|
240
|
+
|
|
241
|
+
function fail(code, msg) {
|
|
242
|
+
process.stderr.write(`fitvete-food: ${msg}\n`);
|
|
243
|
+
process.exit(code);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---- argv parsing: positionals + --flags (--key, --base, --number, --pretty, --json) ----
|
|
247
|
+
function parseArgs(argv) {
|
|
248
|
+
const pos = [];
|
|
249
|
+
const opts = {};
|
|
250
|
+
for (let i = 0; i < argv.length; i++) {
|
|
251
|
+
const a = argv[i];
|
|
252
|
+
if (a === "--pretty") opts.pretty = true;
|
|
253
|
+
else if (a === "--json") opts.pretty = false;
|
|
254
|
+
else if (a === "--key") opts.key = argv[++i];
|
|
255
|
+
else if (a === "--base") opts.base = argv[++i];
|
|
256
|
+
else if (a === "--number" || a === "-n") opts.number = clampNumber(argv[++i]);
|
|
257
|
+
else if (a.startsWith("--number=")) opts.number = clampNumber(a.slice(9));
|
|
258
|
+
else if (a.startsWith("--key=")) opts.key = a.slice(6);
|
|
259
|
+
else if (a.startsWith("--base=")) opts.base = a.slice(7);
|
|
260
|
+
else if (a === "-h" || a === "--help") opts.help = true;
|
|
261
|
+
// Generic --flag value / --flag=value (recipe filters etc.), kebab -> camelCase.
|
|
262
|
+
else if (a.startsWith("--")) {
|
|
263
|
+
const eq = a.indexOf("=");
|
|
264
|
+
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;
|
|
267
|
+
}
|
|
268
|
+
else if (a.startsWith("-") && a !== "-") fail(2, `unknown option: ${a}`);
|
|
269
|
+
else pos.push(a);
|
|
270
|
+
}
|
|
271
|
+
return { pos, opts };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function clampNumber(v) {
|
|
275
|
+
const n = parseInt(v, 10);
|
|
276
|
+
if (!Number.isFinite(n)) fail(2, "--number must be an integer");
|
|
277
|
+
return Math.max(1, Math.min(25, n));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function helpText() {
|
|
281
|
+
const lines = [
|
|
282
|
+
"FitVete Food & Nutrition API CLI",
|
|
283
|
+
"",
|
|
284
|
+
"Usage: fitvete-food <command> [args] [options]",
|
|
285
|
+
"",
|
|
286
|
+
"Commands:",
|
|
287
|
+
];
|
|
288
|
+
for (const [name, c] of Object.entries(COMMANDS)) lines.push(` ${name.padEnd(14)} ${c.summary}`);
|
|
289
|
+
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.",
|
|
293
|
+
"",
|
|
294
|
+
"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).",
|
|
300
|
+
"",
|
|
301
|
+
"Auth: set FITVETE_API_KEY or pass --key. Get a free key at https://fitvete.com/api/",
|
|
302
|
+
"",
|
|
303
|
+
"Examples:",
|
|
304
|
+
' fitvete-food search-foods chicken --number 5',
|
|
305
|
+
' fitvete-food compute "1 cup rice" "2 eggs" | jq .calories',
|
|
306
|
+
" fitvete-food recipe 715538",
|
|
307
|
+
" fitvete-food barcode 012345678905",
|
|
308
|
+
" fitvete-food photo ./meal.jpg",
|
|
309
|
+
);
|
|
310
|
+
return lines.join("\n");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// AI-agent tool manifest: every command as a function-calling tool definition.
|
|
314
|
+
function toolManifest() {
|
|
315
|
+
const tools = Object.entries(COMMANDS).map(([name, c]) => {
|
|
316
|
+
const properties = {};
|
|
317
|
+
const required = [];
|
|
318
|
+
for (const a of c.args) {
|
|
319
|
+
properties[a.name] = { type: a.type === "integer" ? "integer" : "string", description: a.description };
|
|
320
|
+
if (a.required) required.push(a.name);
|
|
321
|
+
}
|
|
322
|
+
for (const o of c.options) properties[o.name] = { type: o.type === "integer" ? "integer" : "string", description: o.description };
|
|
323
|
+
return {
|
|
324
|
+
name: `fitvete_${name.replace(/-/g, "_")}`,
|
|
325
|
+
command: name,
|
|
326
|
+
description: c.summary,
|
|
327
|
+
cli: `fitvete-food ${c.usage}`,
|
|
328
|
+
input_schema: { type: "object", properties, required },
|
|
329
|
+
};
|
|
330
|
+
});
|
|
331
|
+
return {
|
|
332
|
+
name: "fitvete-food",
|
|
333
|
+
version: VERSION,
|
|
334
|
+
description: "FitVete Food & Nutrition API CLI. Set FITVETE_API_KEY, then run a command; stdout is JSON.",
|
|
335
|
+
base_url: DEFAULT_BASE,
|
|
336
|
+
auth: { env: "FITVETE_API_KEY", header: "x-api-key" },
|
|
337
|
+
tools,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---- HTTP ----
|
|
342
|
+
function mimeType(path) {
|
|
343
|
+
const ext = path.toLowerCase().split(".").pop();
|
|
344
|
+
if (ext === "jpg" || ext === "jpeg") return "image/jpeg";
|
|
345
|
+
if (ext === "png") return "image/png";
|
|
346
|
+
if (ext === "webp") return "image/webp";
|
|
347
|
+
return "application/octet-stream";
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function request(spec, args, opts) {
|
|
351
|
+
const path = spec.path(args, opts);
|
|
352
|
+
const base = opts.base || process.env.FITVETE_API_BASE || DEFAULT_BASE;
|
|
353
|
+
const key = opts.key || process.env.FITVETE_API_KEY;
|
|
354
|
+
if (!key) fail(3, "no API key. Set FITVETE_API_KEY or pass --key. Get one at https://fitvete.com/api/");
|
|
355
|
+
const init = {
|
|
356
|
+
method: spec.method || (spec.jsonBody ? "POST" : "GET"),
|
|
357
|
+
headers: { "x-api-key": key },
|
|
358
|
+
};
|
|
359
|
+
if (spec.jsonBody) {
|
|
360
|
+
init.headers["content-type"] = "application/json";
|
|
361
|
+
init.body = JSON.stringify(spec.jsonBody(args, opts));
|
|
362
|
+
} else if (spec.imageArg) {
|
|
363
|
+
const filePath = args[0];
|
|
364
|
+
let bytes;
|
|
365
|
+
try {
|
|
366
|
+
bytes = await readFile(filePath);
|
|
367
|
+
} catch (e) {
|
|
368
|
+
fail(2, `cannot read image file: ${e.message}`);
|
|
369
|
+
}
|
|
370
|
+
const form = new FormData();
|
|
371
|
+
form.append("image", new Blob([bytes], { type: mimeType(filePath) }), basename(filePath));
|
|
372
|
+
init.body = form;
|
|
373
|
+
}
|
|
374
|
+
let res;
|
|
375
|
+
try {
|
|
376
|
+
res = await fetch(base + path, init);
|
|
377
|
+
} catch (e) {
|
|
378
|
+
fail(1, `network error: ${e.message}`);
|
|
379
|
+
}
|
|
380
|
+
const text = await res.text();
|
|
381
|
+
let body;
|
|
382
|
+
try { body = JSON.parse(text); } catch { body = { raw: text }; }
|
|
383
|
+
if (!res.ok) {
|
|
384
|
+
const msg = (body && body.message) || res.statusText;
|
|
385
|
+
const code = res.status === 401 || res.status === 402 || res.status === 429 ? 3 : 1;
|
|
386
|
+
process.stderr.write(`fitvete-food: ${res.status} ${msg}\n`);
|
|
387
|
+
process.stdout.write(JSON.stringify(body) + "\n");
|
|
388
|
+
process.exit(code);
|
|
389
|
+
}
|
|
390
|
+
return body;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---- pretty (human) rendering ----
|
|
394
|
+
function pretty(command, data) {
|
|
395
|
+
const rows = [];
|
|
396
|
+
if (data && Array.isArray(data.foods)) {
|
|
397
|
+
rows.push(["NAME", "KCAL", "P", "C", "F"]);
|
|
398
|
+
for (const f of data.foods) rows.push([f.name, f.calories, f.protein_g, f.carbs_g, f.fat_g].map(String));
|
|
399
|
+
} else if (data && Array.isArray(data.recipes)) {
|
|
400
|
+
rows.push(["ID", "TITLE", "KCAL"]);
|
|
401
|
+
for (const r of data.recipes) rows.push([String(r.id ?? ""), r.title ?? "", String(r.calories ?? "")]);
|
|
402
|
+
} else if (data && Array.isArray(data.drinks)) {
|
|
403
|
+
rows.push(["TITLE", "KCAL"]);
|
|
404
|
+
for (const d of data.drinks) rows.push([String(d.title ?? d.name ?? ""), String(d.calories ?? "")]);
|
|
405
|
+
} else if (command === "barcode" && data) {
|
|
406
|
+
rows.push(["FIELD", "VALUE"]);
|
|
407
|
+
for (const [k, v] of [["name", data.name], ["brand", data.brand], ["calories", data.calories], ["protein_g", data.protein_g], ["carbs_g", data.carbs_g], ["fat_g", data.fat_g]]) rows.push([String(k), String(v ?? "")]);
|
|
408
|
+
} else if ((command === "photo" || command === "label") && data) {
|
|
409
|
+
rows.push(["FIELD", "VALUE"]);
|
|
410
|
+
for (const [k, v] of [["name", data.name], ["serving_size", data.serving_size], ["calories", data.calories], ["protein_g", data.protein_g], ["carbs_g", data.carbs_g], ["fat_g", data.fat_g]]) {
|
|
411
|
+
if (v != null) rows.push([String(k), String(v)]);
|
|
412
|
+
}
|
|
413
|
+
rows.push(["nutrients", String((data.nutrients || []).length)]);
|
|
414
|
+
} else if (command === "compute" && data) {
|
|
415
|
+
rows.push(["FIELD", "VALUE"]);
|
|
416
|
+
rows.push(["calories", String(data.calories)], ["protein_g", String(data.protein_g)], ["carbs_g", String(data.carbs_g)], ["fat_g", String(data.fat_g)]);
|
|
417
|
+
for (const n of data.nutrients || []) rows.push([n.name, `${n.amount} ${n.unit}`]);
|
|
418
|
+
} else {
|
|
419
|
+
return JSON.stringify(data, null, 2);
|
|
420
|
+
}
|
|
421
|
+
const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => (r[i] || "").length)));
|
|
422
|
+
return rows.map((r) => r.map((cell, i) => (cell || "").padEnd(widths[i])).join(" ")).join("\n");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ---- main ----
|
|
426
|
+
const { pos, opts } = parseArgs(process.argv.slice(2));
|
|
427
|
+
const command = pos.shift();
|
|
428
|
+
|
|
429
|
+
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
|
+
if (command === "tools") { process.stdout.write(JSON.stringify(toolManifest(), null, 2) + "\n"); process.exit(0); }
|
|
432
|
+
|
|
433
|
+
const spec = COMMANDS[command];
|
|
434
|
+
if (!spec) fail(2, `unknown command: ${command}. Run 'fitvete-food help'.`);
|
|
435
|
+
if (pos.length === 0 && !spec.optionalArgs) fail(2, `usage: fitvete-food ${spec.usage}`);
|
|
436
|
+
|
|
437
|
+
const data = await request(spec, pos, opts);
|
|
438
|
+
process.stdout.write((opts.pretty ? pretty(command, data) : JSON.stringify(data)) + "\n");
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fitvete-food-cli",
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "CLI for the FitVete Food & Nutrition API — clean JSON output for scripts and AI agents.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"fitvete-food": "index.js"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"index.js",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"fitvete",
|
|
18
|
+
"food",
|
|
19
|
+
"nutrition",
|
|
20
|
+
"api",
|
|
21
|
+
"cli",
|
|
22
|
+
"recipes",
|
|
23
|
+
"drinks",
|
|
24
|
+
"macros",
|
|
25
|
+
"ai-agent",
|
|
26
|
+
"mcp"
|
|
27
|
+
],
|
|
28
|
+
"homepage": "https://fitvete.com/api/",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/shimspedy/fitinessx.git",
|
|
32
|
+
"directory": "cli"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/shimspedy/fitinessx/issues",
|
|
36
|
+
"email": "api@fitvete.com"
|
|
37
|
+
},
|
|
38
|
+
"author": "FitVete <api@fitvete.com> (https://fitvete.com/)",
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"license": "MIT"
|
|
43
|
+
}
|