diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index cdcd185..309c716 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -4,15 +4,15 @@ "name": "Terminal49" }, "metadata": { - "description": "Terminal49 container-tracking tools and agent skills", - "version": "0.1.0" + "description": "Terminal49 container-tracking and trade-intelligence tools and agent skills", + "version": "0.2.0" }, "plugins": [ { "name": "terminal49", "source": "./plugins/terminal49", - "description": "Use Terminal49 MCP tools to track and investigate container shipments", - "version": "0.1.0" + "description": "Use Terminal49 MCP tools to track and investigate container shipments, and Terminal49 trade intelligence to research US ocean imports", + "version": "0.2.0" } ] } diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index cdcd185..309c716 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -4,15 +4,15 @@ "name": "Terminal49" }, "metadata": { - "description": "Terminal49 container-tracking tools and agent skills", - "version": "0.1.0" + "description": "Terminal49 container-tracking and trade-intelligence tools and agent skills", + "version": "0.2.0" }, "plugins": [ { "name": "terminal49", "source": "./plugins/terminal49", - "description": "Use Terminal49 MCP tools to track and investigate container shipments", - "version": "0.1.0" + "description": "Use Terminal49 MCP tools to track and investigate container shipments, and Terminal49 trade intelligence to research US ocean imports", + "version": "0.2.0" } ] } diff --git a/AGENTS.md b/AGENTS.md index 3229723..c852b7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,11 +12,19 @@ This is a public, cross-platform plugin marketplace for Terminal49. resource identifier. - Do not ask users to paste credentials into chat. Let the client initiate its OAuth connection flow. +- The `trade-intelligence` skill reads a Terminal49 API key that the user saves + in their own terminal (`T49_API_KEY` or `~/.t49_api_key`). Never put a real + key in examples, logs, or chat. ## Shared source of truth -- `plugins/terminal49/skills/container-tracking/SKILL.md` is the shared behavioral - guidance for all clients. +- The skills under `plugins/terminal49/skills/` are the shared behavioral + guidance for all clients: + - `container-tracking/SKILL.md` covers container and shipment questions with + the Terminal49 MCP tools. + - `trade-intelligence/SKILL.md` covers US import market research through the + Terminal49 REST API, using its bundled `scripts/ti.py` client and + `references/api.md`. - `plugins/terminal49/.mcp.json` and `plugins/terminal49/mcp.json` are the MCP adapters for Claude/Codex and Cursor respectively. Keep their server entries identical. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a222061..df4787b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thanks for improving the Terminal49 agent plugins. ## Principles -1. Keep the shared skill client-neutral. +1. Keep the shared skills client-neutral. 2. Prefer tool-selection guidance over duplicating the Terminal49 API docs. 3. Treat `track_container` as a write: search first and create a tracking request only when the user intends to start tracking. @@ -20,4 +20,4 @@ Thanks for improving the Terminal49 agent plugins. 4. Test installation in any client affected by the change. Platform-specific behavior belongs in its adapter. Guidance that applies to all -clients belongs in `plugins/terminal49/skills/container-tracking/SKILL.md`. +clients belongs in the relevant skill under `plugins/terminal49/skills/`. diff --git a/README.md b/README.md index bcef51f..e364786 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # Terminal49 agent plugins -Install Terminal49 container-tracking tools and workflow guidance in Grok Bot, -Cursor, Claude Code, Codex, and GitHub Copilot CLI. +Install Terminal49 container-tracking tools, trade-intelligence research, and +workflow guidance in Grok Bot, Cursor, Claude Code, Codex, and GitHub Copilot +CLI. This marketplace currently publishes one plugin: - **Terminal49** — connects to the hosted Terminal49 MCP server and teaches - agents how to search, track, and investigate container shipments. + agents how to search, track, and investigate container shipments, and how to + research US ocean imports with Terminal49 trade intelligence. ## What the plugin provides @@ -16,9 +18,14 @@ This marketplace currently publishes one plugin: demurrage-risk questions - Guardrails around tracking-request creation, credentials, dates, and missing data +- A `trade-intelligence` skill for competitor research, sourcing, and market + trends from US ocean import bill-of-lading records since 2022 (for accounts + with trade intelligence enabled) -The plugin does not contain API keys or customer data. Authentication happens -through the MCP client's OAuth flow. +The plugin does not contain API keys or customer data. Container tracking +authenticates through the MCP client's OAuth flow. Trade intelligence currently +uses a Terminal49 API key that you save in your own terminal; see the +[plugin README](plugins/terminal49/README.md#trade-intelligence-api-key). ## Install @@ -77,9 +84,12 @@ After connecting your Terminal49 account, ask your agent: - “Is this container ready for pickup, and are there any holds?” - “Explain what changed in this container's journey.” - “Show containers updated since yesterday and group them by status.” +- “Who are the largest US importers of office chairs, and where do they source + them?” The example identifier is illustrative. Results depend on the shipments visible -to the authenticated Terminal49 account. +to the authenticated Terminal49 account. Trade-intelligence questions need an +account with trade intelligence enabled. ## Repository layout @@ -96,10 +106,15 @@ to the authenticated Terminal49 account. ├── mcp.json # Cursor MCP adapter ├── README.md ├── assets/logo.svg # Referenced by the Cursor manifest - └── skills/container-tracking/SKILL.md + └── skills/ + ├── container-tracking/SKILL.md + └── trade-intelligence/ + ├── SKILL.md + ├── references/api.md # Endpoint parameters and fields + └── scripts/ti.py # Python 3 API client, no dependencies ``` -The skill is shared. Each platform-specific manifest and MCP file is a thin +The skills are shared. Each platform-specific manifest and MCP file is a thin adapter around that shared content, with validation keeping the endpoint in sync. diff --git a/plugins/terminal49/.claude-plugin/plugin.json b/plugins/terminal49/.claude-plugin/plugin.json index 6aca362..338860b 100644 --- a/plugins/terminal49/.claude-plugin/plugin.json +++ b/plugins/terminal49/.claude-plugin/plugin.json @@ -2,8 +2,8 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "terminal49", "displayName": "Terminal49", - "version": "0.1.0", - "description": "Track and investigate container shipments with Terminal49 MCP tools and workflow guidance.", + "version": "0.2.0", + "description": "Track and investigate container shipments with Terminal49 MCP tools, and research US ocean import markets with Terminal49 trade intelligence.", "author": { "name": "Terminal49" }, @@ -15,7 +15,8 @@ "logistics", "mcp", "ocean-freight", - "supply-chain" + "supply-chain", + "trade-intelligence" ], "skills": "./skills/", "mcpServers": "./.mcp.json" diff --git a/plugins/terminal49/.codex-plugin/plugin.json b/plugins/terminal49/.codex-plugin/plugin.json index ad7df45..06acdac 100644 --- a/plugins/terminal49/.codex-plugin/plugin.json +++ b/plugins/terminal49/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "terminal49", - "version": "0.1.0", - "description": "Track and investigate container shipments with Terminal49 MCP tools and workflow guidance.", + "version": "0.2.0", + "description": "Track and investigate container shipments with Terminal49 MCP tools, and research US ocean import markets with Terminal49 trade intelligence.", "author": { "name": "Terminal49" }, @@ -13,14 +13,15 @@ "logistics", "mcp", "ocean-freight", - "supply-chain" + "supply-chain", + "trade-intelligence" ], "skills": "./skills/", "mcpServers": "./.mcp.json", "interface": { "displayName": "Terminal49", - "shortDescription": "Track and investigate container shipments", - "longDescription": "Connect to Terminal49 and use guided workflows for container status, pickup readiness, holds, ETAs, routes, delays, and demurrage risk.", + "shortDescription": "Track container shipments and research US imports", + "longDescription": "Connect to Terminal49 and use guided workflows for container status, pickup readiness, holds, ETAs, routes, delays, and demurrage risk, plus trade-intelligence research on US ocean imports: competitors, sourcing, and market trends.", "developerName": "Terminal49", "category": "Productivity", "capabilities": [ @@ -29,7 +30,8 @@ ], "websiteURL": "https://terminal49.com", "defaultPrompt": [ - "Track a container or investigate the latest status of my shipments" + "Track a container or investigate the latest status of my shipments", + "Show what a competitor imports, from where, and how its volume is trending" ] } } diff --git a/plugins/terminal49/.cursor-plugin/plugin.json b/plugins/terminal49/.cursor-plugin/plugin.json index 4c900e9..393609b 100644 --- a/plugins/terminal49/.cursor-plugin/plugin.json +++ b/plugins/terminal49/.cursor-plugin/plugin.json @@ -1,8 +1,8 @@ { "name": "terminal49", "displayName": "Terminal49", - "version": "0.1.0", - "description": "Track and investigate container shipments with Terminal49 MCP tools and workflow guidance.", + "version": "0.2.0", + "description": "Track and investigate container shipments with Terminal49 MCP tools, and research US ocean import markets with Terminal49 trade intelligence.", "author": { "name": "Terminal49" }, @@ -15,7 +15,8 @@ "logistics", "mcp", "ocean-freight", - "supply-chain" + "supply-chain", + "trade-intelligence" ], "category": "Productivity", "skills": "./skills/", diff --git a/plugins/terminal49/README.md b/plugins/terminal49/README.md index 5cbe0e9..454f3be 100644 --- a/plugins/terminal49/README.md +++ b/plugins/terminal49/README.md @@ -1,7 +1,8 @@ # Terminal49 plugin -This plugin connects an agent to Terminal49's hosted MCP server and supplies a -shared skill for reliable container-tracking workflows. It is published from +This plugin connects an agent to Terminal49's hosted MCP server and supplies +shared skills for reliable container-tracking workflows and trade-intelligence +research. It is published from the [Terminal49/agent-plugins](https://github.com/Terminal49/agent-plugins) marketplace — see that README for installation instructions. @@ -11,7 +12,22 @@ The MCP client should open an OAuth authorization flow when Terminal49 tools are first used. Authenticate in the browser and return to the client. Do not paste API keys, access tokens, or one-time codes into chat. -## Included skill +### Trade intelligence API key + +The `trade-intelligence` skill calls the Terminal49 REST API with your +Terminal49 API key and needs Python 3. Create a key at +https://app.terminal49.com/developers/api-keys, then save it in your own +terminal (bash or zsh). Never paste it into chat: + +```sh +printf 'Terminal49 API key: '; read -rs K; echo; (umask 077; touch ~/.t49_api_key; chmod 600 ~/.t49_api_key; printf %s "$K" > ~/.t49_api_key); unset K +``` + +Alternatively, set `T49_API_KEY` in the environment your agent runs in. +Trade intelligence is enabled per account. If requests return HTTP 403, contact +support@terminal49.com. + +## Included skills `container-tracking` is written for logistics operators and covers: @@ -27,11 +43,22 @@ API keys, access tokens, or one-time codes into chat. - querying fleets with filters and pagination - presenting dates, holds, missing information, and paid-feature boundaries +`trade-intelligence` answers market questions from US ocean import +bill-of-lading records since 2022, refreshed daily: + +- competitor research: what a named company imports, from which countries, + through which US ports and carriers, and how its volume is trending +- sourcing: who imports a product and where it comes from +- market trends by port, coast, carrier, origin, or commodity +- caveats that change the answer: forwarders and brokers on the records, one + company under several names, importers who withhold their names, partial + months, and estimated values + The Terminal49 API and MCP implementation live in [Terminal49/API](https://github.com/Terminal49/API). ## Plugin evals -Behavioral eval cases use checked-in MCP mocks and never require the live -Terminal49 service. See [`evals/README.md`](evals/README.md) for validation, +Behavioral eval cases cover `container-tracking`, use checked-in MCP mocks, +and never require the live Terminal49 service. See [`evals/README.md`](evals/README.md) for validation, mocked eval, baseline, and marketplace-submission guidance. diff --git a/plugins/terminal49/skills/trade-intelligence/SKILL.md b/plugins/terminal49/skills/trade-intelligence/SKILL.md new file mode 100644 index 0000000..e557414 --- /dev/null +++ b/plugins/terminal49/skills/trade-intelligence/SKILL.md @@ -0,0 +1,115 @@ +--- +name: trade-intelligence +description: Research US ocean imports with Terminal49 trade intelligence — bill-of-lading records for every US ocean import since 2022, refreshed daily. Use it to see what a competitor or any named company imports (volumes, origin countries, US ports, carriers, products, trend), to find who imports a product and where it's sourced from, and to track market trends by port, coast, carrier, origin, or commodity. Use this whenever someone asks about another company's imports, top importers of a product, sourcing countries or lanes for a product, port or carrier volumes, or how US import volumes are changing — even if they don't say "trade intelligence". +--- + +# Trade intelligence (Terminal49 API) + +Terminal49 trade intelligence covers US ocean imports from bill-of-lading records: every import since 2022, +refreshed daily. This skill answers market questions from it through the Terminal49 REST API. + +The person you're helping is usually in logistics, procurement, or sales at an importer, forwarder, or broker. They +want to understand a competitor, a product's supply chain, or a market well enough to make a decision. Give them the +answer and the few numbers that support it, not a data dump. + +## Setup + +Run `scripts/ti.py ''` (Python 3, no dependencies). It needs a Terminal49 API key in `T49_API_KEY` +or `~/.t49_api_key`. Keys are at https://app.terminal49.com/developers/api-keys. + +Never ask the user to paste an API key, access token, or password into chat, and never print the key. If no key is +set, ask the user to save theirs by running this in their own terminal (bash or zsh): + +```bash +printf 'Terminal49 API key: '; read -rs K; echo; (umask 077; touch ~/.t49_api_key; chmod 600 ~/.t49_api_key; printf %s "$K" > ~/.t49_api_key); unset K +``` + +Trade intelligence is enabled per account. If calls return 403, tell the user their account doesn't have it yet and +that they can contact support@terminal49.com to turn it on; don't keep retrying. Do not invent trade data when calls +fail. + +`references/api.md` has every endpoint's parameters, response fields, filter names, and errors. Read it before your +first call in a conversation. + +## What the data is (and isn't) + +These shape whether an answer is right, so keep them in mind when reading results and explain the relevant ones to +the user: + +- **It covers US ocean imports only**: no air freight, exports, or domestic moves. Canadian ports such as Vancouver + BC appear for US-bound cargo. +- **Volume is physical containers**, each box counted once even if it appears on several bills of lading. TEUs are + available too. +- **Companies are the consignee or notify party on the bill of lading.** Large importers usually appear under their + own name. Forwarders and customs brokers appear as well, so a company's own freight can sit partly under its + forwarder's name, and a "top importers" list can include forwarders. A high `containers_as_notify_party` share + usually means a logistics provider. +- **One company can appear under several names and locations.** A retailer may show up as its distribution + centers and divisions (`DOLLAR TREE DISTRIBUTION`, `DOLLAR TREE MERCHANDISING`, …) and once per US state it imports + into. Search results are ordered by match quality, not size, so look at every row. Profile each name that belongs to + the company (without a state, which combines its locations), add them up, and tell the user which names you + included. +- **Missing volume doesn't mean small.** Importers can ask US Customs to keep their name off shipping manifests, and + much big-retail freight is booked under forwarders or suppliers. If a well-known company shows far less than its + size suggests, say the records are incomplete for it and present the figures as a floor, not as its true volume. +- **Values are estimates in USD**, not declared customs values. Call them "estimated value". +- **Time windows differ by endpoint.** Profiles and rankings use the last N full months; trends and breakdowns + default to windows that include the current, partial month. Always state the period, and use full months when + comparing periods. +- **Don't add up across products or across companies.** A container carrying several products counts once per + product code, and companies can share containers. For market totals use `trends` or `breakdown` without a company + filter. The API states these caveats in `notes`. +- **Shipment-level details aren't available here**: no shipper names or individual bills of lading, only volumes by + company, product, lane, port, and carrier. + +**Speak the user's language, not the API's.** Field names and data mechanics don't belong in the answer. Say +"listed as the party to notify on the shipping documents, which is usually a forwarder or customs broker" rather than +"notify party share", "the company imports under several US locations" rather than "state rows", and never describe +API limits ("the API only returns the top 10"); if a limit affects the answer, say what's missing in plain terms. + +## How to work + +1. **Resolve the company or product first.** Company → `companies/search` with `name`, then use the exact + `company_name` it returns. Product → `commodities/search`; check `common_goods` to confirm the HS4 code is really + the user's product, then use `hs4`. If more than one company or code could be meant, say which you used, or ask + when the choice changes the answer. +2. **Use the endpoint that answers the question directly**: one company → `companies/profile`; "who imports X" → + `importers/top`; "how has X changed" → `trends`; "what's the mix" → `breakdown`. Most answers take 2–5 calls. + - **One company's trend:** `companies/profile` with `months: 24` gives its monthly series for a year-over-year + comparison. Avoid `trends` with `filters.company` for this: it matches every company whose name contains the + text. + - **History goes back to January 2022.** Trends default to the last 24 months; pass `since` for a longer view, and + don't describe the default window as the start of the data. + - **Importer lists don't say who's a forwarder.** When forwarders or brokers matter (they usually do in "who + imports X"), check the top names with one `companies/search` each and use the notify-party share. +3. **Sanity-check before presenting.** A sudden collapse in the latest month is usually a partial month; a company + much smaller than expected may be split across names, shipping under a forwarder's name, or keeping its name off + the records. Carriers come under several spellings (two MSCs, `CMA CGM` and `CMA CGM AMERICA LLC`); merge them + before computing shares. Look again before reporting. +4. **Answer first.** Lead with the takeaway in one to three sentences (caveats come after, not in the lead), then a + compact table. Round (`87k containers`, + `$290M est.`), name the period, and keep caveats to the ones that change the conclusion. + +## Common questions + +### Competitor research: "what does import?" + +Search the name, then `companies/profile` with `months: 24` (no state): the monthly series shows direction against +the same months a year earlier. Cover: volume and trend vs a year earlier; top origin countries; US ports and +destination states; carriers and their shares; main products (HS4 with estimated value); and anything notable, such +as a shift between ports or carriers, a new origin country, or seasonality. If the company looks smaller than the +user expects, mention that some of its freight may be booked under a forwarder. + +### Sourcing: "who imports , and where does it come from?" + +`commodities/search` for the HS4 code, then: +- importers: `importers/top` with `hs4` (add `port_of_discharge` or `origin_country` to narrow); +- origins and lanes: `breakdown` with `dims: ["origin_country", "pod"]` and `filters.hs4`, or `trends` grouped by + `origin_country` to show shifts (e.g. China to Vietnam) over time. + +### Market trends: ports, carriers, lanes, products + +`trends` for change over time (pick `interval` to fit the span: month for a year or less, quarter for two to three +years) and `breakdown` for share at a point in time. Give chart-ready tables (period × series), the filters used, and +a short read of what changed. Use full months, and compare like periods (the same months a year earlier) to avoid +seasonality. diff --git a/plugins/terminal49/skills/trade-intelligence/references/api.md b/plugins/terminal49/skills/trade-intelligence/references/api.md new file mode 100644 index 0000000..c0a6039 --- /dev/null +++ b/plugins/terminal49/skills/trade-intelligence/references/api.md @@ -0,0 +1,143 @@ +# Trade intelligence API reference + +All calls go through `scripts/ti.py ''`, which calls the Terminal49 API at +`https://api.terminal49.com` with the user's Terminal49 API key and prints the JSON. + +## Contents +- Endpoints at a glance +- companies/search +- companies/profile +- commodities/search +- importers/top +- trends +- breakdown +- Filters and dimensions (trends, breakdown) +- Errors + +## Endpoints at a glance + +| Endpoint | Use it to | Speed | +|---|---|---| +| `meta` | See the data window: the 12-month search window, how far back history goes, the partial month | ms | +| `companies/search` | Find importers by (fuzzy) name and/or what they import, or list the largest importers matching filters | ~100 ms | +| `companies/profile` | Everything about one importer: totals, monthly trend, ports, origins, carriers, destinations, HS4 mix | ~100 ms | +| `commodities/search` | Turn a product description into HS4 codes | ~100 ms | +| `importers/top` | Rank importers by containers for an HS4, port, and/or origin | ~100 ms | +| `trends` | Time series (month/quarter/year), split by up to 2 dimensions, any filters | ~100 ms | +| `breakdown` | Nested totals with subtotals (e.g. region > country > port) | ~100 ms | + +Rate limit: about 120 requests a minute per API key. Plenty for a conversation; don't loop over hundreds of companies. + +## companies/search + +```json +{"name": "home depot", "imports": "frozen shrimp", "state": "FL", "port_of_discharge": "Long Beach", + "origin_country": "Vietnam", "min_containers": 100, "limit": 10} +``` +All fields optional; `limit` 1–50 (default 10). `name` is fuzzy (typos and partial names are fine). `imports` is +semantic ("frozen shrimp", "office chairs"). With neither, it lists the largest importers matching the filters. + +Each result: `company_name`, `company_state` (may be null or a Canadian province), `containers` (12 months, as +consignee or notify party), `containers_as_consignee`, `containers_as_notify_party`, `teus`, `estimated_value`, +`reefer_share`, `first_month`, `last_month`, `top_ports`, `top_origins`, `top_carriers`, `top_commodities` (HS4 with +description, value, containers), `score`. The response also carries `index` (same as `meta`). + +**Results are ordered by match score, not size.** The same company appears once per state it imports under +(`HOME DEPOT` in GA, again in FL, again in CA, …). Read the whole list before answering. + +## companies/profile + +```json +{"company_name": "HOME DEPOT", "company_state": "GA", "months": 12} +``` +`company_name` must be the exact string from `companies/search`. Omit `company_state` to combine every state row for +that name (usually what you want for "how much does X import"). `months` 1–60, full calendar months ending last month. + +Returns `since`, `until`, `found`, `totals` (`containers`, `containers_as_consignee`, `containers_as_notify_party`, +`teus`, `states`), `monthly[]`, `ports_of_discharge[]`, `origin_countries[]`, `carriers[]`, `destination_states[]`, +`commodities_hs4[]` (with `estimated_value`), `notes[]`. If `found` is false, the name didn't match exactly: search +again. + +## commodities/search + +```json +{"query": "office chairs", "limit": 10} +``` +`query` is a product description or an HS code prefix ("9401", "94"). Returns HS4 codes with `description`, +`common_goods` (wording seen on bills of lading), 12-month `estimated_value`, number of `companies`, and `score`. +Check `common_goods` to confirm the code really is the user's product: HS descriptions are terse and often truncated. + +## importers/top + +```json +{"hs4": "9401", "port_of_discharge": "savannah", "origin_country": "vietnam", "months": 12, "limit": 20} +``` +Any combination of filters; `limit` 1–100. Ranked by containers; includes `estimated_value` when `hs4` is given. +Each importer: `company_name`, `states[]`, `containers`, `teus`, `estimated_value`. The ranking combines a company's +state rows. It does **not** say whether a company is a consignee or notify party; for that, look the company up in +one `companies/search` call (see "How to work" in `SKILL.md`). + +## trends + +```json +{"measure": "containers", "group_by": ["origin_country"], "filters": {"hs4": "9401"}, + "interval": "quarter", "since": "2024-01", "until": "2026-09", "top": 5} +``` +- `measure`: `containers` (default), `teus`, `estimated_value`. +- `group_by`: up to 2 dimensions; keeps the `top` groups (1–50, default 10) by total over the range. +- `interval`: `month` (default), `quarter`, `year`. +- `since` / `until`: `YYYY-MM`, inclusive. Default: the last 24 months up to the current month, which is partial + and flagged `partial`. **Set `until` to the last full month for any comparison**, or the latest period looks like + a collapse. + +Returns `series[]` of `{period, , value}` plus `fact` (which table answered) and `notes[]`. + +## breakdown + +```json +{"dims": ["origin_region", "origin_country", "pod"], "measure": "containers", "filters": {"hs2": "03"}, + "since": "2025-10", "until": "2026-09", "top": 5} +``` +A rollup over 1–3 nested dimensions, outermost first. `rows[]` holds a grand total (`level: 0`), then subtotals per +level, keeping the top N children under each parent. Default window: the last 12 months **including the partial +current month**; pass `since`/`until` for clean full months. + +## Filters and dimensions (trends, breakdown) + +Dimensions (for `group_by`, `dims`) and filters share names: + +| Name | Meaning | Filter match | +|---|---|---| +| `company`, `company_state` | Importer (consignee/notify party) and its state | company: substring; state: exact | +| `hs4`, `hs2` | Commodity code (`"9401"`, `"94"`) | exact, digits only | +| `carrier`, `scac` | Ocean carrier name / SCAC code | carrier: substring; scac: exact | +| `origin_country`, `origin_region` | Where the goods come from | substring | +| `pol`, `pol_country` | Foreign port of lading and its country | substring | +| `pod`, `pod_coast` | US port of discharge; coast `EAST`, `WEST`, `GULF` | pod: substring; coast: exact | +| `dest_state` | Final US destination state | exact | +| `container_type`, `reefer` | Equipment type; refrigerated (`true`/`false`) | substring; exact | + +**The `company` filter is a substring match.** `{"company": "IKEA"}` also matches `IKEA SUPPLY AG` and any other name +containing IKEA. For one company's own trend, use `companies/profile` with `months: 24` (exact name, monthly series); +use the filter only when you mean every name containing the text. `group_by: ["company"]` has no such problem. + +**Carrier names come in variants**: MSC appears under more than one spelling, `CMA CGM` alongside `CMA CGM AMERICA +LLC`, and COSCO alongside `CHINA OCEAN SHIPPING`. Merge variants of the same carrier before computing shares, and +say you did. + +Names use the data's spelling: China is `PEOPLES REP OF CHINA`, so filter `origin_country: "china"` (substring) +rather than the full name. Ports are upper case (`LOS ANGELES`, `LONG BEACH`, `NEW YORK`, `SAVANNAH`, `HOUSTON`, `VANCOUVER BC`). + +Lane dimensions (`pol`, `pol_country`, `container_type`) can't be combined with company or HS dimensions (as +dimensions or filters) in one query, and `estimated_value` isn't available by them; the API answers 400 with the +reason if you try. + +## Errors + +| Status | Meaning | What to do | +|---|---|---| +| 400 / 422 | The request was wrong; the body says why (e.g. `hs4 must be a 4-digit HS code`) | Fix the request and retry | +| 401 | API key rejected | Ask the user to save a valid key in their own terminal (never in chat) | +| 403 | Trade intelligence isn't enabled for this account | Tell the user to contact support@terminal49.com; don't retry | +| 429 | Rate limit | `ti.py` waits and retries automatically | +| 502 / 504 | Service unavailable or timed out | Retry once, then tell the user | diff --git a/plugins/terminal49/skills/trade-intelligence/scripts/ti.py b/plugins/terminal49/skills/trade-intelligence/scripts/ti.py new file mode 100755 index 0000000..92ce9e9 --- /dev/null +++ b/plugins/terminal49/skills/trade-intelligence/scripts/ti.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Call Terminal49's trade intelligence API and print the JSON response. + +Usage: + ti.py meta + ti.py companies/search '{"name": "home depot", "limit": 10}' + ti.py trends '{"filters": {"hs4": "9403"}, "group_by": ["origin_country"], "interval": "quarter"}' + +Endpoints: meta, companies/search, companies/profile, commodities/search, importers/top, trends, breakdown. + +Auth: a Terminal49 API key from T49_API_KEY, or the first line of ~/.t49_api_key. The key is never printed. +T49_API_BASE overrides the host (default https://api.terminal49.com). +""" +import json +import os +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +ENDPOINTS = { + "meta": "GET", + "companies/search": "POST", + "companies/profile": "POST", + "commodities/search": "POST", + "importers/top": "POST", + "trends": "POST", + "breakdown": "POST", +} + + +def api_key(): + key = os.environ.get("T49_API_KEY", "").strip() + if not key: + path = Path.home() / ".t49_api_key" + if path.exists(): + key = path.read_text().strip().splitlines()[0].strip() if path.read_text().strip() else "" + if not key: + sys.exit("No API key: set T49_API_KEY or put the key in ~/.t49_api_key (chmod 600).") + return key.removeprefix("Token ").strip() + + +def main(argv): + if len(argv) < 2 or argv[1] in ("-h", "--help") or argv[1].strip("/") not in ENDPOINTS: + sys.exit(__doc__) + endpoint = argv[1].strip("/") + method = ENDPOINTS[endpoint] + body = None + if method == "POST": + try: + body = json.dumps(json.loads(argv[2] if len(argv) > 2 else "{}")).encode() + except json.JSONDecodeError as e: + sys.exit(f"Request body is not valid JSON: {e}") + base = os.environ.get("T49_API_BASE", "https://api.terminal49.com").rstrip("/") + req = urllib.request.Request( + f"{base}/v2/trade_intel/{endpoint}", + data=body, + method=method, + headers={"Authorization": f"Token {api_key()}", "Content-Type": "application/json", "Accept": "application/json"}, + ) + for attempt in range(3): + try: + with urllib.request.urlopen(req, timeout=60) as resp: + print(json.dumps(json.load(resp), indent=1, ensure_ascii=False)) + return 0 + except urllib.error.HTTPError as e: + text = e.read().decode(errors="replace") + if e.code == 429 and attempt < 2: + time.sleep(20) + continue + if e.code in (502, 504) and attempt < 1: + time.sleep(2) + continue + hint = { + 401: "The API key was rejected (wrong, revoked, or from another environment).", + 403: "Trade intelligence is not enabled for this account; contact Terminal49 (support@terminal49.com) to turn it on.", + 429: "Rate limit (about 120 requests a minute); wait a minute and retry.", + 502: "The trade data service is unavailable right now; try again shortly.", + 504: "The request timed out; retry once.", + }.get(e.code, "") + print(f"HTTP {e.code}. {hint}\n{text}", file=sys.stderr) + return 1 + except urllib.error.URLError as e: + print(f"Could not reach {base}: {e.reason}", file=sys.stderr) + return 1 + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 1e46581..3b72399 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -1,4 +1,4 @@ -import { readFile, access } from "node:fs/promises"; +import { readFile, readdir, access } from "node:fs/promises"; import { resolve } from "node:path"; import process from "node:process"; @@ -15,7 +15,9 @@ const paths = { codexPlugin: "plugins/terminal49/.codex-plugin/plugin.json", mcp: "plugins/terminal49/.mcp.json", cursorMcp: "plugins/terminal49/mcp.json", + skillsDir: "plugins/terminal49/skills", skill: "plugins/terminal49/skills/container-tracking/SKILL.md", + tradeSkill: "plugins/terminal49/skills/trade-intelligence/SKILL.md", }; async function readJson(relativePath) { @@ -54,9 +56,19 @@ const [cursorMarketplace, claudeMarketplace, codexMarketplace, cursorPlugin, cla ]); await Promise.all( - ["README.md", "LICENSE", "AGENTS.md", "CONTRIBUTING.md", "plugins/terminal49/README.md", paths.skill].map(requireFile), + ["README.md", "LICENSE", "AGENTS.md", "CONTRIBUTING.md", "plugins/terminal49/README.md", paths.skill, paths.tradeSkill].map( + requireFile, + ), ); +const skillNames = await readdir(resolve(root, paths.skillsDir), { withFileTypes: true }) + .then((entries) => entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)) + .catch((error) => { + errors.push(`${paths.skillsDir}: ${error.message}`); + return []; + }); +const skillPaths = skillNames.map((name) => `${paths.skillsDir}/${name}/SKILL.md`); + const plugins = [cursorPlugin, claudePlugin, codexPlugin].filter(Boolean); const pluginVersion = claudePlugin?.version ?? cursorPlugin?.version ?? codexPlugin?.version; const names = new Set(plugins.map((plugin) => plugin.name)); @@ -142,7 +154,7 @@ if (JSON.stringify(cursorMcp) !== JSON.stringify(mcp)) { if (cursorPlugin?.logo) await requireFile(`plugins/terminal49/${cursorPlugin.logo}`); -for (const docPath of ["README.md", "CONTRIBUTING.md", "AGENTS.md", "plugins/terminal49/README.md", paths.skill]) { +for (const docPath of ["README.md", "CONTRIBUTING.md", "AGENTS.md", "plugins/terminal49/README.md", ...skillPaths]) { try { const text = await readFile(resolve(root, docPath), "utf8"); const suffixed = text.match(/mcp\.terminal49\.com\/[^\s)`"']*/); @@ -154,17 +166,40 @@ for (const docPath of ["README.md", "CONTRIBUTING.md", "AGENTS.md", "plugins/ter } } +for (const [index, name] of skillNames.entries()) { + const skillPath = skillPaths[index]; + try { + const skill = await readFile(resolve(root, skillPath), "utf8"); + if (!skill.startsWith("---\n")) errors.push(`${skillPath} must start with YAML frontmatter`); + if (skill.match(/^name: (.+)$/m)?.[1] !== name) errors.push(`${skillPath} name must be ${name}`); + if (!/^description: .+$/m.test(skill)) errors.push(`${skillPath} must have a description`); + const bundled = new Set(Array.from(skill.matchAll(/`((?:references|scripts)\/[^`\s]+)/g), (match) => match[1])); + await Promise.all([...bundled].map((file) => requireFile(`${paths.skillsDir}/${name}/${file}`))); + } catch (error) { + errors.push(`${skillPath}: ${error.message}`); + } +} + try { const skill = await readFile(resolve(root, paths.skill), "utf8"); - if (!skill.startsWith("---\n")) errors.push("skill must start with YAML frontmatter"); - if (!/^name: container-tracking$/m.test(skill)) errors.push("skill name must be container-tracking"); - if (!/^description: .+$/m.test(skill)) errors.push("skill must have a description"); if (!skill.includes("search_container")) errors.push("skill must explain search_container"); if (!skill.includes("track_container")) errors.push("skill must explain track_container"); } catch (error) { errors.push(`${paths.skill}: ${error.message}`); } +try { + const skill = await readFile(resolve(root, paths.tradeSkill), "utf8"); + if (!/never ask the user to paste an API key/i.test(skill)) { + errors.push("trade-intelligence skill must forbid pasting API keys into chat"); + } + if (!skill.includes("support@terminal49.com")) { + errors.push("trade-intelligence skill must send accounts without access to support@terminal49.com"); + } +} catch (error) { + errors.push(`${paths.tradeSkill}: ${error.message}`); +} + if (errors.length > 0) { console.error("Plugin validation failed:\n"); for (const error of errors) console.error(`- ${error}`);