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.
Files changed (3) hide show
  1. package/README.md +106 -0
  2. package/index.js +438 -0
  3. 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
+ }