Skip to content

Latest commit

 

History

History
625 lines (475 loc) · 20 KB

File metadata and controls

625 lines (475 loc) · 20 KB

Host API Reference

This document describes the host APIs available to plugins via the ctx object passed to probe(ctx).

Context Object

type ProbeContext = {
  nowIso: string // Current UTC time (ISO 8601)
  app: {
    version: string // App version
    platform: string // OS platform (e.g., "macos")
    appDataDir: string // App data directory
    pluginDataDir: string // Plugin-specific data dir (auto-created)
  }
  host: HostApi
}

ctx.nowIso

Current UTC timestamp in ISO 8601 format (e.g., 2026-01-15T12:30:00.000Z).

ctx.app

Application metadata:

Property Description
version App version string
platform OS platform (e.g., "macos", "windows", "linux")
appDataDir App's data directory path
pluginDataDir Plugin-specific data directory (auto-created on demand)

The pluginDataDir is unique per plugin ({appDataDir}/plugins_data/{pluginId}/) and is automatically created when the plugin runs. Use it to store config files, cached data, or state.

Logging

host.log.info(message: string): void
host.log.warn(message: string): void
host.log.error(message: string): void

Logs are prefixed with [plugin:<id>] and written to the app's log output.

Example:

ctx.host.log.info("Fetching usage data...")
ctx.host.log.warn("Token expires soon")
ctx.host.log.error("API request failed: " + error.message)

Filesystem

host.fs.exists(path: string): boolean
host.fs.readText(path: string): string   // Throws on error
host.fs.writeText(path: string, content: string): void  // Throws on error
host.fs.listDir(path: string): string[]  // Throws if directory cannot be opened; per-entry errors are silently skipped

Path Expansion

  • ~ expands to the user's home directory
  • ~/foo expands to $HOME/foo

Error Handling

Both readText and writeText throw on errors. Always wrap in try/catch:

try {
  const content = ctx.host.fs.readText("~/.config/myapp/settings.json")
  const settings = JSON.parse(content)
} catch (e) {
  ctx.host.log.error("Failed to read settings: " + String(e))
  throw "Failed to read settings. Check your config."
}

Directory Listing

Use listDir to inspect immediate child names under a directory:

let entries = []
try {
  entries = ctx.host.fs.listDir("~/Library/Application Support/JetBrains")
} catch {
  entries = []
}

Example: Persisting plugin state

const statePath = ctx.app.pluginDataDir + "/state.json"

// Read state
let state = { counter: 0 }
if (ctx.host.fs.exists(statePath)) {
  try {
    state = JSON.parse(ctx.host.fs.readText(statePath))
  } catch {
    // Use default state
  }
}

// Update and save state
state.counter++
ctx.host.fs.writeText(statePath, JSON.stringify(state, null, 2))

Environment

host.env.get(name: string): string | null

Reads an environment variable by name.

Behavior

  • Returns variable value as string when set
  • Returns null when missing
  • Variable must be whitelisted first in src-tauri/src/plugin_engine/host_api.rs
  • Resolution order: current process env first, then a login+interactive shell lookup (macOS)
  • Values may be cached for the app session; restart OpenUsage after changing shell config

Example

const codexHome = ctx.host.env.get("CODEX_HOME")
const authPath = codexHome
  ? codexHome.replace(/\/+$/, "") + "/auth.json"
  : "~/.config/codex/auth.json"

Crypto

host.crypto.decryptAes256Gcm(envelope: string, keyB64: string): string
host.crypto.encryptAes256Gcm(plaintext: string, keyB64: string): string

AES-256-GCM helpers for plugins that need to read or write encrypted local auth stores.

Behavior

  • keyB64 must be a base64-encoded 32-byte key.
  • encryptAes256Gcm(...) returns a JSON envelope string with nonce and ciphertext fields.
  • decryptAes256Gcm(...) expects that same envelope shape and returns the decrypted UTF-8 plaintext.
  • Both methods throw on malformed base64, malformed envelopes, or failed decryption.

Example

const keyB64 = ctx.host.fs.readText("~/.factory/auth.v2.key").trim()
const envelope = ctx.host.fs.readText("~/.factory/auth.v2.file")
const plaintext = ctx.host.crypto.decryptAes256Gcm(envelope, keyB64)
const auth = JSON.parse(plaintext)

auth.access_token = refreshedAccessToken
const nextEnvelope = ctx.host.crypto.encryptAes256Gcm(JSON.stringify(auth, null, 2), keyB64)
ctx.host.fs.writeText("~/.factory/auth.v2.file", nextEnvelope)

HTTP

