nutrition-cli 1.0.0__tar.gz

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.
@@ -0,0 +1,20 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ *.egg
8
+ .eggs/
9
+ *.so
10
+ .venv/
11
+ venv/
12
+ env/
13
+ .env
14
+ *.env
15
+ .pytest_cache/
16
+ .mypy_cache/
17
+ .ruff_cache/
18
+ htmlcov/
19
+ .coverage
20
+ *.whl
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javier Ruiz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: nutrition-cli
3
+ Version: 1.0.0
4
+ Summary: CLI tool for nutrition data lookup from USDA and Open Food Facts
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: click>=8.0
9
+ Requires-Dist: requests>=2.28
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest; extra == 'dev'
12
+ Requires-Dist: pytest-mock; extra == 'dev'
13
+ Requires-Dist: responses; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # nutrition-cli
17
+
18
+ CLI tool for nutrition data lookup, powered by [USDA FoodData Central](https://fdc.nal.usda.gov/) and [Open Food Facts](https://world.openfoodfacts.org/). Also ships as an [OpenClaw](https://github.com/openclaw) skill for AI-assisted nutrition queries.
19
+
20
+ ## Install
21
+
22
+ ### Via ClawHub (recommended for OpenClaw users)
23
+
24
+ ```
25
+ claw install nutrition-cli
26
+ ```
27
+
28
+ This installs both the CLI tool and the OpenClaw skill that lets your agent answer nutrition questions automatically.
29
+
30
+ ### Via pip
31
+
32
+ ```
33
+ pip install nutrition-cli
34
+ ```
35
+
36
+ ## Quick start
37
+
38
+ ```bash
39
+ # Look up a food
40
+ nutrition search "chicken breast" --grams 200
41
+
42
+ # Scan a barcode
43
+ nutrition barcode 3017624010701
44
+
45
+ # Compare foods
46
+ nutrition compare "white rice" "brown rice" "quinoa"
47
+
48
+ # Calculate a meal
49
+ nutrition meal "200g chicken breast" "150g rice" "1 avocado" --rda
50
+
51
+ # Calories burned
52
+ nutrition burn running 30 --weight 70
53
+
54
+ # Daily targets
55
+ nutrition daily --sex female --age 25 --weight 60 --activity moderate
56
+ ```
57
+
58
+ ## Commands
59
+
60
+ | Command | Description |
61
+ |---------|-------------|
62
+ | `nutrition search QUERY` | Look up nutrition for any food |
63
+ | `nutrition barcode CODE` | Look up by barcode (EAN-8/13, UPC-A) |
64
+ | `nutrition compare FOOD1 FOOD2 ...` | Side-by-side comparison (2-5 foods) |
65
+ | `nutrition meal "Xg food" ...` | Sum nutrition across a meal |
66
+ | `nutrition burn ACTIVITY MINUTES` | Estimate calories burned |
67
+ | `nutrition daily` | Personalized daily intake targets |
68
+ | `nutrition config set` | Set API key or defaults |
69
+
70
+ All commands support `--format json` for machine-readable output and `--grams` for custom serving sizes.
71
+
72
+ ## Rate limits
73
+
74
+ No API key is required. The tool uses USDA's `DEMO_KEY` (50 requests/day per IP) and Open Food Facts (unlimited, no key needed).
75
+
76
+ If you hit the USDA rate limit, get a free personal key in 30 seconds:
77
+
78
+ 1. Sign up at https://fdc.nal.usda.gov/api-key-signup
79
+ 2. Run: `nutrition config set --usda-key YOUR_KEY`
80
+
81
+ This raises your limit to 1,000 requests/hour.
82
+
83
+ ## Data sources
84
+
85
+ - **USDA FoodData Central** — generic foods (SR Legacy, Foundation) and branded products. US government data, high quality.
86
+ - **Open Food Facts** — community-contributed product database with barcode lookup, Nutri-Score, NOVA classification, allergen data, and vegan/vegetarian status.
87
+
88
+ The CLI prefers USDA SR Legacy/Foundation results for generic food queries and falls back to Open Food Facts automatically.
89
+
90
+ ## License
91
+
92
+ MIT
@@ -0,0 +1,77 @@
1
+ # nutrition-cli
2
+
3
+ CLI tool for nutrition data lookup, powered by [USDA FoodData Central](https://fdc.nal.usda.gov/) and [Open Food Facts](https://world.openfoodfacts.org/). Also ships as an [OpenClaw](https://github.com/openclaw) skill for AI-assisted nutrition queries.
4
+
5
+ ## Install
6
+
7
+ ### Via ClawHub (recommended for OpenClaw users)
8
+
9
+ ```
10
+ claw install nutrition-cli
11
+ ```
12
+
13
+ This installs both the CLI tool and the OpenClaw skill that lets your agent answer nutrition questions automatically.
14
+
15
+ ### Via pip
16
+
17
+ ```
18
+ pip install nutrition-cli
19
+ ```
20
+
21
+ ## Quick start
22
+
23
+ ```bash
24
+ # Look up a food
25
+ nutrition search "chicken breast" --grams 200
26
+
27
+ # Scan a barcode
28
+ nutrition barcode 3017624010701
29
+
30
+ # Compare foods
31
+ nutrition compare "white rice" "brown rice" "quinoa"
32
+
33
+ # Calculate a meal
34
+ nutrition meal "200g chicken breast" "150g rice" "1 avocado" --rda
35
+
36
+ # Calories burned
37
+ nutrition burn running 30 --weight 70
38
+
39
+ # Daily targets
40
+ nutrition daily --sex female --age 25 --weight 60 --activity moderate
41
+ ```
42
+
43
+ ## Commands
44
+
45
+ | Command | Description |
46
+ |---------|-------------|
47
+ | `nutrition search QUERY` | Look up nutrition for any food |
48
+ | `nutrition barcode CODE` | Look up by barcode (EAN-8/13, UPC-A) |
49
+ | `nutrition compare FOOD1 FOOD2 ...` | Side-by-side comparison (2-5 foods) |
50
+ | `nutrition meal "Xg food" ...` | Sum nutrition across a meal |
51
+ | `nutrition burn ACTIVITY MINUTES` | Estimate calories burned |
52
+ | `nutrition daily` | Personalized daily intake targets |
53
+ | `nutrition config set` | Set API key or defaults |
54
+
55
+ All commands support `--format json` for machine-readable output and `--grams` for custom serving sizes.
56
+
57
+ ## Rate limits
58
+
59
+ No API key is required. The tool uses USDA's `DEMO_KEY` (50 requests/day per IP) and Open Food Facts (unlimited, no key needed).
60
+
61
+ If you hit the USDA rate limit, get a free personal key in 30 seconds:
62
+
63
+ 1. Sign up at https://fdc.nal.usda.gov/api-key-signup
64
+ 2. Run: `nutrition config set --usda-key YOUR_KEY`
65
+
66
+ This raises your limit to 1,000 requests/hour.
67
+
68
+ ## Data sources
69
+
70
+ - **USDA FoodData Central** — generic foods (SR Legacy, Foundation) and branded products. US government data, high quality.
71
+ - **Open Food Facts** — community-contributed product database with barcode lookup, Nutri-Score, NOVA classification, allergen data, and vegan/vegetarian status.
72
+
73
+ The CLI prefers USDA SR Legacy/Foundation results for generic food queries and falls back to Open Food Facts automatically.
74
+
75
+ ## License
76
+
77
+ MIT
@@ -0,0 +1 @@
1
+ """nutrition-cli — CLI tool for nutrition data lookup."""
@@ -0,0 +1,276 @@
1
+ """CLI entrypoint for nutrition-cli."""
2
+
3
+ import json
4
+ import re
5
+
6
+ import click
7
+
8
+ from . import config, format, search
9
+ from .rda import MET_TABLE, get_rda
10
+ from .usda import RateLimitError
11
+
12
+
13
+ @click.group()
14
+ def cli():
15
+ """Nutrition data lookup from USDA and Open Food Facts."""
16
+
17
+
18
+ # --- config ---
19
+
20
+
21
+ @cli.group("config")
22
+ def config_cmd():
23
+ """Manage nutrition-cli configuration."""
24
+
25
+
26
+ @config_cmd.command("set")
27
+ @click.option("--usda-key", default=None, help="Personal USDA FoodData Central API key.")
28
+ @click.option("--default-grams", default=None, type=float, help="Default serving size in grams.")
29
+ def config_set(usda_key, default_grams):
30
+ """Save configuration values."""
31
+ if usda_key is None and default_grams is None:
32
+ # Show current config status
33
+ data = config.load_config()
34
+ has_key = "usda_key" in data
35
+ grams = data.get("default_grams", 100)
36
+ click.echo(f" USDA key : {'configured' if has_key else 'not set (using DEMO_KEY)'}")
37
+ click.echo(f" Default g: {grams}")
38
+ return
39
+
40
+ if usda_key is not None:
41
+ config.set("usda_key", usda_key)
42
+ click.echo(" USDA API key saved.")
43
+ if default_grams is not None:
44
+ config.set("default_grams", default_grams)
45
+ click.echo(f" Default serving size set to {default_grams}g.")
46
+
47
+
48
+ # --- search ---
49
+
50
+
51
+ @cli.command("search")
52
+ @click.argument("query")
53
+ @click.option("--grams", "-g", default=None, type=float, help="Serving size in grams.")
54
+ @click.option("--format", "fmt", type=click.Choice(["summary", "json"]), default="summary")
55
+ @click.option("--rda", "show_rda", is_flag=True, help="Show daily intake percentage.")
56
+ @click.option("--sex", type=click.Choice(["male", "female"]), default="male")
57
+ @click.option("--age", type=int, default=30)
58
+ def search_cmd(query, grams, fmt, show_rda, sex, age):
59
+ """Look up nutrition data for a food."""
60
+ if grams is None:
61
+ grams = config.get("default_grams", 100)
62
+
63
+ try:
64
+ result = search.lookup(query, grams)
65
+ except RateLimitError as e:
66
+ click.echo(format.format_error_rate_limit(e.retry_after), err=True)
67
+ raise SystemExit(1)
68
+
69
+ if fmt == "json":
70
+ click.echo(json.dumps(result, indent=2))
71
+ else:
72
+ click.echo(format.nutrition_block(result))
73
+ if show_rda:
74
+ rda = get_rda(sex, age)
75
+ click.echo()
76
+ click.echo(format.rda_progress(result, rda))
77
+
78
+
79
+
80
+ # --- barcode ---
81
+
82
+
83
+ @cli.command()
84
+ @click.argument("barcode")
85
+ @click.option("--grams", "-g", default=None, type=float, help="Serving size in grams.")
86
+ @click.option("--format", "fmt", type=click.Choice(["summary", "json"]), default="summary")
87
+ def barcode(barcode, grams, fmt):
88
+ """Look up a product by barcode (EAN-8/EAN-13/UPC-A)."""
89
+ if grams is None:
90
+ grams = config.get("default_grams", 100)
91
+
92
+ from . import openfoodfacts
93
+ product = openfoodfacts.get_by_barcode(barcode)
94
+ if product is None:
95
+ raise click.ClickException(
96
+ f"Barcode '{barcode}' not found in Open Food Facts. "
97
+ "Try searching by name: nutrition search <name>"
98
+ )
99
+
100
+ result = openfoodfacts.parse_product(product, grams)
101
+
102
+ if fmt == "json":
103
+ click.echo(json.dumps(result, indent=2))
104
+ else:
105
+ click.echo(format.nutrition_block(result))
106
+
107
+
108
+ # --- compare ---
109
+
110
+
111
+ @cli.command()
112
+ @click.argument("foods", nargs=-1, required=True)
113
+ @click.option("--grams", "-g", default=None, type=float, help="Serving size in grams.")
114
+ @click.option("--format", "fmt", type=click.Choice(["summary", "json"]), default="summary")
115
+ def compare(foods, grams, fmt):
116
+ """Compare nutrition data for 2-5 foods."""
117
+ if len(foods) < 2:
118
+ raise click.ClickException("Provide at least 2 foods to compare.")
119
+ if len(foods) > 5:
120
+ raise click.ClickException("Compare supports up to 5 foods.")
121
+
122
+ if grams is None:
123
+ grams = config.get("default_grams", 100)
124
+
125
+ try:
126
+ results = search.lookup_multi(list(foods), grams)
127
+ except RateLimitError as e:
128
+ click.echo(format.format_error_rate_limit(e.retry_after), err=True)
129
+ raise SystemExit(1)
130
+
131
+ if fmt == "json":
132
+ click.echo(json.dumps(results, indent=2))
133
+ else:
134
+ click.echo(format.compare_table(results))
135
+
136
+
137
+ # --- meal ---
138
+
139
+
140
+ def _parse_meal_arg(arg: str, default_grams: float) -> tuple[str, float]:
141
+ """Parse '200g chicken' into (query, grams). Falls back to default_grams."""
142
+ m = re.match(r"^(\d+\.?\d*)\s*g\s+(.+)$", arg, re.IGNORECASE)
143
+ if m:
144
+ return m.group(2), float(m.group(1))
145
+ return arg, default_grams
146
+
147
+
148
+ @cli.command()
149
+ @click.argument("items", nargs=-1, required=True)
150
+ @click.option("--grams", "-g", default=None, type=float, help="Default serving size in grams.")
151
+ @click.option("--rda", "show_rda", is_flag=True, help="Show daily intake percentage.")
152
+ @click.option("--format", "fmt", type=click.Choice(["summary", "json"]), default="summary")
153
+ @click.option("--sex", type=click.Choice(["male", "female"]), default="male")
154
+ @click.option("--age", type=int, default=30)
155
+ def meal(items, grams, show_rda, fmt, sex, age):
156
+ """Calculate nutrition for a full meal. Use '200g chicken' syntax for portions."""
157
+ if grams is None:
158
+ grams = config.get("default_grams", 100)
159
+
160
+ results = []
161
+ errors = []
162
+ for item in items:
163
+ query, item_grams = _parse_meal_arg(item, grams)
164
+ try:
165
+ results.append(search.lookup(query, item_grams))
166
+ except RateLimitError as e:
167
+ click.echo(format.format_error_rate_limit(e.retry_after), err=True)
168
+ raise SystemExit(1)
169
+ except click.ClickException as e:
170
+ errors.append(f" - {query}: {e.format_message()}")
171
+
172
+ if errors:
173
+ click.echo("Some lookups failed:", err=True)
174
+ for err in errors:
175
+ click.echo(err, err=True)
176
+
177
+ if not results:
178
+ raise click.ClickException("No foods could be looked up.")
179
+
180
+ if fmt == "json":
181
+ click.echo(json.dumps(results, indent=2))
182
+ else:
183
+ for r in results:
184
+ click.echo(format.nutrition_block(r))
185
+ click.echo()
186
+ click.echo(format.meal_summary(results))
187
+ if show_rda:
188
+ # Build combined food dict for RDA
189
+ keys = ["kcal", "protein", "fat", "carbs", "fiber"]
190
+ combined = {}
191
+ for key in keys:
192
+ values = [r.get(key) for r in results if r.get(key) is not None]
193
+ combined[key] = round(sum(values), 1) if values else None
194
+ rda = get_rda(sex, age)
195
+ click.echo()
196
+ click.echo(format.rda_progress(combined, rda))
197
+
198
+
199
+ # --- burn ---
200
+
201
+
202
+ @cli.command()
203
+ @click.argument("activity")
204
+ @click.argument("duration", type=float)
205
+ @click.option("--weight", "-w", default=None, type=float, help="Body weight in kg.")
206
+ def burn(activity, duration, weight):
207
+ """Estimate calories burned for an activity."""
208
+ if weight is None:
209
+ weight = config.get("default_weight", 75)
210
+
211
+ activity_lower = activity.lower()
212
+ met = MET_TABLE.get(activity_lower)
213
+ if met is None:
214
+ available = ", ".join(sorted(MET_TABLE.keys()))
215
+ raise click.ClickException(
216
+ f"Unknown activity '{activity}'. Available: {available}"
217
+ )
218
+
219
+ calories = round(met * weight * (duration / 60), 1)
220
+ click.echo(f" {activity.capitalize()} for {duration:.0f} min ({weight}kg)")
221
+ click.echo(f" Estimated burn: {calories} kcal")
222
+
223
+ # Show equivalent in another activity
224
+ walking_met = MET_TABLE["walking"]
225
+ walking_min = round(calories / (walking_met * weight) * 60, 0)
226
+ click.echo(f" Equivalent to {walking_min:.0f} min of walking")
227
+
228
+
229
+ # --- daily ---
230
+
231
+
232
+ @cli.command()
233
+ @click.option("--sex", type=click.Choice(["male", "female"]), default="male")
234
+ @click.option("--age", type=int, default=30)
235
+ @click.option("--weight", "-w", type=float, default=75, help="Body weight in kg.")
236
+ @click.option("--height", type=float, default=175, help="Height in cm.")
237
+ @click.option(
238
+ "--activity",
239
+ type=click.Choice(["sedentary", "light", "moderate", "active"]),
240
+ default="moderate",
241
+ )
242
+ def daily(sex, age, weight, height, activity):
243
+ """Show recommended daily nutrition intake (Harris-Benedict TDEE)."""
244
+ # Harris-Benedict BMR
245
+ if sex == "male":
246
+ bmr = 88.362 + (13.397 * weight) + (4.799 * height) - (5.677 * age)
247
+ else:
248
+ bmr = 447.593 + (9.247 * weight) + (3.098 * height) - (4.330 * age)
249
+
250
+ multipliers = {
251
+ "sedentary": 1.2,
252
+ "light": 1.375,
253
+ "moderate": 1.55,
254
+ "active": 1.725,
255
+ }
256
+ tdee = round(bmr * multipliers[activity])
257
+
258
+ # Macro split: 30% protein, 25% fat, 45% carbs
259
+ protein_g = round(tdee * 0.30 / 4, 1)
260
+ fat_g = round(tdee * 0.25 / 9, 1)
261
+ carbs_g = round(tdee * 0.45 / 4, 1)
262
+
263
+ rda = get_rda(sex, age)
264
+ fiber_g = rda["fiber_g"]
265
+
266
+ click.echo(f" Daily targets ({sex}, age {age}, {weight}kg, {height}cm, {activity}):")
267
+ click.echo(f" {'=' * 40}")
268
+ click.echo(f" Calories : {tdee} kcal")
269
+ click.echo(f" Protein : {protein_g}g")
270
+ click.echo(f" Fat : {fat_g}g")
271
+ click.echo(f" Carbs : {carbs_g}g")
272
+ click.echo(f" Fiber : {fiber_g}g")
273
+
274
+
275
+ if __name__ == "__main__":
276
+ cli()
@@ -0,0 +1,40 @@
1
+ """Persistent config management at ~/.config/nutrition-cli/config.json."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+
7
+ def get_config_path() -> Path:
8
+ """Return config file path, creating parent dirs if missing."""
9
+ path = Path.home() / ".config" / "nutrition-cli" / "config.json"
10
+ path.parent.mkdir(parents=True, exist_ok=True)
11
+ return path
12
+
13
+
14
+ def load_config() -> dict:
15
+ """Read config JSON. Returns {} if file missing or invalid."""
16
+ path = get_config_path()
17
+ if not path.exists():
18
+ return {}
19
+ try:
20
+ return json.loads(path.read_text())
21
+ except (json.JSONDecodeError, OSError):
22
+ return {}
23
+
24
+
25
+ def save_config(data: dict) -> None:
26
+ """Write config dict as JSON with indent=2."""
27
+ path = get_config_path()
28
+ path.write_text(json.dumps(data, indent=2) + "\n")
29
+
30
+
31
+ def get(key: str, default=None):
32
+ """Read one key from config."""
33
+ return load_config().get(key, default)
34
+
35
+
36
+ def set(key: str, value: str) -> None:
37
+ """Load config, update one key, save."""
38
+ data = load_config()
39
+ data[key] = value
40
+ save_config(data)
@@ -0,0 +1,159 @@
1
+ """All output formatting. Returns strings — never prints directly."""
2
+
3
+
4
+ def _val(value, unit="g", width=7) -> str:
5
+ """Format a nutrient value, showing dash for None."""
6
+ if value is None:
7
+ return "\u2014".rjust(width)
8
+ return f"{value}{unit}".rjust(width)
9
+
10
+
11
+ def _source_label(food: dict) -> str:
12
+ """Build source label from food dict."""
13
+ source = food.get("source", "")
14
+ data_type = food.get("data_type", "")
15
+ brand = food.get("brand", "")
16
+ if data_type:
17
+ return f"{source} ({data_type})"
18
+ if brand:
19
+ return f"{source} ({brand})"
20
+ return source
21
+
22
+
23
+ def nutrition_block(food: dict) -> str:
24
+ """Format a single food lookup result."""
25
+ lines = []
26
+ lines.append(f" {food['name']}")
27
+ lines.append(f" Source : {_source_label(food)}")
28
+
29
+ if food.get("nutriscore"):
30
+ lines.append(f" Nutri-Score: {food['nutriscore']}")
31
+ if food.get("nova_group"):
32
+ lines.append(f" NOVA : {food['nova_group']}")
33
+
34
+ lines.append(f" Serving : {food['grams']}g")
35
+ lines.append(" " + "\u2500" * 35)
36
+
37
+ lines.append(f" Calories : {_val(food.get('kcal'), ' kcal')}")
38
+ lines.append(f" Protein : {_val(food.get('protein'))}")
39
+
40
+ fat_str = _val(food.get("fat"))
41
+ sat = food.get("saturated_fat")
42
+ if sat is not None:
43
+ fat_str += f" (sat. {sat}g)"
44
+ lines.append(f" Fat : {fat_str}")
45
+
46
+ lines.append(f" Carbs : {_val(food.get('carbs'))}")
47
+ lines.append(f" Fiber : {_val(food.get('fiber'))}")
48
+ lines.append(f" Sugar : {_val(food.get('sugar'))}")
49
+
50
+ sodium_key = "sodium_mg" if "sodium_mg" in food else "salt_mg"
51
+ sodium_label = "Sodium" if sodium_key == "sodium_mg" else "Salt"
52
+ lines.append(f" {sodium_label:9s}: {_val(food.get(sodium_key), ' mg')}")
53
+
54
+ allergens = food.get("allergens", [])
55
+ if allergens:
56
+ lines.append(f" Allergens: {', '.join(allergens)}")
57
+
58
+ return "\n".join(lines)
59
+
60
+
61
+ def compare_table(foods: list[dict]) -> str:
62
+ """Aligned comparison table for 2-5 foods."""
63
+ label_width = 12
64
+ col_width = 20
65
+
66
+ # Truncate food names
67
+ names = [f["name"][:18] for f in foods]
68
+
69
+ lines = []
70
+ header = " " * label_width + "".join(n.rjust(col_width) for n in names)
71
+ lines.append(header)
72
+ lines.append("\u2500" * (label_width + col_width * len(foods)))
73
+
74
+ rows = [
75
+ ("Calories", "kcal", " kcal"),
76
+ ("Protein", "protein", "g"),
77
+ ("Fat", "fat", "g"),
78
+ ("Carbs", "carbs", "g"),
79
+ ("Fiber", "fiber", "g"),
80
+ ("Sugar", "sugar", "g"),
81
+ ]
82
+
83
+ for label, key, unit in rows:
84
+ row = label.ljust(label_width)
85
+ for f in foods:
86
+ val = f.get(key)
87
+ if val is None:
88
+ row += "\u2014".rjust(col_width)
89
+ else:
90
+ row += f"{val}{unit}".rjust(col_width)
91
+ lines.append(row)
92
+
93
+ return "\n".join(lines)
94
+
95
+
96
+ def meal_summary(foods: list[dict], label: str = "Meal total") -> str:
97
+ """Sum nutrients across foods and format as a totals block."""
98
+ keys = ["kcal", "protein", "fat", "saturated_fat", "carbs", "fiber", "sugar"]
99
+ totals = {}
100
+ total_grams = 0.0
101
+
102
+ for key in keys:
103
+ values = [f.get(key) for f in foods if f.get(key) is not None]
104
+ totals[key] = round(sum(values), 1) if values else None
105
+
106
+ for f in foods:
107
+ total_grams += f.get("grams", 0)
108
+
109
+ total_food = {
110
+ "name": label,
111
+ "source": f"{len(foods)} items",
112
+ "grams": total_grams,
113
+ **totals,
114
+ }
115
+ return nutrition_block(total_food)
116
+
117
+
118
+ def rda_progress(food: dict, rda: dict) -> str:
119
+ """Show percentage of daily recommended intake with ASCII bars."""
120
+ bar_width = 20
121
+ lines = []
122
+
123
+ target_cal = rda["calories"]
124
+ lines.append(f" Daily intake contribution ({target_cal} kcal target):")
125
+
126
+ rows = [
127
+ ("Calories", "kcal", rda["calories"], " kcal"),
128
+ ("Protein", "protein", rda["protein_g"], "g"),
129
+ ("Fat", "fat", rda["fat_g"], "g"),
130
+ ("Carbs", "carbs", rda["carbs_g"], "g"),
131
+ ("Fiber", "fiber", rda["fiber_g"], "g"),
132
+ ]
133
+
134
+ for label, key, daily, unit in rows:
135
+ val = food.get(key)
136
+ if val is None:
137
+ pct = 0.0
138
+ else:
139
+ pct = (val / daily * 100) if daily > 0 else 0.0
140
+
141
+ filled = min(int(pct / 100 * bar_width), bar_width)
142
+ empty = bar_width - filled
143
+ bar = "\u2588" * filled + "\u2591" * empty
144
+
145
+ val_str = f"{val}{unit}" if val is not None else "\u2014"
146
+ lines.append(f" {label:9s} {bar} {val_str:>10s} ({pct:.1f}%)")
147
+
148
+ return "\n".join(lines)
149
+
150
+
151
+ def format_error_rate_limit(retry_after: int) -> str:
152
+ """Format rate limit error with upgrade instructions."""
153
+ minutes = max(1, retry_after // 60)
154
+ return (
155
+ f" Rate limit reached (USDA DEMO_KEY: 50 requests/day).\n"
156
+ f" Try again in {minutes} minutes, or upgrade in 30 seconds:\n"
157
+ f" \u2192 Get a free key: https://fdc.nal.usda.gov/api-key-signup\n"
158
+ f" \u2192 Then run: nutrition config set --usda-key YOUR_KEY"
159
+ )