# @wport/cli

![npm](https://img.shields.io/npm/v/@wport/cli)
![node](https://img.shields.io/node/v/@wport/cli)
![license](https://img.shields.io/npm/l/@wport/cli)

`wport` — terminal interface to the W101 Talent Search Hub public API. Query public job listings from your shell, pipe results to `jq`, scriptable end-to-end. Read-only public job search & view — no login, no employer-side commands yet.

> **Status**: 0.1.x pre-release. The default API base `https://api.wport.me` and the npm scope `@wport` are **not yet publicly committed contracts** and may change before the public 0.1.0 release. Override locally via the `WPORT_API_BASE` env var or `--api <url>`.

## Install

```bash
npm install -g @wport/cli
```

Requires Node.js >= 18.17.

### dev channel(內部測試用)

`@wport/cli@dev` 是預設連 dev 後端(`https://developers.wport.me/v2`)的建置,供內部測試未上 prod 的後端改動:

    npm i -g @wport/cli@dev     # wport → dev 後端
    wport login                 # 直接對 dev 後端做 device flow
    npm i -g @wport/cli@latest  # 換回 prod 版

dev 版憑證獨立存於 `credentials-dev.json`,與 prod 版的 `credentials.json` 互不干擾;`wport doctor` 會印出目前 channel。base URL 仍可用 `--api` / `WPORT_API_BASE` 覆寫。

## Quick start

```bash
# Search jobs
wport jobs search --keyword backend

# View a specific job (enc_id from the search result)
wport jobs view <enc_id>

# Pipe to jq for scripting
wport jobs search --keyword backend --output json | jq '.data[].title'

# ...or skip jq: keep only the fields you need (token-friendly for agents)
wport jobs search --keyword backend --minimal --output json
wport jobs search --keyword backend --fields enc_id,title --output json

# Switch language (affects API response, not CLI strings)
wport jobs search --keyword backend --lang en-US

# Persist your preferences
wport config set locale zh-TW
wport config get

# Point at a local backend for development (default base is https://api.wport.me)
WPORT_API_BASE=http://localhost:3000 wport jobs search --keyword backend
# or per-invocation:
wport jobs search --keyword backend --api http://localhost:3000

# Diagnose your setup (resolved config, connectivity, server quirks)
wport doctor
```

## Enterprise commands

Manage your own company's job postings with an enterprise API key (`wpk_live_...`,
issued offline — contact the BD team). All enterprise commands are isolated under
`wport enterprise` and never touch the public, unauthenticated commands above.

```bash
# One-time: validate and save your key (prompts without echoing; not in shell history)
wport enterprise login

# Check which key is in effect (offline, never calls the API)
wport enterprise whoami

# List / view your company's jobs
wport enterprise jobs list --status published
wport enterprise jobs view <enc_id>

# Create / update / publish jobs (writes are idempotent; destructive ops need --confirm)
wport enterprise jobs create --file new-job.json
wport enterprise jobs publish <enc_id>
wport enterprise jobs copy <enc_id>

# View / update your company profile
wport enterprise company view
wport enterprise company update --file company-patch.json
wport enterprise company logo upload ./logo.png

# `basic` section (via company update) also accepts: representative_name, establishment_date,
# directors (string[]), and their three display toggles is_representative_visible /
# is_establishment_date_visible / is_directors_visible (all default to 0 = hidden when unset).
# is_uniform_number_visible is intentionally NOT supported.

# `descriptions` section (via company update) also accepts video_links (<=3, YouTube/Vimeo URLs).
# Unlike the other three description fields, video_links is optimistic-locked: it is written
# atomically with `description` using an If-Match derived from your latest `company view`
# (handled automatically — no flag needed). Removing any existing video (a non-empty submission
# that drops one, or `video_links: []` to clear all) requires --confirm; a stale revision (409)
# is never auto-retried — re-run `company view` and retry.
wport enterprise company update --file company-video-links.json --confirm

# Replace an entire profile block. This is a hard replacement: items not listed are deleted,
# not soft-deleted; any deletion needs --confirm, whether from a non-empty submission or an
# empty one. Before deleting, save a backup. A view backup cannot be restored as-is after
# deletion because its enc_id values point to rows that no longer exist. Strip enc_id first;
# restore then creates new rows with new enc_id values:
wport enterprise company view --output json > company-backup.json
jq '{items: [.awards[] | del(.enc_id)]}' company-backup.json > awards-restore.json
wport enterprise company replace awards --file awards-restore.json --confirm
wport enterprise company replace pain-points --file pain-points.json --confirm
wport enterprise company replace awards --file awards.json
wport enterprise company replace milestones --file milestones.json
wport enterprise company replace qa-items --file qa-items.json

# Talent pool: browse applicants and reply (no bulk export by design)
wport enterprise talents list --tab applied
wport enterprise talents view <enc_resume_id>
wport enterprise talents respond <enc_resume_id> --subject "Interview invite" --body-file reply.txt

# Recruitment campaigns
wport enterprise campaigns list --status open
wport enterprise campaigns create --file campaign.json
wport enterprise campaigns publish <enc_id>

# API key governance: list your keys, rotate an expired one in place
wport enterprise keys list
wport enterprise keys rotate <enc_id> --reveal

# Monthly quota (all authenticated API calls) + rate-limit ceiling
wport enterprise usage

# Agent / CI friendly: no key on disk
WPORT_API_KEY=wpk_live_... wport enterprise jobs list --minimal --output json

# Remove the saved key
wport enterprise logout
```

Key resolution precedence: `--api-key` flag > `WPORT_API_KEY` env var > saved credentials
(`wport enterprise login`). The key is stored in a separate `credentials.json` with file
mode 600 (owner-only) on macOS/Linux. **On Windows, file permissions are best-effort —
prefer the `WPORT_API_KEY` env var if you need stricter isolation.** The CLI never prints
more than the last 4 characters of a key.

## Personal commands

Manage your own candidate profile data as a job seeker. Sign in once with OAuth
(browser-based device flow — no passwords, no API keys); tokens refresh silently and
can be revoked anytime from any logged-in machine.

```bash
# One-time: opens your browser to authorize this machine (SSH? add --no-browser)
wport login

# Who am I / which devices are signed in / kick a stolen laptop
wport whoami
wport sessions list
wport sessions revoke --all-others

# Resume workflow, agent-first (US-1): schema → fill → validate → create
wport personal resumes schema > schema.json          # machine-readable field docs (offline)
wport personal resumes template --out full.json      # skeleton with example values (offline)
wport personal resumes validate --file full.json     # local check, no API quota spent (offline)
wport personal resumes create --file full.json --output json   # per-section success report

# Read / edit round-trip: view emits enc_id per item; items WITH enc_id get updated,
# items WITHOUT enc_id get appended. Existing items are never deleted implicitly
# (exception: portfolio_links is a declarative bulk upsert).
wport personal resumes view <enc_id> --output json > mine.json
wport personal resumes update <enc_id> --file mine.json

# Housekeeping
wport personal resumes list                          # includes quota (max 6)
wport personal resumes copy <enc_id> --name "英文版"
wport personal resumes publish <enc_id>              # searchable by companies (reversible)
wport personal resumes delete <enc_id> --confirm
wport personal resumes export --out ./backup

# Apply to jobs (visible to the employer; requires --confirm; 20/day)
wport personal apply <job_enc_id> --resume <enc_id> --message "自我介紹…" --confirm
wport personal apply <job_enc_id> --resume <enc_id> --message-file intro.txt --confirm   # or - for stdin
wport personal apply --file batch.json --confirm     # { "applications": [...] }, max 20, partial success

# Sign out (revokes the refresh token server-side, clears local credentials)
wport logout
```

Personal OAuth tokens live in the same `credentials.json` as the enterprise key
(separate sections, mode 600); the two lines never interfere. Personal auth is
OAuth-only by design — there is no personal API key and no `--token` flag.

## Example output

Default `table` output in a TTY (`wport jobs search --keyword backend`):

```text
┌────────────────┬──────────────────────────────────────┬──────────────────────┬────────────────────┬────────────────────┬──────────────┐
│ ENC_ID         │ TITLE                                │ COMPANY              │ LOCATION           │ SALARY             │ UPDATED      │
├────────────────┼──────────────────────────────────────┼──────────────────────┼────────────────────┼────────────────────┼──────────────┤
│ a1B2c3D4e5F6g… │ Senior Backend Engineer (Node.js)    │ Acme Cloud Inc.      │ Taipei City        │ TWD 1.2M–1.8M / yr │ 2026-05-28   │
│ h7I8j9K0l1M2n… │ Backend Developer — Go / gRPC        │ Penguin Labs         │ Remote (TW)        │ Negotiable         │ 2026-05-27   │
└────────────────┴──────────────────────────────────────┴──────────────────────┴────────────────────┴────────────────────┴──────────────┘
Showing page 1/5 (10 of 47 results).  Next: wport jobs search --page 2
```

Same query as `--output json` (auto-selected when piped), trimmed to two fields for agents:

```console
$ wport jobs search --keyword backend --fields enc_id,title --output json | jq '.data[]'
{ "enc_id": "a1B2c3D4e5F6g7", "title": "Senior Backend Engineer (Node.js)" }
{ "enc_id": "h7I8j9K0l1M2n3", "title": "Backend Developer — Go / gRPC" }
```

## Commands

### `wport jobs search [options]`

| Flag | Description |
|---|---|
| `-k, --keyword <text>` | Keyword search (title / company name etc.) |
| `-l, --location <code>` | Area code (repeatable, e.g. `6001001000`) |
| `-c, --category <code>` | Job classification code (repeatable) |
| `-p, --page <n>` | Page number (default 1) |
| `-s, --page-size <n>` | Page size (default 10, max 100) |
| `--json-query <file>` | Advanced: read full query body from a JSON file |
| `--fields <list>` | JSON only: keep only these fields per result (comma-separated dotted paths, e.g. `enc_id,title`) |
| `--minimal` | JSON only: shorthand for `--fields enc_id,title,company_name,area_display,salary_display` |

For query fields not exposed as flags (work_experience_code, remote_mode_codes, salary range, etc.), use `--json-query`. The JSON schema mirrors the server-side `JobSearchDto`.

`--fields` / `--minimal` filter **client-side** — they trim the tokens an LLM has to read, not the bytes over the wire (the server has no field-projection param). They apply to JSON output only; in table mode they're ignored (with a notice). `--fields` and `--minimal` are mutually exclusive.

**Sort order is server-determined**: results are ordered by publish date (descending) for filter searches, or by relevance score for keyword searches. The endpoint does not honour client-supplied `orderBy` / `order` even though they appear in the schema — this CLI deliberately omits the corresponding flags to avoid misleading you.

JSON response shape (paginated, flat):

```json
{
  "success": true,
  "statusCode": 200,
  "message": "...",
  "data": [ { "enc_id": "...", "title": "...", ... } ],
  "currentPage": 1,
  "totalPages": 5,
  "pageSize": 10,
  "totalCount": 47
}
```

### `wport jobs view <enc_id> [options]`

| Flag | Description |
|---|---|
| `--field <path>` | Output a single field as a raw value (dotted paths, e.g. `--field job_info.salary_display`) |
| `--fields <list>` | Output selected fields as a JSON object (comma-separated dotted paths) |
| `--batch` | Read newline-separated enc_ids from stdin and emit one ND-JSON record per job (requires `-` as the enc_id arg) |
| `--concurrency <n>` | Max parallel requests in `--batch` mode (default 5, max 20) |

`--field` and `--fields` are mutually exclusive.

Supports stdin: `echo "<enc_id>" \| wport jobs view -`

**Batch mode** processes many jobs in a single process (avoiding N cold starts) and emits ND-JSON — one `{ enc_id, ok, data?, error? }` record per line, in input order. A single failed enc_id becomes an `ok:false` line instead of aborting the run:

```bash
# search → extract enc_ids → fetch each job's title in one batched call
wport jobs search --keyword backend --fields enc_id --output json \
  | jq -r '.data[].enc_id' \
  | wport jobs view - --batch --field job_info.job_title
```

The response is the nested `JobViewVM`:

```
company_header_info.{ company_name, company_icon_url, enc_company_id, ... }
job_info.{ job_title, area_display, salary_display, experience_display, ... }
job_description                                  # HTML rich text
job_information / recruitment_conditions / benefits / about_company
structured_data                                  # JSON-LD for SEO
```

Table mode prints the highlights from `company_header_info` + `job_info` + the stripped `job_description`. Use `--output json` to get the full structure for scripting.

### `wport config <set|get|path|reset>`

Configurable keys: `locale`, `output`, `timeout_ms`.

Config file location varies by OS (see `wport config path`). Stored with mode `0600`.

> **Removed in 0.1.2**: `api_base_url` is no longer a config key (a persisted mutable base URL is an SSRF / credential-exfil surface). Use the `WPORT_API_BASE` env var or `--api` instead. An existing config that still contains `api_base_url` won't break — the key is ignored with a one-time warning.

### `wport doctor`

Prints a setup diagnostic: resolved configuration (base URL, locale, timeout, config file path), a server connectivity probe, the bundled OpenAPI schema fingerprint, and known server behaviours agents should be aware of (e.g. `orderBy` / `order` are silently ignored). Exits `4` if the server is unreachable, `0` otherwise.

```bash
wport doctor
WPORT_API_BASE=http://localhost:3000 wport doctor   # diagnose a local backend
```

## Environment variables

| Variable | Effect |
|---|---|
| `WPORT_API_BASE` | Override the API base URL (http/https only). Precedence: `--api` flag > `WPORT_API_BASE` > default `https://api.wport.me`. |
| `NO_COLOR` | Disable colored output (standard convention). |

## Global flags

| Flag | Default |
|---|---|
| `--lang <locale>` | config → `zh-TW`. Allowed: `zh-TW`, `en-US`, `vi-VN`, `th-TH`, `id-ID` |
| `--api <url>` | `WPORT_API_BASE` env var → `https://api.wport.me` (production) |
| `--output <fmt>` | `table` (TTY) / `json` (when piped) |
| `--no-color` | auto via `NO_COLOR` env |
| `--timeout <ms>` | 10000 |

## Exit codes

| Code | Meaning |
|---|---|
| `0` | Success |
| `2` | Invalid arguments |
| `3` | Server returned 4xx |
| `4` | Server returned 5xx / network error |
| `5` | Config file corrupted |

## Rate limit notice

The public job search endpoint is throttled to 1200 requests / minute / client IP. For human interactive use this is plenty. **Do not** spin up multi-process scrapers — your IP will be blocked.

## Development

```bash
# from the repo root (npm workspaces monorepo)
npm install
npm run gen:openapi:prod -w @wport/core  # fetch + filter swagger from prod, produces packages/core/src/generated/schema.d.ts
npm run build -w @wport/cli
npm link -w @wport/cli    # so the wport command resolves to this build

wport jobs search --keyword backend
```

Three codegen scripts live in `packages/core/package.json` — run each with `-w @wport/core`, pick whichever matches your setup:

| Script | Source | Output | When to use |
|---|---|---|---|
| `npm run gen:openapi:prod -w @wport/core` | `https://api.wport.me/api/api-doc-json` | `packages/core/src/generated/schema.d.ts` (jobs line) | Default; works from a fresh checkout, no local backend needed |
| `npm run gen:openapi -w @wport/core` | `http://localhost:3000/api/api-doc-json` | `packages/core/src/generated/schema.d.ts` (jobs line) | When you're hacking on the backend locally |
| `npm run gen:openapi:personal -w @wport/core` | repo's frozen `docs/handoff/swagger-wport-personal-api-v1.json` | `packages/core/src/generated/personal-schema.d.ts` (personal line) | Regenerating personal-line types after the frozen spec changes; deterministic, no network needed, and CI enforces this one stays in sync |

All three go through `packages/core/scripts/gen-from-remote.cjs`, which filters the upstream spec down to the endpoints this CLI uses (it ignores unrelated `$ref` breakage elsewhere in the spec). When the CLI starts using a new endpoint, add it to `KEEP_PATHS` (jobs line) or `--keep-prefix` (personal line) in that script.

#### 發 dev channel 到 npm

    npm version prerelease --preid=dev --no-git-tag-version   # 0.9.0 → 0.9.1-dev.0(prerelease 會先進 patch)
    npm run build:dev                                         # 選用:發布前先在本地確認 dev build 行為正常
    # 發布必須帶 WPORT_BUILD_CHANNEL=dev:否則 npm publish 的 prepublishOnly hook 會跑 `npm run build`(prod),
    # 把 dist 覆寫回 prod 產物,結果 @wport/cli@dev 底下發成 prod build(靜默、不報錯)。
    WPORT_BUILD_CHANNEL=dev npm publish --tag dev --otp <code> # 必須顯式帶 --tag dev
    npm view @wport/cli dist-tags                             # 斷言 latest 未被動到

⚠️ npm 預設 tag 是 `latest`,即使版號是 prerelease;漏掉 `--tag dev` 會把 `latest` 指到 dev 版,污染所有 `npm i @wport/cli`。發布後務必用 `npm view dist-tags` 確認 `latest` 沒變。

⚠️ `package.json` 的 `prepublishOnly` 會在 `npm publish` 時自動重跑 `npm run build`(prod build,無 dev channel),覆寫掉上一步 `build:dev` 產出的 dist。真正決定 channel 的是 publish 指令本身的 `WPORT_BUILD_CHANNEL=dev` 前綴 —— 該 env 會被 npm 傳進 prepublishOnly 的子行程,讓重跑的 `npm run build` 也讀到 dev channel。漏掉這個前綴,`@wport/cli@dev` 就會靜默發成連 `api.wport.me` 的 prod build。

## Support

This CLI is in pre-release. Bug reports and feature requests are best-effort and handled directly by the maintainer.

**During the `0.x.y` phase**, please contact:

- **Email**: [yao@wport.me](mailto:yao@wport.me)
- **Response time**: best-effort, typically within a few business days

A public issue tracker will be opened once usage volume justifies it. Until then, email is the only supported channel — please don't open issues on the main repo for CLI-specific concerns yet (the repo is shared with the backend and would create cross-team noise).

## Compatibility

This CLI tracks the `api.wport.me` public job endpoints (`GET /api/jobs/search`, `GET /api/jobs/{encId}/view`).

**Pre-1.0 (0.x.y) — soft compatibility**: During the `0.x.y` phase the upstream API and the CLI may evolve without strict semver guarantees. Backward-incompatible changes can land in any `0.x` minor release. Pin to a specific version (`@wport/cli@0.1.0`) if you script against this CLI in production. Each release pins the server version range it has been tested against (see the bundled `CHANGELOG.md`).

**Post-1.0 (1.x.y and beyond) — firm semver**: The `1.0.0` release will commit to standard semver. Any breaking server-side change that affects the CLI surface will be released as a CLI **major** bump (`2.0.0`, `3.0.0`, ...). Additive changes (new flags, new fields) → minor; bug fixes → patch.

Until `1.0.0` ships, treat this CLI as an early-access tool. Open an issue or contact the maintainer before building production automation on top of it.

## 審查流程(開發端審查 + CI Review Gate)

審查在**開發端本地跑**(本機 Claude,不需任何 CI token/secret):

1. 分支開發完成後跑 `review-pipeline` skill(review → fix → verify 一條龍),
   依 `.claude/skills/code-review-assistant/references/` 的 checklist 審查,
   產出結構化報告到 `docs/code-review/`(frontmatter 含 `reviewed_head`)。
2. findings 修到 `critical: 0` 且 `major: 0`(或 review-verifier 驗到 `status: all_fixed`)。
3. **報告 commit 進分支**再 push、開 PR。

CI 端 `.github/workflows/review-gate.yml`(純 shell、零 secret)守門:
報告存在、`reviewed_head` 之後只動過文件類檔案(否則判過期要求重審)、findings 達標才綠。
docs-only PR 免報告直接放行。`security` label 僅作人工提醒標記,不改變 gate 行為。
