Skip to content

Commit 30059eb

Browse files
m2declaude
andcommitted
Add remote recipe fetching with \getapi update\ command
- New \`getapi update\` subcommand fetches recipes from raw.githubusercontent.com - Recipes are cached locally (platform cache dir / getapi / recipes) - Cached recipes are merged on top of bundled ones at startup (cache wins on conflict) - Bundled recipes remain the offline fallback — no network calls unless user runs update - New providers/index.json manifest lists all recipes with id, file, and version - scripts/generate-index.sh regenerates the index from providers/*.json - CI validates that index.json is up to date on every push Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d7150fd commit 30059eb

10 files changed

Lines changed: 401 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,8 @@ jobs:
4242
fi
4343
done
4444
exit $exit_code
45+
46+
- name: Validate recipe index
47+
run: |
48+
bash scripts/generate-index.sh
49+
git diff --exit-code providers/index.json

CLAUDE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ cargo test
1818
- `docs/` — Documentation site source (GitHub Pages)
1919
- `homebrew/` — Local copy of Homebrew formula (canonical copy lives in `m2de/homebrew-tap`)
2020

21+
## Modifying recipes
22+
23+
When adding, removing, or changing the `version` field of any file in `providers/`, regenerate the index:
24+
25+
```sh
26+
bash scripts/generate-index.sh
27+
```
28+
29+
The CI `validate-recipes` job checks that `providers/index.json` is up to date.
30+
2131
## Release process
2232

2333
1. Update `version` in `Cargo.toml` and all 6 `npm/*/package.json` files

providers/index.json

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
{
2+
"schema_version": "1",
3+
"updated_at": "2026-02-20T09:55:01Z",
4+
"recipes": [
5+
{
6+
"id": "anthropic",
7+
"file": "anthropic.json",
8+
"version": "1.0.0"
9+
},
10+
{
11+
"id": "auth0",
12+
"file": "auth0.json",
13+
"version": "1.0.0"
14+
},
15+
{
16+
"id": "clerk",
17+
"file": "clerk.json",
18+
"version": "1.0.0"
19+
},
20+
{
21+
"id": "cloudflare",
22+
"file": "cloudflare.json",
23+
"version": "1.0.0"
24+
},
25+
{
26+
"id": "discord",
27+
"file": "discord.json",
28+
"version": "1.0.0"
29+
},
30+
{
31+
"id": "firebase",
32+
"file": "firebase.json",
33+
"version": "1.0.0"
34+
},
35+
{
36+
"id": "github",
37+
"file": "github.json",
38+
"version": "1.0.0"
39+
},
40+
{
41+
"id": "google-maps",
42+
"file": "google-maps.json",
43+
"version": "1.0.0"
44+
},
45+
{
46+
"id": "notion",
47+
"file": "notion.json",
48+
"version": "1.0.0"
49+
},
50+
{
51+
"id": "openai",
52+
"file": "openai.json",
53+
"version": "1.0.0"
54+
},
55+
{
56+
"id": "resend",
57+
"file": "resend.json",
58+
"version": "1.0.0"
59+
},
60+
{
61+
"id": "sendgrid",
62+
"file": "sendgrid.json",
63+
"version": "1.0.0"
64+
},
65+
{
66+
"id": "shopify",
67+
"file": "shopify.json",
68+
"version": "1.0.0"
69+
},
70+
{
71+
"id": "slack",
72+
"file": "slack.json",
73+
"version": "1.0.0"
74+
},
75+
{
76+
"id": "spotify",
77+
"file": "spotify.json",
78+
"version": "1.0.0"
79+
},
80+
{
81+
"id": "stripe",
82+
"file": "stripe.json",
83+
"version": "1.0.0"
84+
},
85+
{
86+
"id": "supabase",
87+
"file": "supabase.json",
88+
"version": "1.0.0"
89+
},
90+
{
91+
"id": "twilio",
92+
"file": "twilio.json",
93+
"version": "1.0.0"
94+
},
95+
{
96+
"id": "twitter",
97+
"file": "twitter.json",
98+
"version": "1.0.0"
99+
},
100+
{
101+
"id": "vercel",
102+
"file": "vercel.json",
103+
"version": "1.0.0"
104+
}
105+
]
106+
}

scripts/generate-index.sh

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#!/usr/bin/env bash
2+
# Regenerates providers/index.json from the current providers/*.json files.
3+
# Run this whenever you add, remove, or bump the version of a recipe.
4+
# The updated_at timestamp is only changed when the recipe list or versions change.
5+
set -euo pipefail
6+
7+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8+
PROVIDERS_DIR="$SCRIPT_DIR/../providers"
9+
OUTPUT="$PROVIDERS_DIR/index.json"
10+
11+
python3 - "$PROVIDERS_DIR" "$OUTPUT" <<'PYEOF'
12+
import json, os, sys, glob
13+
from datetime import timezone, datetime
14+
15+
providers_dir = sys.argv[1]
16+
output_path = sys.argv[2]
17+
18+
files = sorted(glob.glob(os.path.join(providers_dir, '*.json')))
19+
20+
recipes = []
21+
for f in files:
22+
basename = os.path.basename(f)
23+
if basename == 'index.json':
24+
continue
25+
with open(f) as fh:
26+
data = json.load(fh)
27+
recipes.append({
28+
'id': data['id'],
29+
'file': basename,
30+
'version': data.get('version', '1.0.0'),
31+
})
32+
33+
# Read existing index to preserve updated_at when nothing changed
34+
existing_recipes = []
35+
existing_updated_at = None
36+
if os.path.exists(output_path):
37+
with open(output_path) as fh:
38+
existing = json.load(fh)
39+
existing_recipes = existing.get('recipes', [])
40+
existing_updated_at = existing.get('updated_at')
41+
42+
if recipes == existing_recipes and existing_updated_at:
43+
updated_at = existing_updated_at
44+
else:
45+
updated_at = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
46+
47+
index = {
48+
'schema_version': '1',
49+
'updated_at': updated_at,
50+
'recipes': recipes,
51+
}
52+
53+
with open(output_path, 'w') as fh:
54+
json.dump(index, fh, indent=2)
55+
fh.write('\n')
56+
57+
print(f"Generated {output_path} with {len(recipes)} recipes.")
58+
PYEOF

src/cli/args.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use clap::{Parser, Subcommand, ValueEnum};
77
getapi walks you through creating API keys for popular platforms like Twitter, \
88
OpenAI, Stripe, and more. It opens the right pages, tells you what to click, \
99
collects your credentials, validates they work, and writes them to .env.\n\n\
10-
Provider recipes are bundled — no network needed to run a setup flow.",
10+
Provider recipes are bundled. Run `getapi update` to fetch the latest recipes.",
1111
long_about = None,
1212
after_help = "\
1313
EXAMPLES:\n \
@@ -90,6 +90,9 @@ pub enum Command {
9090
/// Provider to reset (omit to reset all)
9191
provider: Option<String>,
9292
},
93+
94+
/// Update provider recipes from the remote repository
95+
Update,
9396
}
9497

9598
#[derive(ValueEnum, Clone, Debug, PartialEq)]

src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ pub enum GetapiError {
3737

3838
#[error("HTTP error: {0}")]
3939
Http(#[from] reqwest::Error),
40+
41+
#[error("Failed to fetch remote recipes: {0}")]
42+
RemoteFetch(String),
4043
}
4144

4245
pub type Result<T> = std::result::Result<T, GetapiError>;

src/main.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ fn run(cli: Cli) -> Result<()> {
4040
}
4141

4242
match &cli.command {
43+
Some(Command::Update) => cmd_update(),
4344
Some(Command::List { search, category }) => {
4445
cmd_list(&registry, search.clone(), category.clone())
4546
}
@@ -453,6 +454,23 @@ fn cmd_manifest(registry: &RecipeRegistry, _cli: &Cli) -> Result<()> {
453454
}
454455
}
455456

457+
fn cmd_update() -> Result<()> {
458+
ui::print_info("Fetching latest recipes from the remote repository...");
459+
match recipe::remote::fetch_and_cache_all() {
460+
Ok(recipes) => {
461+
ui::print_success(&format!(
462+
"Updated {} recipes. Run `getapi list` to see available providers.",
463+
recipes.len()
464+
));
465+
Ok(())
466+
}
467+
Err(e) => {
468+
eprintln!("\n {} {}", console::style("Warning:").yellow().bold(), e);
469+
std::process::exit(1);
470+
}
471+
}
472+
}
473+
456474
fn cmd_reset(provider: Option<String>) -> Result<()> {
457475
match provider {
458476
Some(p) => {

src/recipe/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod loader;
22
pub mod registry;
3+
pub mod remote;
34
pub mod template;
45
pub mod types;

src/recipe/registry.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
use std::collections::HashMap;
2+
13
use crate::recipe::loader;
4+
use crate::recipe::remote;
25
use crate::recipe::types::Recipe;
36

47
pub struct RecipeRegistry {
@@ -7,7 +10,9 @@ pub struct RecipeRegistry {
710

811
impl RecipeRegistry {
912
pub fn new() -> Self {
10-
let recipes = loader::load_all_bundled();
13+
let bundled = loader::load_all_bundled();
14+
let cached = remote::load_cached_recipes();
15+
let recipes = merge_recipes(bundled, cached);
1116
Self { recipes }
1217
}
1318

@@ -47,3 +52,17 @@ impl RecipeRegistry {
4752
.collect()
4853
}
4954
}
55+
56+
/// Merge two recipe lists. `overlay` takes precedence when the same `id` appears in both.
57+
fn merge_recipes(base: Vec<Recipe>, overlay: Vec<Recipe>) -> Vec<Recipe> {
58+
let mut map: HashMap<String, Recipe> = HashMap::new();
59+
for recipe in base {
60+
map.insert(recipe.id.clone(), recipe);
61+
}
62+
for recipe in overlay {
63+
map.insert(recipe.id.clone(), recipe);
64+
}
65+
let mut merged: Vec<Recipe> = map.into_values().collect();
66+
merged.sort_by(|a, b| a.id.cmp(&b.id));
67+
merged
68+
}

0 commit comments

Comments
 (0)