* security(skills): drop bare Bash and the portal CLI glob from skill grants A skill's allowed-tools pre-approves the listed tools for the turn that invokes it, even in an untrusted folder. job-application-assistant granted bare Bash, Edit and Write; job-scraper granted bun run on any .agents/skills/*/cli/src/cli.ts. Every Bash command the skills document is already allowlisted per command in .claude/settings.json, so the bare and glob grants bought nothing but exposure. The per-portal grants stay in settings.json, which is where a portal added by /add-portal registers too (Step 5 now says so). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * security(settings): deny exfil-shaped shell and credential reads, always ask on permission-defining files Deny rules block in every permission mode including bypassPermissions and override any allow rule or skill grant; ask rules force a prompt even when a skill's allowed-tools would pre-approve the call. settings.json now ships both: deny for shell that uploads or opens a remote shell (curl data/form/upload flags, wget post, scp, sftp, ssh, nc) and for reads of .env and ~/.ssh, ~/.aws, ~/.gnupg; ask for the files whose content is the permission surface (settings, skill frontmatter, agents, CI, this guard) and for git push. security_guards.py requires each rule to stay present, the same way it requires the personal-data gitignore rules. Prefix rules are tripwires, not walls; SECURITY.md will say so. Also allowlists tools/robots_check.py and the upcoming tools/fetch_page.py so the 403 retry no longer needs a curl grant. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * security(guards): hold skill and command allowed-tools to a reviewed allowlist The guard allowlisted settings.json permissions but never read skill frontmatter, so the narrow settings allowlist sat next to a bare Bash grant it could not see. Check 4 parses every .claude/skills/*/SKILL.md, .agents/skills/*/SKILL.md and .claude/commands/*.md frontmatter with a stdlib parser (scalar, folded, and list forms) and holds each allowed-tools entry to ALLOWED_SKILL_TOOLS; a portal skill may additionally grant exactly its own cli/src/cli.ts, matched by path so no portal name is hardcoded. Bare Bash and mid-pattern wildcards get their own message. On master's frontmatter this check reports two failures. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(tools): add GET-only fetch_page.py for the browser-header retry 09-web-research.md documented the 403 retry as a curl command with browser headers plus an inline python3 -c tag-stripper, which meant the workflow wanted a Bash(curl:*) grant - and curl with a free tail is an upload channel. fetch_page.py is the same retry as one command with a fixed GET argv (URL after --, bounded to 45 s / 5 redirects / 10 MB), http(s) only, robots_check.gate() enforced with no bypass flag, --out refused inside the repo, and --text for the stripped page. urllib fallback when curl is absent. The settings.json grant names the script. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(security): describe the permission surface as a table; route the 403 retry through fetch_page.py SECURITY.md now lists every shipped grant with its location, lifetime and guard, and says plainly what the permission layer does not do: no sandbox on native Windows, prefix rules are tripwires, WebFetch is unrestricted by domain, subagents run with the parent's tools (next change), and Edit/Write stay granted because tailoring a CV is editing files. 09-web-research.md's retry section is the one fetch_page.py command (framework_version 1.1.3 -> 1.1.4); apply, rank and scrape point at it instead of curl. SETUP.md and the README tools tree follow. CHANGELOG gains a Security section under [Unreleased]. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * security(review): ask on command frontmatter, portal skills grant only their CLI, bound the urllib fallback Findings from a fresh-context review of the branch: - A command's frontmatter is a grant too (check 4 already parses it), so Edit(.claude/commands/*.md) joins the required ask rules; without it a steered session could write `allowed-tools: Bash` into apply.md with no prompt and the next /apply would be pre-approved for bare Bash. - SECURITY.md promised a portal skill may grant "its own CLI and nothing else"; the guard now enforces exactly that instead of own-CLI plus the core allowlist. All six shipped portal skills already comply. - fetch_page.py's urllib fallback now matches curl's 5-redirect cap and applies the 10 MB cap after decompression, so the docstring's bounds hold on both paths. - Frontmatter parser: a BOM no longer reads as "missing frontmatter", YAML flow lists split correctly, and a non-UTF-8 file fails with a message instead of a traceback. Module docstring lists check 4. - CHANGELOG no longer claims "no new prompt": compile and aux cleanup, which the removed bare Bash covered in a skill-invoking turn, now prompt, deliberately. "Once per session" wording corrected to per call. - Three upload-shaped deny siblings added (curl -XPOST, curl --request POST, wget --method=POST); /add-portal recon points at fetch_page.py. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
14 KiB
/add-portal - Generate a Job-Portal Search Skill for Your Local Market
You are helping the user build a job-portal search skill for a job board in their market. The repo ships worked examples of the pattern (four Danish portals plus the country-agnostic linkedin-search and freehire-search), and the README invites users elsewhere to build equivalents — this command turns that invitation into a guided workflow: investigate the portal, scaffold the skill from the canonical structure, and test-run a live query before registering anything.
The generator is country-agnostic: it works for any portal in any market and language. The skills it produces are typically market-specific and live in the user's fork (per repo policy, country-specific portal skills are not merged upstream — the generator is the upstream feature, its output is yours).
$ARGUMENTS may contain a subcommand, a portal URL, or nothing.
Follow these steps in order.
Step 0: Parse Arguments
- If
$ARGUMENTScontains--list: use Glob with.agents/skills/*/SKILL.md, print a table of installed portal skills (name, market from the description, data source fromurl-reference.md), and stop. - If
$ARGUMENTScontains a URL: treat it as the portal URL and carry it into Step 1. - Otherwise: start the interview at Step 1.
Step 1: Interview - Portal Basics
Ask the user (skip anything already answered by $ARGUMENTS):
- Portal URL - the job board's public site (e.g.
https://www.seek.com.au,https://www.stepstone.de). - Skill name - kebab-case, suffixed
-search(e.g.seek-search,stepstone-search). Must not collide with an existing folder in.agents/skills/. - Market and language - which country/region the portal covers and what language its postings use. This drives the trigger phrases in
SKILL.md(include local-language terms like the Danish skills do: "ledige stillinger", "jobsøgning"). - A realistic test query - a job title or skill the user would actually search for, used for the live test in Step 4.
Step 2: Investigate the Portal
Do reconnaissance before writing any code. Use WebFetch (or python3 tools/fetch_page.py '<URL>' --text via Bash when WebFetch is refused) on the portal:
-
Find the search URL pattern. Load the portal's search page, run a search in the URL bar mentally or via fetch, and identify: the search endpoint, the query parameter, and any parameters for location, posting age, and pagination. Prefer a JSON API if one backs the site (check for
/api/XHR endpoints in the page source); otherwise plan to parse the HTML results page. -
Fetch one search-results response for the test query and identify the per-result fields: id, title, company, location, posting date, and URL. For HTML, note the class names / attributes that anchor each field. For JSON, note the field paths.
-
Find the detail-page pattern - the URL that returns a single posting's full description, and where the description, deadline, employment type, and apply link live in it.
-
Check access requirements and terms.
- Fetch
robots.txtand check whether the search/detail paths are disallowed. - If the portal requires login/authentication to view listings, stop: this pattern only works on public pages. Tell the user and suggest checking whether the portal has an official API.
- If robots.txt disallows the paths or the portal's terms prohibit automated access, tell the user plainly and let them decide whether to proceed for personal use. If they proceed, the generated
SKILL.mdmust carry a prominent personal-use-only warning (copy the tone oflinkedin-search's "⚠️ Personal use only" section: keep volume low, no commercial or bulk use, own responsibility).
- Fetch
-
Check whether the portal can be reached without a credential. Some portals return usable content only through a third-party fetching service (a paid unlocker/proxy API). This step never overrides Step 2.4: if
robots.txtor the portal's terms disallow access, that is decided there, and a paid fetching service does not change the answer. The credential path exists for portals whoserobots.txtpermits access but whose bot protection blocks ordinary fetches. Where that applies and the test fetch succeeds only through such a service, say so to the user before scaffolding - a portal that bills per query is a different proposition from a free one, and they may prefer to skip it. Note which service and which environment variable; the handling rules are in the portal-skill contract in Step 3.
Record everything you found - endpoints, parameters, field anchors, quirks - you will write it into url-reference.md in Step 3.
Step 3: Scaffold the Skill
Canonical reference: read .agents/skills/linkedin-search/ before generating - it is the zero-dependency worked example of this exact structure. Copy its architecture, not its LinkedIn-specific parsing.
Create .agents/skills/<name>/ with:
<name>/
├── SKILL.md # Skill definition with trigger phrases
├── url-reference.md # Endpoint documentation from Step 2
└── cli/
├── package.json
├── tsconfig.json
├── README.md
├── src/
│ ├── cli.ts # Arg parsing, help text, command dispatch
│ ├── helpers.ts # Fetch with backoff, parsers, error writer
│ └── commands/
│ ├── search.ts
│ └── detail.ts
└── tests/
└── helpers.ts # runCLI + parseJSON test utilities (copy from jobindex-search)
The portal-skill contract (every generated skill MUST honor this)
These conventions are what make portal skills interchangeable for /scrape and for users reading any skill's docs:
- Commands:
searchanddetail <id|url>. - Search flags:
--query/-q,--jobage <days>(posting age; map to the portal's parameter, note in SKILL.md if unsupported),--page <n>(1-indexed),--limit <n>(client-side cap),--format json|table|plain(defaultjson). Add--location/-lif the portal supports location as a parameter; if it only supports location inside the keyword query, document that in SKILL.md the wayjobindex-searchdoes ("include the city in--query"). - JSON output shape:
{ "meta": { "count": ..., "page": ... }, "results": [...] }where each result has at leastid,title,company,location,date,url(missing values arenull, never omitted). - Errors: written to stderr as
{ "error": "...", "code": "..." }, exit code1. Never write errors to stdout. - Fetching: an honest User-Agent that names the tool (
Mozilla/5.0 (compatible; <portal>-cli/1.0), the convention every shipped portal CLI follows) - never a full browser impersonation; if the portal refuses that UA, escalation to browser headers goes through the robots.txt gate in.claude/skills/job-application-assistant/09-web-research.md, not through the CLI's default. Exponential backoff with jitter on 429/5xx (max ~6 retries),""/nullon 404 rather than a crash. - HTML parsing: split the response into per-result chunks and parse each independently, so one malformed card cannot break the rest (see
parseJobCardsinlinkedin-search/cli/src/helpers.ts). - Dependencies: default to zero runtime dependencies (plain
bun+fetch+ regex parsing) likelinkedin-search-bun installshould only pull dev types. Only add a parsing library if the portal's markup genuinely defeats chunked regex parsing, and say so in the README. - Credentials: a skill that needs an API key (Step 2.5) reads it only from an environment variable named
<SERVICE>_API_TOKEN. Never hardcode it, never accept it as a CLI flag (flags leak into shell history and process listings), and never write a real token intourl-reference.md, a README example, or a test fixture. If the variable is unset, exit1with the standard stderr JSON error and codeMISSING_CREDENTIALS, naming the variable to set - never fall through to an unauthenticated request that fails confusingly. The repo.gitignorecovers.env; do not commit one.
File specifics
SKILL.mdfrontmatter:name,version: 1.0.0, adescriptionwritten for skill triggering - it must name the portal, the market, and include trigger phrases in English and the market's language;context: fork;allowed-tools: Bash(bun run .agents/skills/<name>/cli/src/cli.ts *).SKILL.mdbody: what the skill searches, the personal-use warning if Step 2 found terms restrictions, command reference with flags, 4-6 usage examples using the user's market (real cities, realistic roles), output-format table, and a Notes section recording portal quirks found in Step 2. If Step 2.5 found the portal needs a credential, add a Setup section naming the service, the exact environment variable to export, and the fact that every call is billed - stated where the user reads it before running the skill, not after.url-reference.md: the endpoints, parameters table, and response-structure notes from Step 2 - this is the file a future maintainer needs when the portal changes its markup.package.json: name<portal>-cli,"type": "module", scriptsstart,test(bun test --timeout 30000), andtypecheck(tsc --noEmit); dev-only dependencies in the zero-dependency default.tests/: copyrunCLI/parseJSONfromjobindex-search/cli/tests/helpers.ts, then add a small live smoke-test file:searchwith the test query returns exit code 0 and ≥1 result with non-nullid/title/url; a bogus flag or missing required arg exits 1 with a JSON error on stderr.
Step 4: Test-Run a Live Query (MANDATORY)
Never register a portal skill that has not returned real results. Markup assumptions from Step 2 routinely miss quirks that only show up live.
- Install dev types and typecheck:
cd .agents/skills/<name>/cli && bun install && bun run typecheck - Run the live search with the user's test query:
bun run src/cli.ts search -q "<test query>" --limit 5 --format table - Verify the results are real and complete: titles and companies are populated (not empty strings or HTML fragments), URLs resolve to the portal, dates parse. If fields come back null or garbled, fix the parsers in
helpers.tsand re-run. Iterate until clean. - Take one
idfrom the results and rundetail:Verify the description is readable text (entities decoded, tags stripped, paragraph breaks preserved).bun run src/cli.ts detail <id> --format plain - Run the test suite:
bun run test. - Keep volume low during iteration - a handful of requests, not a crawl. If the portal rate-limits you mid-test, back off and tell the user.
Do not proceed to Step 5 until search, detail, and tests all pass.
Step 5: Register
- Ask whether the user wants the new portal added to their
/scrapesearch strategy. If yes:- The portal CLI itself is already picked up automatically by
/scrape(it discovers.agents/skills/*/SKILL.md) — no further wiring is needed for CLI search/detail. - Pre-approval is per portal and deliberate.
/scraperuns the CLI through Bash, and the only standing grants are the per-portal entries in.claude/settings.json. AddBash(bun run .agents/skills/<name>/cli/src/cli.ts:*)topermissions.allowthere and toALLOWED_PERMISSIONSintools/security_guards.pyin the same commit - the guard fails the fork's CI on a settings entry it does not know, which is the review step working as intended. Until both are in place,/scrapeprompts on each call of this portal's CLI; the generatedSKILL.md's ownallowed-toolsline covers only turns that invoke that skill directly. - Optionally add WebSearch/
site:placeholder queries for that board in.claude/skills/job-scraper/search-queries.md(use the[YOUR_JOB_BOARD]style placeholders already there) so the fallback path still covers the board if the CLI is unavailable.
- The portal CLI itself is already picked up automatically by
- Remind the user to add the install line for their own records if they maintain a fork README:
(Skip if the skill is zero-dependency and they don't care about typecheck types.)
cd .agents/skills/<name>/cli && bun install && cd ../../../.. - Note that the skill auto-triggers from its
SKILL.mddescription - no other wiring is needed. - CI coverage is also automatic: the
cli-checksjob discovers every.agents/skills/*/cli/package.json, so the new CLI'stypecheckandtestscripts run on every push to the fork without editing the workflow.
Step 6: Confirm
Present a summary:
Portal skill
<name>generated and verified.
- Files:
.agents/skills/<name>/(SKILL.md, url-reference.md, CLI with tests)- Live test:
search "<test query>"returned results;detailverified on one posting- Data source: ; <personal-use warning noted, if applicable>
Try it:
bun run .agents/skills/<name>/cli/src/cli.ts search -q "<test query>" --format tablePer upstream policy, market-specific skills like this live in your fork rather than being PR'd upstream. If the portal changes its markup later,
url-reference.mdrecords the parsing anchors to update.
Design Principles
- The generator is country-agnostic; its output is market-specific and stays in the user's fork. This matches the repo policy that upstream stays a universal template.
- Investigation before scaffolding: the command never generates parsers from guesses - Step 2 fetches real responses first, and Step 4 verifies against live data before anything is registered.
- The portal-skill contract keeps every generated skill interchangeable with the shipped ones: same commands, same flags, same output shape, same error convention.
- Zero runtime dependencies by default, matching
linkedin-search- a portal skill should run on a fresh clone with nothing butbun. - Access rules are surfaced, not silently bypassed: auth-walled portals are declined, robots.txt/ToS restrictions are reported to the user, and restricted portals get a prominent personal-use-only warning in the generated skill.
- Credentials live in the environment, never in the repo: a generated skill reads its token from an environment variable, fails loudly when it is unset, and never commits it. Per-call cost is disclosed before the skill is generated, not discovered afterwards.