diff --git a/README.md b/README.md index e487a4c..274b7f3 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ secapi config profiles Two binaries are installed: the preferred `secapi` and the compatibility alias `omni-sec`. ```bash -secapi --version # prints the bare package version, e.g. 1.2.0 +secapi --version # prints the bare package version, e.g. 1.3.0 secapi --help # short task-oriented help for common workflows secapi help all # full command inventory secapi examples # local starter workflows for humans and agents diff --git a/package.json b/package.json index ccd1e98..0ca4b6c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@secapi/cli", - "version": "1.2.0", + "version": "1.3.0", "description": "SEC API CLI for SEC data shaped for investors and agents", "type": "module", "bin": { diff --git a/src/index.test.ts b/src/index.test.ts index 1f6a08e..ca55e79 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -134,6 +134,11 @@ beforeAll(() => { headers: { ...responseHeaders, "content-type": "text/csv; charset=utf-8" }, }) } + if (url.pathname.endsWith("/export")) { + return new Response("## Special Situation Brief\n\nSource-cited test brief.\n", { + headers: { ...responseHeaders, "content-type": "text/markdown; charset=utf-8" }, + }) + } if (url.pathname === "/v1/me" && request.headers.get("x-api-key") === "secapi_live_DOCTOR_ECHO_SECRET") { return Response.json({ object: "error", @@ -634,6 +639,7 @@ describe("CLI version and search commands", () => { const result = await runCli(["--version"]) expect(result.status).toBe(0) + expect(packageJson.version).toBe("1.3.0") expect(result.stdout.trim()).toBe(packageJson.version) expect(result.stdout).not.toContain("SEC API CLI") expect(requests).toHaveLength(0) @@ -2715,6 +2721,200 @@ describe("CLI bridge telegram validation (Phase 12)", () => { }) }) +describe("CLI Special Situations commands", () => { + const auth = { SECAPI_API_KEY: "secapi_live_ENV_BACKED_AUTH" } + + test("situations list calls the public REST endpoint with filters and response mode", async () => { + const result = await runCli([ + "situations", "list", + "--types", "merger,tender_offer", + "--statuses", "announced", + "--tickers", "AAPL,MSFT", + "--forms", "SC 13D,DEFM14A", + "--enrich", "false", + "--limit", "10", + "--response-mode", "agent", + ], { env: auth }) + + expect(result.status).toBe(0) + expect(JSON.parse(result.stdout)).toEqual({ ok: true }) + expect(requests).toHaveLength(1) + expect(requests[0]).toMatchObject({ + method: "GET", + path: "/v1/situations", + }) + expect(requests[0]?.headers["x-api-key"]).toBe("secapi_live_ENV_BACKED_AUTH") + expect(requests[0]?.searchParams).toMatchObject({ + types: "merger,tender_offer", + statuses: "announced", + tickers: "AAPL,MSFT", + forms: "SC 13D,DEFM14A", + enrich: "false", + limit: "10", + response_mode: "agent", + }) + assertNoSecretLeak(result.stdout, result.stderr) + }) + + test("situations by-form forwards every public filter", async () => { + const result = await runCli([ + "situations", "by-form", + "--form", "SC 13D", + "--subtypes", "stake_disclosure,proxy_contest", + "--statuses", "announced,pending", + "--tickers", "AAPL,MSFT", + "--sectors", "technology,health_care", + "--market-cap", "mid,large", + "--country", "US", + "--announced-from", "2026-01-01", + "--announced-to", "2026-06-30", + "--updated-from", "2026-07-01", + "--enrich", "false", + "--cursor", "10", + "--limit", "25", + "--response-mode", "agent", + ], { env: auth }) + + expect(result.status).toBe(0) + expect(requests).toHaveLength(1) + expect(requests[0]).toMatchObject({ + method: "GET", + path: "/v1/situations/by-form/SC%2013D", + searchParams: { + subtypes: "stake_disclosure,proxy_contest", + statuses: "announced,pending", + tickers: "AAPL,MSFT", + sectors: "technology,health_care", + market_cap: "mid,large", + country: "US", + announced_from: "2026-01-01", + announced_to: "2026-06-30", + updated_from: "2026-07-01", + enrich: "false", + cursor: "10", + limit: "25", + response_mode: "agent", + }, + }) + }) + + test("situations export prints markdown from the public REST endpoint", async () => { + const result = await runCli(["situations", "export", "--situation-id", "sit_abc123"], { env: auth }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain("## Special Situation Brief") + expect(result.stderr).toBe("") + expect(requests[0]?.path).toBe("/v1/situations/sit_abc123/export") + assertNoSecretLeak(result.stdout, result.stderr) + }) + + test("situations archive and underwriting commands use their public API routes", async () => { + const situationId = "sit_0123456789abcdef0123" + + const archive = await runCli(["situations", "issues", "--limit", "12"], { env: auth }) + const issue = await runCli(["situations", "issue", "--issue", "42"], { env: auth }) + const underwrite = await runCli(["situations", "underwrite", "--situation-id", situationId], { env: auth }) + + expect(archive.status).toBe(0) + expect(issue.status).toBe(0) + expect(underwrite.status).toBe(0) + expect(requests).toHaveLength(3) + expect(requests.map((request) => request.path)).toEqual([ + "/v1/situations/issues", + "/v1/situations/issues/42", + `/v1/situations/${situationId}/underwriting-pack`, + ]) + expect(requests.every((request) => request.headers["x-api-key"] === "secapi_live_ENV_BACKED_AUTH")).toBe(true) + }) + + test("situations watch dry-run previews a public monitor request without calling the API", async () => { + const result = await runCli([ + "situations", "watch", + "--tickers", "AAPL,MSFT", + "--types", "merger", + "--use-org-webhooks", + "--dry-run", + ], { env: auth }) + + expect(result.status).toBe(0) + expect(requests).toHaveLength(0) + const preview = JSON.parse(result.stdout) + expect(preview).toMatchObject({ + object: "secapi_cli_dry_run", + command: "secapi situations watch", + mutates: true, + callsApi: false, + request: { + method: "POST", + path: "/v1/monitors", + }, + }) + expect(preview.request.body).toEqual({ + name: "Special Situations watch", + query: "situations.watch", + searchMode: "situation", + filters: { + types: ["merger"], + tickers: ["AAPL", "MSFT"], + }, + delivery: { type: "webhook", config: { organizationEventFanout: true } }, + }) + assertNoSecretLeak(result.stdout, result.stderr) + }) + + test("situations watch rejects invalid public contract values before fetching", async () => { + const invalidCommands = [ + ["situations", "watch", "--situation-id", "sit_notcanonical", "--use-org-webhooks"], + ["situations", "watch", "--types", "not_a_situation", "--use-org-webhooks"], + ["situations", "watch", "--subtypes", "not_a_subtype", "--use-org-webhooks"], + ["situations", "watch", "--statuses", "active", "--use-org-webhooks"], + ["situations", "watch", "--types", "merger", "--email", " "], + ["situations", "watch", "--types", "merger", "--email", "you@example.com", "--use-org-webhooks"], + ] + + for (const command of invalidCommands) { + const result = await runCli(command, { env: auth }) + expect(result.status).not.toBe(0) + expect(requests).toHaveLength(0) + } + }) + + test("situations watch accepts a canonical situation id", async () => { + const situationId = "sit_0123456789abcdef0123" + const result = await runCli([ + "situations", "watch", + "--situation-id", situationId, + "--email", "you@example.com", + ], { env: auth }) + + expect(result.status).toBe(0) + expect(requests).toHaveLength(1) + expect(JSON.parse(requests[0]?.body ?? "{}")).toMatchObject({ + searchMode: "situation", + filters: { situationIds: [situationId] }, + delivery: { type: "email", config: { to: "you@example.com" } }, + }) + }) + + test("agent-context registers situations commands with accurate mutation and output metadata", async () => { + const result = await runCli(["agent-context"]) + + expect(result.status).toBe(0) + expect(requests).toHaveLength(0) + const payload = JSON.parse(result.stdout) + const situations = payload.commandGroups.find((group: { group: string }) => group.group === "situations") + expect(situations?.commands).toContain("secapi situations list [--types ] [--statuses ] [--tickers ] [--forms ] [--enrich true|false] [--limit ]") + const watch = situations?.details.find((detail: { command: string }) => detail.command === "secapi situations watch") + expect(watch).toMatchObject({ + auth: "api_key", + mutates: true, + requiredFlags: ["--situation-ids|--types|--tickers|--sectors|--statuses|--subtypes", "--email|--use-org-webhooks"], + }) + const exported = situations?.details.find((detail: { command: string }) => detail.command === "secapi situations export") + expect(exported).toMatchObject({ output: "json_or_markdown" }) + }) +}) + describe("CLI monitors and news (Phase 10)", () => { const auth = { SECAPI_API_KEY: "secapi_live_ENV_BACKED_AUTH" } diff --git a/src/index.ts b/src/index.ts index c4134fb..b60e5a3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -715,6 +715,7 @@ const DRY_RUN_MUTATION_COMMANDS = new Set([ "billing portal", "monitors create", "monitors delete", + "situations watch", "streams create", "webhooks create", "webhooks replay-delivery", @@ -995,6 +996,80 @@ function factorResponseParams() { } type MacroCliResponseMode = "compact" | "standard" | "verbose" | "agent" +type SituationCliResponseMode = "compact" | "standard" | "verbose" | "agent" + +// Public Special Situations monitor contract. The installed public SDK does not +// yet export these structured-monitor schemas, so keep this narrow mirror here. +const PUBLIC_SITUATION_TYPES = [ + "merger", "tender_offer", "going_private", "spin_off", "divestiture", + "activist_campaign", "restructuring", "bankruptcy", "liquidation", + "strategic_review", "capital_return", "capital_raise", "spac", "delisting", + "relisting", "litigation", "management_change", "domicile_change", + "demutualization", "other", +] as const +const PUBLIC_SITUATION_SUBTYPES = [ + "definitive", "preliminary", "unsolicited", "rumor_response", "scheme_of_arrangement", "spac_merger", + "self_tender", "third_party", "exchange_offer", "management_buyout", "sponsor_buyout", "squeeze_out", + "spin_off", "split_off", "carve_out_ipo", "asset_sale", "joint_venture", "carve_out", + "stake_disclosure", "proxy_contest", "cooperation_agreement", "settlement", "debt_for_equity_swap", + "out_of_court", "operational", "chapter_11", "chapter_7", "chapter_15", "administration", "prepackaged", + "emergence", "plan_of_liquidation", "dissolution", "formal_alternatives", "sale_process", + "buyback_authorization", "special_dividend", "recapitalization", "rights_offering", "public_offering", + "private_placement", "pipe", "atm_program", "ipo", "extension", "trust_liquidation", "forced", "voluntary", + "uplisting", "otc_relisting", "won", "lost", "settled", "ceo", "cfo", "chair", "board", "redomiciliation", +] as const +const PUBLIC_SITUATION_STATUSES = ["rumored", "announced", "pending", "completed", "terminated", "expired"] as const +const CANONICAL_SITUATION_ID = /^sit_[a-f0-9]{20}$/ +const EMAIL_ADDRESS = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +type PublicSituationWatchFilters = { + situationIds?: string[] + types?: string[] + subtypes?: string[] + statuses?: string[] + tickers?: string[] + sectors?: string[] +} + +function validatePublicSituationWatchFilters(filters: PublicSituationWatchFilters) { + const validateList = (flag: string, values: string[] | undefined, max: number, allowed?: readonly string[]) => { + if (!values) return + if (values.length > max) throw new Error(`${flag} accepts at most ${max} values`) + for (const value of values) { + if (allowed && !allowed.includes(value)) { + throw new Error(`${flag} must contain only: ${allowed.join(", ")}`) + } + } + } + + validateList("--situation-ids", filters.situationIds, 50) + for (const id of filters.situationIds ?? []) { + if (!CANONICAL_SITUATION_ID.test(id)) { + throw new Error("--situation-ids must contain canonical situation ids (sit_ followed by 20 lowercase hexadecimal characters)") + } + } + validateList("--types", filters.types, 50, PUBLIC_SITUATION_TYPES) + validateList("--subtypes", filters.subtypes, 100, PUBLIC_SITUATION_SUBTYPES) + validateList("--statuses", filters.statuses, 10, PUBLIC_SITUATION_STATUSES) + validateList("--tickers", filters.tickers, 200) + validateList("--sectors", filters.sectors, 200) +} + +function getPublicSituationWatchDelivery() { + const hasEmail = hasRawFlag("--email") + const rawEmail = getFlag("--email") + const email = rawEmail?.trim() + const useOrganizationWebhooks = hasFlag("--use-org-webhooks") + if (hasEmail && (!email || email.length > 320 || !EMAIL_ADDRESS.test(email))) { + throw new Error("--email must be a nonempty email address") + } + if (hasEmail === useOrganizationWebhooks) { + throw new Error("Choose exactly one delivery destination: --email
or --use-org-webhooks") + } + return email + ? { type: "email" as const, config: { to: email } } + : { type: "webhook" as const, config: { organizationEventFanout: true as const } } +} function getMacroResponseModeFlag(name = "--response-mode"): MacroCliResponseMode | undefined { const raw = getEnumFlag(name, ["compact", "standard", "verbose", "agent", "default"], "macro response mode") @@ -1025,6 +1100,11 @@ function macroResponseParams() { } } +function getSituationResponseModeFlag(name = "--response-mode"): SituationCliResponseMode | undefined { + const raw = getEnumFlag(name, ["compact", "standard", "verbose", "agent", "default"], "situations response mode") + return raw === "default" ? "standard" : raw +} + function factorKeySelectionParams() { return { keys: getListFlag("--keys") ?? getListFlag("--factors"), @@ -1505,6 +1585,34 @@ function defaultClient(credentials: CliCredentials) { }) } +async function publicRestGet(credentials: CliCredentials, path: string, params: Record = {}) { + const url = new URL(path, `${baseUrl.replace(/\/+$/, "")}/`) + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue + if (Array.isArray(value)) { + if (value.length) url.searchParams.set(key, value.join(",")) + continue + } + url.searchParams.set(key, String(value)) + } + const headers: Record = {} + if (credentials.apiKey) headers["x-api-key"] = credentials.apiKey + if (credentials.bearerToken) headers.authorization = `Bearer ${credentials.bearerToken}` + const response = await captureFetch(credentials.apiKey)(url, { method: "GET", headers }) + const contentType = response.headers.get("content-type") ?? "" + const body = contentType.includes("application/json") ? await response.json() : await response.text() + if (!response.ok) { + const record = asRecord(body) + const message = fieldString(record, "message") ?? fieldString(record, "error") ?? `SEC API request failed with HTTP ${response.status}` + throw new Error(message) + } + return body +} + +function situationsRestGet(credentials: CliCredentials, path: string, params: Record = {}) { + return publicRestGet(credentials, path, params) +} + function humanClient(credentials: CliCredentials) { const bearerToken = credentials.bearerToken if (!bearerToken) { @@ -1842,6 +1950,90 @@ const COMMAND_HELP: Record = { flags: ["--holdings-json ", "--holdings-file ", "--benchmark-label