diff --git a/connectors/stilta/.env.example b/connectors/stilta/.env.example new file mode 100644 index 00000000..9d0ce752 --- /dev/null +++ b/connectors/stilta/.env.example @@ -0,0 +1,6 @@ +# Stilta API credentials +# Get an API key from your Stilta account dashboard. +STILTA_API_KEY=your-api-key-here + +# Optional: override the API base URL (defaults to https://api.stilta.com/v1) +# STILTA_BASE_URL=https://api.stilta.com/v1 diff --git a/connectors/stilta/.gitignore b/connectors/stilta/.gitignore new file mode 100644 index 00000000..5441f960 --- /dev/null +++ b/connectors/stilta/.gitignore @@ -0,0 +1,32 @@ +# Dependencies +node_modules/ + +# Build outputs +dist/ +bin/*.js + +# Environment +.env +.env.local +.env.*.local + +# Secrets +.secrets/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS specific +.DS_Store + +# Bun +bun.lockb + +# Logs +*.log + +# Local config (contains tokens) +.connect/ diff --git a/connectors/stilta/.npmrc b/connectors/stilta/.npmrc new file mode 100644 index 00000000..31adfd21 --- /dev/null +++ b/connectors/stilta/.npmrc @@ -0,0 +1 @@ +@hasna:registry=https://registry.npmjs.org/ diff --git a/connectors/stilta/AGENTS.md b/connectors/stilta/AGENTS.md new file mode 100644 index 00000000..abf39015 --- /dev/null +++ b/connectors/stilta/AGENTS.md @@ -0,0 +1,120 @@ +# CLAUDE.md + +This file provides guidance to AI coding agents when working with this repository. + +## Project Overview + +connect-stilta is a TypeScript connector for the Stilta patents / prior-art research API. It provides both a CLI and a programmatic API for searching patents, retrieving patent records, and running prior-art research jobs. Authentication uses an API key with multi-profile configuration support. + +## Build & Run Commands + +```bash +# Install dependencies +bun install + +# Run CLI in development +bun run dev + +# Build for distribution +bun run build + +# Type check +bun run typecheck +``` + +## Code Style + +- TypeScript with strict mode +- ESM modules (`type: module`) +- Async/await for all async operations +- Minimal dependencies: commander, chalk +- Type annotations required everywhere + +## Project Structure + +``` +src/ +├── api/ +│ ├── client.ts # HTTP client with API key authentication +│ └── index.ts # Stilta connector class +├── cli/ +│ └── index.ts # CLI commands +├── types/ +│ └── index.ts # TypeScript types +├── utils/ +│ ├── config.ts # Multi-profile configuration +│ └── output.ts # CLI output formatting +└── index.ts # Library exports +``` + +## API Coverage + +- **Patents**: search patents (`POST /patents/search`), get a patent by id (`GET /patents/{patentId}`) +- **Research Jobs**: list jobs (`GET /research-jobs`), create a job (`POST /research-jobs`), get a job by id (`GET /research-jobs/{jobId}`) +- **Raw**: perform an arbitrary request against any Stilta API path + +## Authentication + +API key authentication. The key is sent in the `Authorization: Bearer ` header. Credentials can be set via: +- Environment variable: `STILTA_API_KEY` +- Profile configuration: `connect-stilta config set-key ` + +The base URL defaults to `https://api.stilta.com/v1` and can be overridden with `STILTA_BASE_URL` or `connect-stilta config set-base-url `. + +## CLI Commands + +### Patents +```bash +connect-stilta patent search --query "wireless charging" --limit 10 +connect-stilta patent search --body '{"query":"solar panel","filters":{"year":2023}}' +connect-stilta patent get +``` + +### Research Jobs +```bash +connect-stilta research-job list --status running +connect-stilta research-job create --type prior-art --query "battery thermal management" +connect-stilta research-job get +``` + +### Raw Requests +```bash +connect-stilta raw /patents/search -X POST --body '{"query":"drone"}' +connect-stilta raw /research-jobs -q '{"limit":5}' +``` + +### Profile & Config +```bash +connect-stilta profile list +connect-stilta profile use +connect-stilta profile create --token --use +connect-stilta profile delete +connect-stilta profile show + +connect-stilta config set-key +connect-stilta config set-base-url +connect-stilta config show +connect-stilta config clear +``` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `STILTA_API_KEY` | Stilta API key (overrides profile) | +| `STILTA_BASE_URL` | Override the API base URL (optional) | + +## Data Storage + +``` +~/.hasna/connectors/connect-stilta/ +├── current_profile # Active profile name +└── profiles/ + ├── default.json # Default profile + └── {name}.json # Named profiles +``` + +## Dependencies + +- commander: CLI framework +- chalk: Terminal styling diff --git a/connectors/stilta/CLAUDE.md b/connectors/stilta/CLAUDE.md new file mode 100644 index 00000000..232bf075 --- /dev/null +++ b/connectors/stilta/CLAUDE.md @@ -0,0 +1,120 @@ +# CLAUDE.md + +This file provides guidance to Claude Code when working with this repository. + +## Project Overview + +connect-stilta is a TypeScript connector for the Stilta patents / prior-art research API. It provides both a CLI and a programmatic API for searching patents, retrieving patent records, and running prior-art research jobs. Authentication uses an API key with multi-profile configuration support. + +## Build & Run Commands + +```bash +# Install dependencies +bun install + +# Run CLI in development +bun run dev + +# Build for distribution +bun run build + +# Type check +bun run typecheck +``` + +## Code Style + +- TypeScript with strict mode +- ESM modules (`type: module`) +- Async/await for all async operations +- Minimal dependencies: commander, chalk +- Type annotations required everywhere + +## Project Structure + +``` +src/ +├── api/ +│ ├── client.ts # HTTP client with API key authentication +│ └── index.ts # Stilta connector class +├── cli/ +│ └── index.ts # CLI commands +├── types/ +│ └── index.ts # TypeScript types +├── utils/ +│ ├── config.ts # Multi-profile configuration +│ └── output.ts # CLI output formatting +└── index.ts # Library exports +``` + +## API Coverage + +- **Patents**: search patents (`POST /patents/search`), get a patent by id (`GET /patents/{patentId}`) +- **Research Jobs**: list jobs (`GET /research-jobs`), create a job (`POST /research-jobs`), get a job by id (`GET /research-jobs/{jobId}`) +- **Raw**: perform an arbitrary request against any Stilta API path + +## Authentication + +API key authentication. The key is sent in the `Authorization: Bearer ` header. Credentials can be set via: +- Environment variable: `STILTA_API_KEY` +- Profile configuration: `connect-stilta config set-key ` + +The base URL defaults to `https://api.stilta.com/v1` and can be overridden with `STILTA_BASE_URL` or `connect-stilta config set-base-url `. + +## CLI Commands + +### Patents +```bash +connect-stilta patent search --query "wireless charging" --limit 10 +connect-stilta patent search --body '{"query":"solar panel","filters":{"year":2023}}' +connect-stilta patent get +``` + +### Research Jobs +```bash +connect-stilta research-job list --status running +connect-stilta research-job create --type prior-art --query "battery thermal management" +connect-stilta research-job get +``` + +### Raw Requests +```bash +connect-stilta raw /patents/search -X POST --body '{"query":"drone"}' +connect-stilta raw /research-jobs -q '{"limit":5}' +``` + +### Profile & Config +```bash +connect-stilta profile list +connect-stilta profile use +connect-stilta profile create --token --use +connect-stilta profile delete +connect-stilta profile show + +connect-stilta config set-key +connect-stilta config set-base-url +connect-stilta config show +connect-stilta config clear +``` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `STILTA_API_KEY` | Stilta API key (overrides profile) | +| `STILTA_BASE_URL` | Override the API base URL (optional) | + +## Data Storage + +``` +~/.hasna/connectors/connect-stilta/ +├── current_profile # Active profile name +└── profiles/ + ├── default.json # Default profile + └── {name}.json # Named profiles +``` + +## Dependencies + +- commander: CLI framework +- chalk: Terminal styling diff --git a/connectors/stilta/GEMINI.md b/connectors/stilta/GEMINI.md new file mode 100644 index 00000000..abf39015 --- /dev/null +++ b/connectors/stilta/GEMINI.md @@ -0,0 +1,120 @@ +# CLAUDE.md + +This file provides guidance to AI coding agents when working with this repository. + +## Project Overview + +connect-stilta is a TypeScript connector for the Stilta patents / prior-art research API. It provides both a CLI and a programmatic API for searching patents, retrieving patent records, and running prior-art research jobs. Authentication uses an API key with multi-profile configuration support. + +## Build & Run Commands + +```bash +# Install dependencies +bun install + +# Run CLI in development +bun run dev + +# Build for distribution +bun run build + +# Type check +bun run typecheck +``` + +## Code Style + +- TypeScript with strict mode +- ESM modules (`type: module`) +- Async/await for all async operations +- Minimal dependencies: commander, chalk +- Type annotations required everywhere + +## Project Structure + +``` +src/ +├── api/ +│ ├── client.ts # HTTP client with API key authentication +│ └── index.ts # Stilta connector class +├── cli/ +│ └── index.ts # CLI commands +├── types/ +│ └── index.ts # TypeScript types +├── utils/ +│ ├── config.ts # Multi-profile configuration +│ └── output.ts # CLI output formatting +└── index.ts # Library exports +``` + +## API Coverage + +- **Patents**: search patents (`POST /patents/search`), get a patent by id (`GET /patents/{patentId}`) +- **Research Jobs**: list jobs (`GET /research-jobs`), create a job (`POST /research-jobs`), get a job by id (`GET /research-jobs/{jobId}`) +- **Raw**: perform an arbitrary request against any Stilta API path + +## Authentication + +API key authentication. The key is sent in the `Authorization: Bearer ` header. Credentials can be set via: +- Environment variable: `STILTA_API_KEY` +- Profile configuration: `connect-stilta config set-key ` + +The base URL defaults to `https://api.stilta.com/v1` and can be overridden with `STILTA_BASE_URL` or `connect-stilta config set-base-url `. + +## CLI Commands + +### Patents +```bash +connect-stilta patent search --query "wireless charging" --limit 10 +connect-stilta patent search --body '{"query":"solar panel","filters":{"year":2023}}' +connect-stilta patent get +``` + +### Research Jobs +```bash +connect-stilta research-job list --status running +connect-stilta research-job create --type prior-art --query "battery thermal management" +connect-stilta research-job get +``` + +### Raw Requests +```bash +connect-stilta raw /patents/search -X POST --body '{"query":"drone"}' +connect-stilta raw /research-jobs -q '{"limit":5}' +``` + +### Profile & Config +```bash +connect-stilta profile list +connect-stilta profile use +connect-stilta profile create --token --use +connect-stilta profile delete +connect-stilta profile show + +connect-stilta config set-key +connect-stilta config set-base-url +connect-stilta config show +connect-stilta config clear +``` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `STILTA_API_KEY` | Stilta API key (overrides profile) | +| `STILTA_BASE_URL` | Override the API base URL (optional) | + +## Data Storage + +``` +~/.hasna/connectors/connect-stilta/ +├── current_profile # Active profile name +└── profiles/ + ├── default.json # Default profile + └── {name}.json # Named profiles +``` + +## Dependencies + +- commander: CLI framework +- chalk: Terminal styling diff --git a/connectors/stilta/README.md b/connectors/stilta/README.md new file mode 100644 index 00000000..76f38038 --- /dev/null +++ b/connectors/stilta/README.md @@ -0,0 +1,121 @@ +# connect-stilta + +Stilta connector CLI - Patent search, research jobs, and prior-art analysis. + +## Installation + +```bash +bun install -g @hasna/connect-stilta +``` + +Or run from source: + +```bash +bun install +bun run dev +``` + +## Quick Start + +```bash +# Set your API key +connect-stilta config set-key YOUR_API_KEY + +# Or use an environment variable +export STILTA_API_KEY=YOUR_API_KEY + +# Search patents +connect-stilta patent search --query "wireless charging" --limit 10 +``` + +## Authentication + +connect-stilta uses API key authentication. The key is sent in the +`Authorization: Bearer ` header on every request. + +Provide the key with either: + +- Environment variable `STILTA_API_KEY` +- Profile config: `connect-stilta config set-key ` + +The base URL defaults to `https://api.stilta.com/v1`. Override it with +`STILTA_BASE_URL` or `connect-stilta config set-base-url `. + +## Commands + +### Patents + +```bash +connect-stilta patent search --query "solar panel" --limit 20 --offset 0 +connect-stilta patent search --body '{"query":"drone","filters":{"year":2023}}' +connect-stilta patent get US1234567B2 +``` + +### Research Jobs + +```bash +connect-stilta research-job list --status running --limit 10 +connect-stilta research-job create --type prior-art --query "battery thermal management" +connect-stilta research-job get job_abc123 +``` + +### Raw Requests + +```bash +connect-stilta raw /patents/search -X POST --body '{"query":"antenna"}' +connect-stilta raw /research-jobs -q '{"limit":5}' +``` + +### Profiles & Config + +```bash +connect-stilta profile create work --token sk-xxx --use +connect-stilta profile use work +connect-stilta -p personal patent search --query "graphene" + +connect-stilta config set-key YOUR_API_KEY +connect-stilta config set-base-url https://api.stilta.com/v1 +connect-stilta config show +connect-stilta config clear +``` + +## Global Options + +| Option | Description | +|--------|-------------| +| `-t, --token ` | API key (overrides config) | +| `-b, --base-url ` | API base URL (overrides config) | +| `-f, --format ` | Output format: `json`, `table`, `pretty` (default `pretty`) | +| `-p, --profile ` | Use a specific profile | + +## Library Usage + +```typescript +import { Stilta } from '@hasna/connect-stilta'; + +const stilta = Stilta.fromEnv(); // reads STILTA_API_KEY / STILTA_BASE_URL + +const results = await stilta.searchPatents({ query: 'wireless charging', limit: 10 }); +const patent = await stilta.getPatent('US1234567B2'); +const job = await stilta.createResearchJob({ type: 'prior-art', query: 'antenna array' }); +``` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `STILTA_API_KEY` | Stilta API key (overrides profile config) | +| `STILTA_BASE_URL` | Override the API base URL (optional) | + +## Development + +```bash +bun install +bun run dev +bun run build +bun run typecheck +``` + +## License + +Apache-2.0 diff --git a/connectors/stilta/package.json b/connectors/stilta/package.json new file mode 100644 index 00000000..26511fa6 --- /dev/null +++ b/connectors/stilta/package.json @@ -0,0 +1,52 @@ +{ + "name": "@hasna/connect-stilta", + "version": "0.1.0", + "description": "Stilta connector CLI - Patent search, research jobs, and prior-art analysis", + "type": "module", + "bin": { + "connect-stilta": "./bin/index.js" + }, + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "bun build ./src/index.ts --outdir ./dist --target bun && bun build ./src/cli/index.ts --outdir ./bin --target bun", + "dev": "bun run ./src/cli/index.ts", + "typecheck": "tsc --noEmit", + "prepublishOnly": "bun run build" + }, + "keywords": [ + "stilta", + "patents", + "ip", + "prior-art", + "research", + "connector", + "cli", + "typescript", + "bun", + "hasna" + ], + "author": "Hasna", + "license": "Apache-2.0", + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5" + }, + "dependencies": { + "commander": "^12.1.0", + "chalk": "^5.3.0" + }, + "engines": { + "bun": ">=1.0.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/hasna/connectors.git" + } +} diff --git a/connectors/stilta/src/api/client.ts b/connectors/stilta/src/api/client.ts new file mode 100644 index 00000000..95fca7b0 --- /dev/null +++ b/connectors/stilta/src/api/client.ts @@ -0,0 +1,113 @@ +import type { StiltaConfig } from '../types'; +import { StiltaApiError } from '../types'; + +export const DEFAULT_BASE_URL = 'https://api.stilta.com/v1'; + +export interface RequestOptions { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + params?: Record; + body?: Record | unknown[] | string; + headers?: Record; +} + +/** + * Minimal HTTP client for the Stilta REST API. + * + * Authentication uses an API key sent as a Bearer credential in the + * `Authorization` header. + */ +export class StiltaClient { + private readonly apiKey: string; + private readonly baseUrl: string; + + constructor(config: StiltaConfig) { + if (!config.apiKey) { + throw new Error('API key is required'); + } + this.apiKey = config.apiKey; + this.baseUrl = (config.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, ''); + } + + private buildUrl(path: string, params?: Record): string { + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + const url = new URL(`${this.baseUrl}${normalizedPath}`); + + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null && value !== '') { + url.searchParams.append(key, String(value)); + } + } + } + + return url.toString(); + } + + async request(path: string, options: RequestOptions = {}): Promise { + const { method = 'GET', params, body, headers = {} } = options; + const url = this.buildUrl(path, params); + + const requestHeaders: Record = { + 'Authorization': `Bearer ${this.apiKey}`, + 'Accept': 'application/json', + ...headers, + }; + + const fetchOptions: RequestInit = { method, headers: requestHeaders }; + + if (body !== undefined && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { + requestHeaders['Content-Type'] = 'application/json'; + fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body); + } + + const response = await fetch(url, fetchOptions); + + if (response.status === 204) { + return {} as T; + } + + let data: unknown; + const contentType = response.headers.get('content-type') || ''; + const text = await response.text(); + + if (contentType.includes('application/json') && text) { + try { + data = JSON.parse(text); + } catch { + data = text; + } + } else { + data = text; + } + + if (!response.ok) { + const errorData = data as { message?: string; error?: string; code?: string } | undefined; + const errorMessage = + errorData?.message || + errorData?.error || + (typeof data === 'object' && data !== null ? JSON.stringify(data) : String(data || response.statusText)); + throw new StiltaApiError(errorMessage, response.status, errorData?.code, data); + } + + return data as T; + } + + async get(path: string, params?: Record): Promise { + return this.request(path, { method: 'GET', params }); + } + + async post( + path: string, + body?: Record | unknown[] | string, + params?: Record, + ): Promise { + return this.request(path, { method: 'POST', body, params }); + } + + getApiKeyPreview(): string { + if (this.apiKey.length > 10) { + return `${this.apiKey.substring(0, 6)}...${this.apiKey.substring(this.apiKey.length - 4)}`; + } + return '***'; + } +} diff --git a/connectors/stilta/src/api/index.ts b/connectors/stilta/src/api/index.ts new file mode 100644 index 00000000..b507c2a8 --- /dev/null +++ b/connectors/stilta/src/api/index.ts @@ -0,0 +1,92 @@ +import type { + StiltaConfig, + Patent, + PatentSearchParams, + PatentSearchResult, + ResearchJob, + ResearchJobListResult, + CreateResearchJobParams, + RawRequestParams, +} from '../types'; +import { StiltaClient } from './client'; + +function encodePathSegment(value: string): string { + return encodeURIComponent(value); +} + +/** + * High-level wrapper around the Stilta patents / prior-art research API. + */ +export class Stilta { + private readonly client: StiltaClient; + + constructor(config: StiltaConfig) { + this.client = new StiltaClient(config); + } + + static fromEnv(): Stilta { + const apiKey = process.env.STILTA_API_KEY; + if (!apiKey) { + throw new Error('STILTA_API_KEY environment variable is required'); + } + const baseUrl = process.env.STILTA_BASE_URL; + return new Stilta({ apiKey, baseUrl }); + } + + getApiKeyPreview(): string { + return this.client.getApiKeyPreview(); + } + + getClient(): StiltaClient { + return this.client; + } + + // ============================================ + // Patents + // ============================================ + + /** Search patents. POST /patents/search */ + async searchPatents(params: PatentSearchParams = {}): Promise { + return this.client.post('/patents/search', params as Record); + } + + /** Get a single patent by id. GET /patents/{patentId} */ + async getPatent(patentId: string): Promise { + return this.client.get(`/patents/${encodePathSegment(patentId)}`); + } + + // ============================================ + // Research Jobs + // ============================================ + + /** List research jobs. GET /research-jobs */ + async listResearchJobs(params?: { + limit?: number; + offset?: number; + status?: string; + }): Promise { + return this.client.get('/research-jobs', params); + } + + /** Create a research job. POST /research-jobs */ + async createResearchJob(params: CreateResearchJobParams): Promise { + return this.client.post('/research-jobs', params as Record); + } + + /** Get a research job by id. GET /research-jobs/{jobId} */ + async getResearchJob(jobId: string): Promise { + return this.client.get(`/research-jobs/${encodePathSegment(jobId)}`); + } + + // ============================================ + // Raw requests + // ============================================ + + /** Perform an arbitrary request against the Stilta API. */ + async rawRequest(params: RawRequestParams): Promise { + const { path, method = 'GET', query, body, headers } = params; + return this.client.request(path, { method, params: query, body, headers }); + } +} + +export { StiltaClient } from './client'; diff --git a/connectors/stilta/src/cli/index.ts b/connectors/stilta/src/cli/index.ts new file mode 100644 index 00000000..d1de19e3 --- /dev/null +++ b/connectors/stilta/src/cli/index.ts @@ -0,0 +1,358 @@ +#!/usr/bin/env bun +import { Command } from 'commander'; +import chalk from 'chalk'; +import { Stilta } from '../api'; +import { + getApiKey, + setApiKey, + getBaseUrl, + setBaseUrl, + clearConfig, + getConfigDir, + setProfileOverride, + getCurrentProfile, + setCurrentProfile, + listProfiles, + createProfile, + deleteProfile, + profileExists, + loadProfile, +} from '../utils/config'; +import type { OutputFormat } from '../utils/output'; +import { success, error, info, print } from '../utils/output'; + +const CONNECTOR_NAME = 'connect-stilta'; +const VERSION = '0.1.0'; + +const program = new Command(); + +program + .name(CONNECTOR_NAME) + .description('Stilta connector - Patent search, research jobs, and prior-art analysis') + .version(VERSION) + .option('-t, --token ', 'API key (overrides config)') + .option('-b, --base-url ', 'API base URL (overrides config)') + .option('-f, --format ', 'Output format (json, table, pretty)', 'pretty') + .option('-p, --profile ', 'Use a specific profile') + .hook('preAction', (thisCommand) => { + const opts = thisCommand.opts(); + if (opts.profile) { + if (!profileExists(opts.profile)) { + error(`Profile "${opts.profile}" does not exist. Create it with "${CONNECTOR_NAME} profile create ${opts.profile}"`); + process.exit(1); + } + setProfileOverride(opts.profile); + } + if (opts.token) { + process.env.STILTA_API_KEY = opts.token; + } + if (opts.baseUrl) { + process.env.STILTA_BASE_URL = opts.baseUrl; + } + }); + +function getFormat(cmd: Command): OutputFormat { + let current: Command | null = cmd; + while (current) { + const format = current.opts().format; + if (format) { + return format as OutputFormat; + } + current = current.parent; + } + return 'pretty'; +} + +function getClient(): Stilta { + const apiKey = getApiKey(); + if (!apiKey) { + error(`No API key configured. Run "${CONNECTOR_NAME} config set-key " or set STILTA_API_KEY environment variable.`); + process.exit(1); + } + return new Stilta({ apiKey, baseUrl: getBaseUrl() }); +} + +function parseJson(value: string, label: string): unknown { + try { + return JSON.parse(value); + } catch { + error(`Invalid JSON for ${label}`); + process.exit(1); + } +} + +// ============================================ +// Profile Commands +// ============================================ +const profileCmd = program + .command('profile') + .description('Manage configuration profiles'); + +profileCmd + .command('list') + .description('List all profiles') + .action(() => { + const profiles = listProfiles(); + const current = getCurrentProfile(); + + if (profiles.length === 0) { + info('No profiles found. Use "profile create " to create one.'); + return; + } + + success('Profiles:'); + profiles.forEach(p => { + const isActive = p === current ? chalk.green(' (active)') : ''; + console.log(` ${p}${isActive}`); + }); + }); + +profileCmd + .command('use ') + .description('Switch to a profile') + .action((name: string) => { + if (!profileExists(name)) { + error(`Profile "${name}" does not exist. Create it with "profile create ${name}"`); + process.exit(1); + } + setCurrentProfile(name); + success(`Switched to profile: ${name}`); + }); + +profileCmd + .command('create ') + .description('Create a new profile') + .option('--token ', 'API key') + .option('--base-url ', 'API base URL') + .option('--use', 'Switch to this profile after creation') + .action((name: string, opts) => { + if (profileExists(name)) { + error(`Profile "${name}" already exists`); + process.exit(1); + } + + createProfile(name, { + apiKey: opts.token, + baseUrl: opts.baseUrl, + }); + success(`Profile "${name}" created`); + + if (opts.use) { + setCurrentProfile(name); + info(`Switched to profile: ${name}`); + } + }); + +profileCmd + .command('delete ') + .description('Delete a profile') + .action((name: string) => { + if (name === 'default') { + error('Cannot delete the default profile'); + process.exit(1); + } + if (deleteProfile(name)) { + success(`Profile "${name}" deleted`); + } else { + error(`Profile "${name}" not found`); + process.exit(1); + } + }); + +profileCmd + .command('show [name]') + .description('Show profile configuration') + .action((name?: string) => { + const profileName = name || getCurrentProfile(); + const config = loadProfile(profileName); + const active = getCurrentProfile(); + + console.log(chalk.bold(`Profile: ${profileName}${profileName === active ? chalk.green(' (active)') : ''}`)); + info(`API Key: ${config.apiKey ? `${config.apiKey.substring(0, 8)}...` : chalk.gray('not set')}`); + info(`Base URL: ${config.baseUrl ? config.baseUrl : chalk.gray('default')}`); + }); + +// ============================================ +// Config Commands +// ============================================ +const configCmd = program + .command('config') + .description('Manage CLI configuration'); + +configCmd + .command('set-key ') + .description('Set API key') + .action((key: string) => { + setApiKey(key); + success(`API key saved to profile: ${getCurrentProfile()}`); + }); + +configCmd + .command('set-base-url ') + .description('Set API base URL') + .action((url: string) => { + setBaseUrl(url); + success(`Base URL saved to profile: ${getCurrentProfile()}`); + }); + +configCmd + .command('show') + .description('Show current configuration') + .action(() => { + const profileName = getCurrentProfile(); + const apiKey = getApiKey(); + + console.log(chalk.bold(`Active Profile: ${profileName}`)); + info(`Config directory: ${getConfigDir()}`); + info(`API Key: ${apiKey ? `${apiKey.substring(0, 8)}...` : chalk.gray('not set')}`); + info(`Base URL: ${getBaseUrl() || chalk.gray('default')}`); + }); + +configCmd + .command('clear') + .description('Clear configuration') + .action(() => { + clearConfig(); + success(`Configuration cleared for profile: ${getCurrentProfile()}`); + }); + +// ============================================ +// Patent Commands +// ============================================ +const patentCmd = program + .command('patent') + .description('Search and retrieve patents'); + +patentCmd + .command('search') + .description('Search patents (POST /patents/search)') + .option('-q, --query ', 'Search query') + .option('-l, --limit ', 'Maximum number of results', (v) => parseInt(v, 10)) + .option('-o, --offset ', 'Result offset for pagination', (v) => parseInt(v, 10)) + .option('--filters ', 'Additional filters as a JSON object') + .option('--body ', 'Full request body as JSON (overrides other options)') + .action(async (opts) => { + try { + const client = getClient(); + const params = opts.body + ? (parseJson(opts.body, '--body') as Record) + : { + ...(opts.query !== undefined ? { query: opts.query } : {}), + ...(opts.limit !== undefined ? { limit: opts.limit } : {}), + ...(opts.offset !== undefined ? { offset: opts.offset } : {}), + ...(opts.filters ? { filters: parseJson(opts.filters, '--filters') as Record } : {}), + }; + const result = await client.searchPatents(params); + print(result, getFormat(patentCmd)); + } catch (err) { + error(String(err)); + process.exit(1); + } + }); + +patentCmd + .command('get ') + .description('Get a patent by ID (GET /patents/{patentId})') + .action(async (patentId: string) => { + try { + const client = getClient(); + const result = await client.getPatent(patentId); + print(result, getFormat(patentCmd)); + } catch (err) { + error(String(err)); + process.exit(1); + } + }); + +// ============================================ +// Research Job Commands +// ============================================ +const jobCmd = program + .command('research-job') + .alias('job') + .description('Manage prior-art / research jobs'); + +jobCmd + .command('list') + .description('List research jobs (GET /research-jobs)') + .option('-l, --limit ', 'Maximum number of results', (v) => parseInt(v, 10)) + .option('-o, --offset ', 'Result offset for pagination', (v) => parseInt(v, 10)) + .option('-s, --status ', 'Filter by status') + .action(async (opts) => { + try { + const client = getClient(); + const result = await client.listResearchJobs({ + limit: opts.limit, + offset: opts.offset, + status: opts.status, + }); + print(result, getFormat(jobCmd)); + } catch (err) { + error(String(err)); + process.exit(1); + } + }); + +jobCmd + .command('create') + .description('Create a research job (POST /research-jobs)') + .option('--type ', 'Research job type (e.g. prior-art)') + .option('-q, --query ', 'Query or subject for the job') + .option('--body ', 'Full request body as JSON (overrides other options)') + .action(async (opts) => { + try { + const client = getClient(); + const params = opts.body + ? (parseJson(opts.body, '--body') as Record) + : { + ...(opts.type !== undefined ? { type: opts.type } : {}), + ...(opts.query !== undefined ? { query: opts.query } : {}), + }; + const result = await client.createResearchJob(params); + print(result, getFormat(jobCmd)); + } catch (err) { + error(String(err)); + process.exit(1); + } + }); + +jobCmd + .command('get ') + .description('Get a research job by ID (GET /research-jobs/{jobId})') + .action(async (jobId: string) => { + try { + const client = getClient(); + const result = await client.getResearchJob(jobId); + print(result, getFormat(jobCmd)); + } catch (err) { + error(String(err)); + process.exit(1); + } + }); + +// ============================================ +// Raw Request Command +// ============================================ +program + .command('raw ') + .description('Perform an arbitrary request against the Stilta API') + .option('-X, --method ', 'HTTP method', 'GET') + .option('-q, --query ', 'Query parameters as a JSON object') + .option('--body ', 'Request body as JSON') + .action(async (path: string, opts, cmd) => { + try { + const client = getClient(); + const result = await client.rawRequest({ + path, + method: String(opts.method).toUpperCase() as 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', + query: opts.query ? (parseJson(opts.query, '--query') as Record) : undefined, + body: opts.body ? (parseJson(opts.body, '--body') as Record) : undefined, + }); + print(result, getFormat(cmd)); + } catch (err) { + error(String(err)); + process.exit(1); + } + }); + +program.parseAsync(process.argv); diff --git a/connectors/stilta/src/index.ts b/connectors/stilta/src/index.ts new file mode 100644 index 00000000..0395877d --- /dev/null +++ b/connectors/stilta/src/index.ts @@ -0,0 +1,24 @@ +// Stilta Connector +// Patent search, research jobs, and prior-art analysis + +export { Stilta, StiltaClient } from './api'; +export { DEFAULT_BASE_URL } from './api/client'; +export * from './types'; + +// Export config utilities +export { + getApiKey, + setApiKey, + getBaseUrl, + setBaseUrl, + getCurrentProfile, + setCurrentProfile, + listProfiles, + createProfile, + deleteProfile, + loadProfile, + saveProfile, + clearConfig, + getConfigDir, + getActiveProfileName, +} from './utils/config'; diff --git a/connectors/stilta/src/types/index.ts b/connectors/stilta/src/types/index.ts new file mode 100644 index 00000000..99fb3095 --- /dev/null +++ b/connectors/stilta/src/types/index.ts @@ -0,0 +1,116 @@ +// Type definitions for the Stilta connector + +export type OutputFormat = 'json' | 'table' | 'pretty'; + +export interface StiltaConfig { + apiKey: string; + baseUrl?: string; +} + +/** + * Error thrown when the Stilta API returns a non-2xx response. + */ +export class StiltaApiError extends Error { + readonly status: number; + readonly code?: string; + readonly details?: unknown; + + constructor(message: string, status: number, code?: string, details?: unknown) { + super(message); + this.name = 'StiltaApiError'; + this.status = status; + this.code = code; + this.details = details; + } +} + +// ============================================ +// Patents +// ============================================ + +export interface Patent { + patentId: string; + title?: string; + abstract?: string; + assignee?: string; + inventors?: string[]; + filingDate?: string; + publicationDate?: string; + grantDate?: string; + status?: string; + claims?: unknown[]; + classifications?: string[]; + [key: string]: unknown; +} + +export interface PatentSearchParams { + /** Free-text or structured query string. */ + query?: string; + /** Maximum number of results to return. */ + limit?: number; + /** Offset for pagination. */ + offset?: number; + /** Additional filters passed through to the API. */ + filters?: Record; + /** Any extra fields accepted by the search endpoint. */ + [key: string]: unknown; +} + +export interface PatentSearchResult { + results?: Patent[]; + total?: number; + limit?: number; + offset?: number; + [key: string]: unknown; +} + +// ============================================ +// Research Jobs +// ============================================ + +export type ResearchJobStatus = + | 'queued' + | 'running' + | 'completed' + | 'failed' + | 'cancelled' + | string; + +export interface ResearchJob { + jobId: string; + type?: string; + status?: ResearchJobStatus; + query?: string; + createdAt?: string; + updatedAt?: string; + completedAt?: string; + result?: unknown; + [key: string]: unknown; +} + +export interface CreateResearchJobParams { + /** The type of research job to run (e.g. prior-art, freedom-to-operate). */ + type?: string; + /** Query or subject for the research job. */ + query?: string; + /** Additional parameters passed through to the API. */ + [key: string]: unknown; +} + +export interface ResearchJobListResult { + results?: ResearchJob[]; + total?: number; + [key: string]: unknown; +} + +// ============================================ +// Raw requests +// ============================================ + +export interface RawRequestParams { + path: string; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + query?: Record; + body?: Record | unknown[] | string; + headers?: Record; +} diff --git a/connectors/stilta/src/utils/config.ts b/connectors/stilta/src/utils/config.ts new file mode 100644 index 00000000..9afe2aed --- /dev/null +++ b/connectors/stilta/src/utils/config.ts @@ -0,0 +1,168 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; + +const CONNECTOR_NAME = 'connect-stilta'; +const DEFAULT_PROFILE = 'default'; + +export interface ProfileConfig { + apiKey?: string; + baseUrl?: string; +} + +let profileOverride: string | undefined; + +const CONFIG_DIR = join(homedir(), '.hasna', 'connectors', CONNECTOR_NAME); +const PROFILES_DIR = join(CONFIG_DIR, 'profiles'); +const CURRENT_PROFILE_FILE = join(CONFIG_DIR, 'current_profile'); + +export function setProfileOverride(profile: string | undefined): void { + profileOverride = profile; +} + +export function ensureConfigDir(): void { + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } + if (!existsSync(PROFILES_DIR)) { + mkdirSync(PROFILES_DIR, { recursive: true }); + } +} + +function getProfilePath(profile: string): string { + return join(PROFILES_DIR, `${profile}.json`); +} + +export function getCurrentProfile(): string { + if (profileOverride) { + return profileOverride; + } + + ensureConfigDir(); + + if (existsSync(CURRENT_PROFILE_FILE)) { + try { + const profile = readFileSync(CURRENT_PROFILE_FILE, 'utf-8').trim(); + if (profile && profileExists(profile)) { + return profile; + } + } catch { + // Fall through to default + } + } + + return DEFAULT_PROFILE; +} + +export function setCurrentProfile(profile: string): void { + ensureConfigDir(); + + if (!profileExists(profile) && profile !== DEFAULT_PROFILE) { + throw new Error(`Profile "${profile}" does not exist`); + } + + writeFileSync(CURRENT_PROFILE_FILE, profile); +} + +export function profileExists(profile: string): boolean { + return existsSync(getProfilePath(profile)); +} + +export function listProfiles(): string[] { + ensureConfigDir(); + + if (!existsSync(PROFILES_DIR)) { + return []; + } + + return readdirSync(PROFILES_DIR) + .filter(f => f.endsWith('.json')) + .map(f => f.replace('.json', '')) + .sort(); +} + +export function createProfile(profile: string, config: ProfileConfig = {}): boolean { + ensureConfigDir(); + + if (profileExists(profile)) { + return false; + } + + if (!/^[a-zA-Z0-9_-]+$/.test(profile)) { + throw new Error('Profile name can only contain letters, numbers, hyphens, and underscores'); + } + + writeFileSync(getProfilePath(profile), JSON.stringify(config, null, 2)); + return true; +} + +export function deleteProfile(profile: string): boolean { + if (profile === DEFAULT_PROFILE) { + return false; + } + + if (!profileExists(profile)) { + return false; + } + + if (getCurrentProfile() === profile) { + setCurrentProfile(DEFAULT_PROFILE); + } + + rmSync(getProfilePath(profile)); + return true; +} + +export function loadProfile(profile?: string): ProfileConfig { + ensureConfigDir(); + const profileName = profile || getCurrentProfile(); + const profilePath = getProfilePath(profileName); + + if (!existsSync(profilePath)) { + return {}; + } + + try { + return JSON.parse(readFileSync(profilePath, 'utf-8')); + } catch { + return {}; + } +} + +export function saveProfile(config: ProfileConfig, profile?: string): void { + ensureConfigDir(); + const profileName = profile || getCurrentProfile(); + writeFileSync(getProfilePath(profileName), JSON.stringify(config, null, 2)); +} + +export function getApiKey(): string | undefined { + return process.env.STILTA_API_KEY || loadProfile().apiKey; +} + +export function setApiKey(apiKey: string): void { + const config = loadProfile(); + config.apiKey = apiKey; + saveProfile(config); +} + +export function getBaseUrl(): string | undefined { + return process.env.STILTA_BASE_URL || loadProfile().baseUrl; +} + +export function setBaseUrl(baseUrl: string): void { + const config = loadProfile(); + config.baseUrl = baseUrl; + saveProfile(config); +} + +export function clearConfig(): void { + saveProfile({}); +} + +export function getConfigDir(): string { + return CONFIG_DIR; +} + +export function getActiveProfileName(): string { + return getCurrentProfile(); +} diff --git a/connectors/stilta/src/utils/output.ts b/connectors/stilta/src/utils/output.ts new file mode 100644 index 00000000..5fe334a9 --- /dev/null +++ b/connectors/stilta/src/utils/output.ts @@ -0,0 +1,119 @@ +import chalk from 'chalk'; + +export type OutputFormat = 'json' | 'table' | 'pretty'; + +export function formatOutput(data: unknown, format: OutputFormat = 'pretty'): string { + switch (format) { + case 'json': + return JSON.stringify(data, null, 2); + case 'table': + return formatAsTable(data); + case 'pretty': + default: + return formatPretty(data); + } +} + +function formatAsTable(data: unknown): string { + if (!Array.isArray(data)) { + data = [data]; + } + + const items = data as Record[]; + if (items.length === 0) { + return 'No data'; + } + + const firstItem = items[0]; + if (!firstItem || typeof firstItem !== 'object') { + return 'No data'; + } + + const keys = Object.keys(firstItem); + const colWidths = keys.map(key => { + const maxValue = Math.max( + key.length, + ...items.map(item => String(item[key] ?? '').length) + ); + return Math.min(maxValue, 40); + }); + + const header = keys.map((key, i) => key.padEnd(colWidths[i] ?? 10)).join(' | '); + const separator = colWidths.map(w => '-'.repeat(w)).join('-+-'); + + const rows = items.map(item => + keys.map((key, i) => { + const value = String(item[key] ?? ''); + const width = colWidths[i] ?? 10; + return value.length > width + ? value.substring(0, width - 3) + '...' + : value.padEnd(width); + }).join(' | ') + ); + + return [header, separator, ...rows].join('\n'); +} + +function formatPretty(data: unknown): string { + if (Array.isArray(data)) { + return data.map((item, i) => `${chalk.cyan(`[${i + 1}]`)} ${formatPrettyItem(item)}`).join('\n\n'); + } + return formatPrettyItem(data); +} + +function formatPrettyItem(item: unknown, indent = 0): string { + if (item === null || item === undefined) { + return chalk.gray('null'); + } + + if (typeof item !== 'object') { + return String(item); + } + + const spaces = ' '.repeat(indent); + const entries = Object.entries(item as Record); + + return entries + .map(([key, value]) => { + if (Array.isArray(value)) { + if (value.length === 0) { + return `${spaces}${chalk.blue(key)}: ${chalk.gray('[]')}`; + } + if (typeof value[0] === 'object') { + return `${spaces}${chalk.blue(key)}:\n${value.map(v => formatPrettyItem(v, indent + 1)).join('\n')}`; + } + return `${spaces}${chalk.blue(key)}: ${value.join(', ')}`; + } + + if (typeof value === 'object' && value !== null) { + return `${spaces}${chalk.blue(key)}:\n${formatPrettyItem(value, indent + 1)}`; + } + + return `${spaces}${chalk.blue(key)}: ${chalk.white(String(value))}`; + }) + .join('\n'); +} + +export function success(message: string): void { + console.log(chalk.green('✓'), message); +} + +export function error(message: string): void { + console.error(chalk.red('✗'), message); +} + +export function warn(message: string): void { + console.warn(chalk.yellow('⚠'), message); +} + +export function info(message: string): void { + console.log(chalk.blue('ℹ'), message); +} + +export function heading(message: string): void { + console.log(chalk.bold.cyan(`\n${message}\n`)); +} + +export function print(data: unknown, format: OutputFormat = 'pretty'): void { + console.log(formatOutput(data, format)); +} diff --git a/connectors/stilta/tsconfig.json b/connectors/stilta/tsconfig.json new file mode 100644 index 00000000..e1f1809a --- /dev/null +++ b/connectors/stilta/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "types": ["bun-types"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "bin"] +} diff --git a/src/lib/connectors/patents-ip.ts b/src/lib/connectors/patents-ip.ts index 0612cc34..23a3bd72 100644 --- a/src/lib/connectors/patents-ip.ts +++ b/src/lib/connectors/patents-ip.ts @@ -30,4 +30,11 @@ export const connectors: ConnectorMeta[] = [ category: "Patents & IP", tags: ["patents", "trademarks", "ip", "wipo"], }, + { + name: "stilta", + displayName: "Stilta", + description: "Stilta API: patent search, research jobs, and prior-art analysis", + category: "Patents & IP", + tags: ["patents", "ip", "research", "prior-art"], + }, ];