host.http.request({
  method?: string,           // Default: "GET"
  url: string,
  headers?: Record<string, string>,
  bodyText?: string,
  timeoutMs?: number         // Default: 10000
}): {
  status: number,
  headers: Record<string, string>,
  bodyText: string
}

Behavior

  • No redirects: The HTTP client does not follow redirects (policy: none)
  • Throws on network errors: Connection failures, DNS errors, and timeouts throw
  • Capability-gated domains: HTTP requests are limited to exact hosts or *.example.com wildcard hosts from capabilities.httpDomains. Empty or omitted domains block all plugin HTTP requests.

Example: GET request

let resp
try {
  resp = ctx.host.http.request({
    method: "GET",
    url: "https://api.example.com/usage",
    headers: {
      Authorization: "Bearer " + token,
      Accept: "application/json",
    },
    timeoutMs: 5000,
  })
} catch (e) {
  throw "Network error. Check your connection."
}

if (resp.status !== 200) {
  throw "Request failed (HTTP " + resp.status + "). Try again later."
}

const data = JSON.parse(resp.bodyText)

Example: POST request with JSON body

const resp = ctx.host.http.request({
  method: "POST",
  url: "https://api.example.com/refresh",
  headers: {
    "Content-Type": "application/json",
  },
  bodyText: JSON.stringify({ refresh_token: token }),
  timeoutMs: 10000,
})

Keychain / Credential Store

host.keychain.readGenericPassword(service: string): string
host.keychain.readGenericPasswordForAccount(service: string, account: string): string
host.keychain.readGenericPasswordForTarget(target: string): string
host.keychain.writeGenericPassword(service: string, value: string): void
host.keychain.deleteGenericPassword(service: string): void

Reads and writes credentials through the platform credential store.

Behavior

  • readGenericPassword(service) uses the OpenUsage-scoped credential namespace.
  • readGenericPasswordForAccount(service, account) reads a native service/account pair directly. Use it only when a provider must integrate with an external tool's credential entry, such as GitHub CLI.
  • readGenericPasswordForTarget(target) reads a native Windows generic credential by its raw target name. Use it only when a Windows app stores credentials under a fixed target instead of an app-owned OpenUsage namespace.
  • All keychain methods throw if the credential store is unavailable or the requested entry cannot be read.

Example

let credentials = null

// Try file first, fall back to keychain
if (ctx.host.fs.exists("~/.myapp/credentials.json")) {
  credentials = JSON.parse(ctx.host.fs.readText("~/.myapp/credentials.json"))
} else {
  try {
    const keychainValue = ctx.host.keychain.readGenericPassword("MyApp-credentials")
    credentials = JSON.parse(keychainValue)
  } catch {
    throw "Login required. Sign in to continue."
  }
}

Provider Secret Vault

host.providerSecrets.read(secretKey: string): string
host.providerSecrets.write(secretKey: string, value: string): void

Reads and writes app-owned provider secrets for the currently running plugin.

Behavior

  • Secrets are automatically namespaced to the current plugin/provider id.
  • On Windows, values are stored through the local protected provider secret store.
  • On macOS/Linux, values are stored in the system credential vault.
  • read(...) throws when the secret is missing or the credential store is unavailable.
  • write(...) throws when the value is empty or the credential store write fails.

Example

const secretKey = "account:" + profileId + ":authJson"
const authJson = ctx.host.providerSecrets.read(secretKey)
const auth = JSON.parse(authJson)

auth.tokens.access_token = refreshedAccessToken
ctx.host.providerSecrets.write(secretKey, JSON.stringify(auth))

SQLite

Query (Read-Only)

host.sqlite.query(dbPath: string, sql: string): string

Executes a read-only SQL query against a SQLite database.

Behavior:

  • Read-only: Database is opened with -readonly flag
  • Returns JSON string: Result is a JSON array of row objects (must JSON.parse())
  • Dot-commands blocked: Commands like .schema, .tables are rejected
  • Throws on errors: Invalid SQL, missing database, etc.

Example:

const dbPath = "~/Library/Application Support/MyApp/state.db"
const sql = "SELECT key, value FROM settings WHERE key = 'token'"

let rows
try {
  const json = ctx.host.sqlite.query(dbPath, sql)
  rows = JSON.parse(json)
} catch (e) {
  ctx.host.log.error("SQLite query failed: " + String(e))
  throw "DB error. Check your data source."
}

if (rows.length === 0) {
  throw "Not configured. Update your settings."
}

const token = rows[0].value

Exec (Read-Write)

host.sqlite.exec(dbPath: string, sql: string): void

Executes a write SQL statement against a SQLite database.

Behavior:

  • Capability-gated: Only available when capabilities.sqliteWrite is explicitly true in plugin.json; otherwise calls are blocked and logged
  • Read-write: Database is opened with full write access
  • Returns nothing: Use for INSERT, UPDATE, DELETE, or other write operations
  • Dot-commands blocked: Commands like .schema, .tables are rejected
  • Throws on errors: Invalid SQL, missing database, permission denied, etc.

Example:

const dbPath = "~/Library/Application Support/MyApp/state.db"

// Escape single quotes in value for SQL safety
const escaped = newToken.replace(/'/g, "''")
const sql = "INSERT OR REPLACE INTO settings (key, value) VALUES ('token', '" + escaped + "')"

try {
  ctx.host.sqlite.exec(dbPath, sql)
} catch (e) {
  ctx.host.log.error("SQLite write failed: " + String(e))
  throw "Failed to save token."
}

Warning: Be careful with SQL injection. Always escape user-provided values.

Execution Timing

probe(ctx) is called when:

  • The app loads
  • The user clicks Refresh (per-provider retry button)
  • The auto-update timer fires (configurable: 5/15/30/60 minutes)

Any token refresh logic (e.g., OAuth refresh) must run inside probe(ctx) at those times.

Line Builders

Helper functions for creating output lines. All builders use an options object pattern.

ctx.line.text(opts)

Creates a text line (label/value pair).

ctx.line.text({
  label: string,      // Required: label shown on the left
  value: string,      // Required: value shown on the right
  color?: string,     // Optional: hex color for value text
  subtitle?: string   // Optional: smaller text below the line
}): MetricLine

Example:

ctx.line.text({ label: "Account", value: "user@example.com" })
ctx.line.text({ label: "Status", value: "Active", color: "#22c55e", subtitle: "Since Jan 2024" })

ctx.line.progress(opts)

Creates a progress bar line.

ctx.line.progress({
  label: string,                    // Required: label shown on the left
  used: number,                     // Required: amount used (>= 0)
  limit: number,                    // Required: limit (> 0)
  format: {                         // Required: formatting rules
    kind: "percent" | "dollars" | "count",
    suffix?: string                 // Required when kind="count" (e.g. "credits")
  },
  resetsAt?: string | null,         // Optional: ISO timestamp for when usage resets
  periodDurationMs?: number,        // Optional: period length in ms for pace tracking
  color?: string,                   // Optional: hex color for progress bar
}): MetricLine

Notes:

  • used may exceed limit (overages).
  • For format.kind: "percent", limit must be 100.
  • Prefer setting resetsAt (via ctx.util.toIso(...)) instead of putting reset info in other lines.
  • periodDurationMs: when provided with resetsAt, enables pace visuals (Dot Pacing status + in-bar pace marker) and projected-rate messaging.

Example:

ctx.line.progress({ label: "Usage", used: 42, limit: 100, format: { kind: "percent" } })
ctx.line.progress({ label: "Spend", used: 12.34, limit: 100, format: { kind: "dollars" } })
ctx.line.progress({
  label: "Session",
  used: 75,
  limit: 100,
  format: { kind: "percent" },
  resetsAt: ctx.util.toIso("2026-02-01T00:00:00Z"),
})

ctx.line.badge(opts)

Creates a badge line (status indicator).

ctx.line.badge({
  label: string,      // Required: label shown on the left
  text: string,       // Required: badge text
  color?: string,     // Optional: hex color for badge border/text
  subtitle?: string   // Optional: smaller text below the line
}): MetricLine

Example:

ctx.line.badge({ label: "Plan", text: "Pro", color: "#000000" })
ctx.line.badge({ label: "Status", text: "Connected", color: "#22c55e" })

Formatters

Helper functions for formatting values.

ctx.fmt.planLabel(value)

Capitalizes a plan name string.

ctx.fmt.planLabel("pro") // "Pro"
ctx.fmt.planLabel("team_plan") // "Team_plan"

ctx.fmt.resetIn(seconds)

Formats seconds until reset as human-readable duration.

ctx.fmt.resetIn(180000) // "2d 2h"
ctx.fmt.resetIn(7200) // "2h 0m"
ctx.fmt.resetIn(300) // "5m"
ctx.fmt.resetIn(30) // "<1m"

ctx.fmt.dollars(cents)

Converts cents to dollars.

ctx.fmt.dollars(1234) // 12.34
ctx.fmt.dollars(500) // 5

ctx.fmt.date(unixMs)

Formats Unix milliseconds as short date.

ctx.fmt.date(1704067200000) // "Jan 1"

Utilities

ctx.util.toIso(value)

Normalizes a timestamp into an ISO string (or returns null if the input can't be parsed).

Accepts common inputs like:

  • ISO strings (with or without timezone; timezone-less is treated as UTC)
  • Unix seconds / milliseconds (number or numeric string)
  • Date objects

Example:

ctx.line.progress({
  label: "Weekly",
  used: 24,
  limit: 100,
  format: { kind: "percent" },
  resetsAt: ctx.util.toIso(data.resets_at),
})

ccusage (Token Usage)

host.ccusage.query(opts: {
  provider?: "claude" | "codex" | "opencode" | "amp" | "droid" | "codebuff" | "hermes" | "pi" | "goose" | "openclaw" | "kilo" | "kimi" | "qwen" | "copilot" | "gemini", // Optional; defaults to plugin id, then "claude"
  since?: string,                // Start date (YYYYMMDD or YYYY-MM-DD)
  until?: string,                // End date (YYYYMMDD or YYYY-MM-DD)
  homePath?: string,             // Provider data-root override (source-specific environment variable)
  claudePath?: string,           // Legacy Claude-only override (deprecated; use homePath)
}):
  | { status: "ok", data: { daily: DailyUsage[] } }
  | { status: "no_runner" }
  | { status: "runner_failed" }

Queries local token usage via provider-focused ccusage commands:

Returns a status envelope:

  • ok: query succeeded, usage data is in data.daily
  • no_runner: no package runner (bunx/pnpm/yarn/npm/npx) was found
  • runner_failed: at least one runner was available but all attempts failed

Behavior

  • Runtime runners: Executes pinned ccusage@20.0.19 via fallback chain bunx -> pnpm dlx -> yarn dlx -> npm exec -> npx
  • Windows launchers: Resolves .exe, .cmd, and .bat package-manager launchers from the user's npm application-data directory and PATH; a globally installed ccusage version is not used
  • Cold starts: Gives each pinned package-runner attempt up to 30 seconds so an initial registry/cache fill is not discarded by the normal warm-start budget
  • Provider-aware: Resolves provider from opts.provider or the plugin id, including aliases such as factory → droid and opencode-go → opencode
  • Focused commands: Uses ccusage <source> daily; it intentionally does not use ccusage daily because that aggregates all detected agents
  • Legacy fallback: If ccusage@20.0.19 cannot run through the package manager release-age policy, retries with release-age-safe ccusage@18.0.11 for Claude or @ccusage/codex@18.0.11 for Codex
  • No provider API calls: Usage is computed from local JSONL session files; the host does not call Claude/Codex (or other provider) APIs, but package runners may contact a package registry to download the ccusage CLI if it is not already available locally
  • Graceful degradation: returns no_runner when no runner exists, runner_failed when execution fails
  • Pricing: Uses ccusage's built-in LiteLLM pricing data

DailyUsage

The host normalizes only the top-level shape to { daily: [...] }. Inner day fields come from the selected CLI and may differ by provider/version.

Commonly observed fields include:

Property Type Notes
date string Date label from CLI output (provider/locale-dependent)
inputTokens number Present when the selected provider reports input usage
outputTokens number Present when the selected provider reports output usage
cacheCreationTokens number Provider-reported cache-write usage
cacheReadTokens number Provider-reported cache-read usage
reasoningTokens number Provider-reported reasoning usage
cachedInputTokens number Codex field
totalTokens number Optional aggregate; derive it only when every token component is present, otherwise keep it unknown
totalCost number | null Claude cost field
costUSD number Codex cost field

Example

var result = ctx.host.ccusage.query({ provider: "codex", since: "20260101" })
if (result.status === "ok") {
  for (var i = 0; i < result.data.daily.length; i++) {
    var day = result.data.daily[i]
    var cost = day.totalCost != null ? day.totalCost : day.costUSD
    var tokens = day.totalTokens
    if (
      tokens == null &&
      day.inputTokens != null &&
      day.outputTokens != null &&
      day.cacheCreationTokens != null &&
      day.cacheReadTokens != null &&
      day.reasoningTokens != null
    ) {
      tokens =
        day.inputTokens +
        day.outputTokens +
        day.cacheCreationTokens +
        day.cacheReadTokens +
        day.reasoningTokens
    }
    ctx.host.log.info(
      day.date + ": " + (tokens != null ? tokens : "n/a") + " tokens, $" + (cost != null ? cost : "n/a")
    )
  }
} else if (result.status === "no_runner") {
  ctx.host.log.warn("ccusage unavailable: no package runner found")
}

See Also

  • Plugin Schema - Plugin structure, manifest format, and output schema