diff --git a/docs/grid/AIRC-CONTINUUM-BRIDGE.md b/docs/grid/AIRC-CONTINUUM-BRIDGE.md index 20bd7120e..32866c75c 100644 --- a/docs/grid/AIRC-CONTINUUM-BRIDGE.md +++ b/docs/grid/AIRC-CONTINUUM-BRIDGE.md @@ -56,6 +56,13 @@ Heavy data should stay out of AIRC. Use AIRC for manifests, handles, room markers, artifact hashes, and job ids; use Continuum/Grid data paths for model weights, LoRA artifacts, voice/video, and high-volume streams. +Secrets stay out of AIRC completely. API keys, HF tokens, SSH keys, cookies, +provider credentials, and encrypted secret payloads are not bridge messages. +AIRC can carry `secretRef` names, fingerprints, lease ids, request ids, PR SHAs, +and acknowledgements so humans and agents can coordinate, but actual credential +material must move only through the secret/capability command path described in +[GRID-ARCHITECTURE.md](GRID-ARCHITECTURE.md). + ## Harness For deterministic tests without a live AIRC monitor: diff --git a/docs/grid/GRID-ARCHITECTURE.md b/docs/grid/GRID-ARCHITECTURE.md index fba38d0da..5db8b14ce 100644 --- a/docs/grid/GRID-ARCHITECTURE.md +++ b/docs/grid/GRID-ARCHITECTURE.md @@ -184,6 +184,180 @@ Entities already serialize/deserialize cleanly, carry UUIDs, have CRUD events, a No new serialization format. No new ID scheme. No new event system. The Grid protocol IS the existing protocol, routed over a mesh. +### 3.5 Secrets, API Keys, And Capability Leases + +The AIRC workflow is the right mental model: agents coordinate by sending +stable identifiers, immutable SHAs, handles, and acknowledgements. They do not +send the thing itself when the thing is large, private, or operationally +sensitive. Grid secrets follow the same rule. + +**Default rule:** no raw API key, HF token, SSH key, cookie, model license token, +or provider credential is ever sent through AIRC, Grid events, chat transcripts, +logs, replay captures, RAG, or persona memory. + +Every node owns its local secret store under `$HOME/.continuum`. The grid moves +capability facts and encrypted grants: + +```typescript +interface GridSecretCapability { + secretRef: string; // e.g. provider/openai/default + provider: string; // openai, anthropic, huggingface, etc. + scopes: string[]; // chat, embeddings, upload, factory + ownerNodeId: UUID; + version: number; + fingerprint: string; // hash/HMAC of normalized metadata, never value + available: boolean; // non-empty + health check passed + expiresAt?: string; // for leases, not local owner secrets +} + +interface GridSecretLease { + leaseId: UUID; + secretRef: string; + granteeNodeId: UUID; + scopes: string[]; + expiresAt: string; + auditHandle: UUID; +} + +interface GridSecretRevision { + nodeId: UUID; + secretRef: string; + version: number; + fingerprint: string; + scopes: string[]; + source: 'env-file' | 'settings-ui' | 'persona-command' | 'factory-import'; + updatedAt: string; +} +``` + +The Settings page, setup flow, persona helper, and JTAG commands all write to +the same local authority. Personas may help the user enter a key or run a +command, but they receive a `secretRef`/lease handle, not the raw value. The +same handle can then be used by Rust workers, TypeScript adapters, factory +jobs, and grid commands without each layer inventing its own credential path. + +Most real setup starts on the lowest-power machine in front of the user: + +- edit `$HOME/.continuum/config.env` directly; +- use the Settings/API Providers widget; +- ask a persona to call existing `ai/key/save`, `ai/key/remove`, or future + `ai/key/*` merge commands; +- import a factory/upload credential for a specific workflow. + +All four entry points produce the same redacted `GridSecretRevision`. Grid sync +then behaves like a small, secret-aware git merge: advertise revisions, compute +a redacted diff, ask for approval if the same `secretRef` changed on more than +one node, then apply only approved encrypted writes through `SecretManager`. +The merge object contains names, versions, fingerprints, scopes, source, and +timestamps. It never contains the secret value. + +```typescript +interface GridSecretMergePlan { + baseRevision?: GridSecretRevision; + localRevision?: GridSecretRevision; + remoteRevision?: GridSecretRevision; + action: 'keep-local' | 'import-remote' | 'export-local' | 'rotate' | 'manual'; + conflict: boolean; + reason: string; +} +``` + +Git can be the implementation substrate for revision history if it is useful, +but it must be a redacted secret ledger, not a repository of `.env` values. A +commit may contain `secretRef`, fingerprint, version, and merge decision; it +must never contain an API key or encrypted credential blob intended for another +node. + +The process that keeps this in line should be a normal Continuum daemon/process, +not a one-off sync script. It watches local secret/config revisions and +occasionally runs the same `ai/key/*` command composition a user action would +run. For explicit user mutations, `sync` is a parameter on the existing command +shape, not a new top-level transport noun: `ai/key/save --sync` and +`ai/key/remove --sync`. + +```text +local edit/widget/persona command + -> SecretManager writes local state + -> GridReconcilerDaemon notices or receives the change event + -> GridReconcilerDaemon runs a bounded ai/key command program for selected peers: + - ai/key/status + - ai/key/diff + - optional owner/persona approval on conflicts + - ai/key/apply-merge + -> audit/replay records command handles, fingerprints, timings, outcomes +``` + +This is the same pattern as an intra-environment call like screenshot capture, +but the target environment is another Continuum node. One node asks another node +to execute a typed command, or a small bounded program of typed commands, against +the target's own `$HOME/.continuum`. The caller receives typed redacted results; +both sides can replay the decision without exposing the secret. + +The substrate already exists in the command system: + +- `grid/send` is the explicit routed command envelope: target node, command + name, params, typed result. +- `GridInterceptor` is the transparent path: normal `Commands.execute()` can be + routed remotely when the router chooses a peer. +- `grid/route` is the dry-run/debug primitive for "where would this command + execute?" +- `model/forge` already delegates to `grid/job-submit`; forge jobs are therefore + another consumer of the same substrate, not a separate agent-managed lane. + +The missing abstraction is a bounded command program shape: a small ordered set +of existing typed commands with limits, redaction policy, timeout, approval +rules, and audit handles. It should be boring TypeScript data, not arbitrary +shell. Secrets need it for status/diff/apply; forge needs it for preflight, +credential availability, artifact/cache checks, job submit, and status followup. +Grid should run those programs itself. It must not require a coding agent on +each machine to manually align environment variables or forge setup. + +The first deployment target is the user's local grid: a trusted subnet/intranet +over Tailscale. The same command envelope later extends to trusted WAN peers and +eventually other users on the P2P mesh, with tighter limits, explicit approval, +and stronger validation as trust decreases. The same shape later applies to +model registry sync, LoRA availability, settings templates, and other low-volume +grid state. + +**API-key slice for the first PR:** + +- Existing `ai/key/save`: write one key into `$HOME/.continuum/config.env` or + the platform vault through `SecretManager`; redact value from logs and command + echo. Add `sync?: boolean | 'trusted-grid'` to request immediate propagation + after the local write. +- Existing `ai/key/remove`: remove one key through `SecretManager`. Add + `sync?: boolean | 'trusted-grid'` to propagate deletion/revocation metadata + after the local remove. +- Existing `ai/key/test`: validate a candidate or stored provider key. +- Existing `ai/providers/status`: provider-facing availability view. +- `ai/key/status`: report configured key names, source path, empty + placeholders, fingerprints, and health without values. +- `ai/key/diff`: compare local redacted revisions with one or more peers and + produce a merge plan without values. +- `ai/key/apply-merge`: apply an approved merge plan through `SecretManager`. +- `ai/key/request-lease`: request a scoped, expiring grant from an owner node; + default response is deny unless the owner or policy approves. +- `ai/key/revoke-lease`: revoke a lease and emit an audit event. + +**Encrypted sharing is explicit.** If the owner chooses to copy a key to another +trusted node, the export is an envelope encrypted to the target node identity +and imported through `SecretManager`; loose file copy is not a grid protocol. +The audit trail records requester, approver, `secretRef`, fingerprint, version, +scope, and outcome. It never records the secret value. + +**No-token onboarding is a gate.** Fresh installs must work with public models +and local inference without `HF_TOKEN` or any cloud key. `HF_TOKEN` is only for +private/gated downloads, uploads, factory publishing, or user-selected provider +workflows. A missing key produces a typed unavailable/degraded result; it must +not silently route to a cloud fallback, stale credential, or CPU-shaped +workaround. + +**Replay and introspection stay useful because they are redacted.** Record the +command, `secretRef`, fingerprint/version, lease id, timing, target node, and +result. That gives VDD/JTAG replay enough information to reproduce routing and +authorization behavior without poisoning logs, RAG, or persona memory with +credentials. + --- ## 4. Transport Layer diff --git a/docs/planning/ALPHA-GAP-ANALYSIS.md b/docs/planning/ALPHA-GAP-ANALYSIS.md index d77b857b0..ae69afb66 100644 --- a/docs/planning/ALPHA-GAP-ANALYSIS.md +++ b/docs/planning/ALPHA-GAP-ANALYSIS.md @@ -2,14 +2,20 @@ -**Updated**: 2026-05-11 +**Updated**: 2026-05-13 **Branch policy**: every change lands as `PR -> canary -> validation -> PR -> main` **Status**: active planning document, shared by humans and agents **Operating rule**: Rust owns runtime logic. TypeScript is UI, schema, generated types, and thin command/transport glue. +**Template-first rule**: new commands must start from `src/generator/specs/*.json` and Continuum's command generator. Manual command scaffolds are not acceptable; hand edits are for post-generation behavior only. **Architectural mandate**: Rust-first, GPU-first, replay-tested. No patchwork substitutes for the target architecture. **Sensory model plan**: [Sensory Model And Experiential Plasticity Plan](../architecture/SENSORY-MODEL-AND-EXPERIENTIAL-PLASTICITY-PLAN.md) -This document is the alpha source of truth. Work should not proceed as disconnected chat threads or private agent branches. Each implementation PR must name the issue it advances, land in `canary`, publish validation evidence, and only then be considered for promotion to `main`. +This document is the alpha/gap source of truth. Work should not proceed as disconnected chat threads, private agent branches, or parallel "gap" documents. Each implementation PR must name the issue it advances, land in `canary`, publish validation evidence, and only then be considered for promotion to `main`. + +As of 2026-05-13 there is exactly one alpha/gap planning file: +`docs/planning/ALPHA-GAP-ANALYSIS.md`. New alpha/gap notes are merged here or +deleted. Architecture references may point here, but they must not become +parallel status ledgers. The previous 2026-05-01 alpha snapshot was useful but had become a historical log. This revision turns it into an execution plan for the current goal: **stable, GPU-first, Rust-centric Continuum with modular Docker and fast tests that do not depend on the Node/UI stack for core correctness.** @@ -520,15 +526,32 @@ Implementation posture: | Issue | Priority | Direction | Test gate | |---|---:|---|---| | file: config single-source issue | P0 | `SecretManager` and Rust `secrets.rs` must treat only non-empty values as configured and must lazy-load `$HOME/.continuum/config.env` before any provider check | provider status shows cloud unavailable for empty placeholders; local chat still works | -| file: `grid/config/sync` command issue | P0 | create a command pair for encrypted config sharing over trusted grid/Tailscale nodes; no loose file copying and no browser exposure | two-node test shares selected keys, decrypts only on trusted target, and never logs values | +| [#1097](https://github.com/CambrianTech/continuum/issues/1097) API-key merge commands | P0 | extend the existing `ai/key/*` command surface for encrypted config sharing over trusted grid/Tailscale nodes; no loose file copying and no browser exposure | two-node test shares selected keys, decrypts only on trusted target, and never logs values | +| [#1098](https://github.com/CambrianTech/continuum/issues/1098) routed command program substrate | P0 | consolidate bounded multi-command execution on top of `grid/send`, `GridInterceptor`, and `grid/route` so secrets and forge use the same path | one local-grid test runs a redacted `ai/key/*` program; one forge preflight routes through the same envelope | | #860 config.env as directory | P1 | keep setup file/dir creation idempotent and typed | setup test catches file-vs-dir mismatch | +Implementation status: + +- Shared `ai/key` base types now exist for provider identity, sync intent, + target nodes, dry-run, synced state, and merge-plan id. +- Existing `ai/key/save`, `ai/key/remove`, and `ai/key/test` shared types + inherit the base. Runtime sync behavior is intentionally not claimed until the + routed reconciliation path exists. +- `ai/key/status` is generated from `src/generator/specs/ai-key-status.json` + and returns only redacted provider/key/source/configured/fingerprint metadata. +- `grid/send` is the explicit routed command envelope; `GridInterceptor` is the + transparent `Commands.execute()` remote path; `grid/route` is the dry-run + routing/debug primitive. + Command shape: -- `grid/config/status`: list configured key names, source path, empty placeholders, and target-node drift without values. -- `grid/config/export`: encrypt selected config keys for a specific trusted node identity. -- `grid/config/import`: decrypt and merge selected keys into the target node's `$HOME/.continuum/config.env`. -- `grid/config/sync`: orchestrate export/import across trusted grid nodes and report per-node success. +- Existing `ai/key/save`: write one key through `SecretManager` to `$HOME/.continuum/config.env` or the platform vault; command echo and logs must redact values. +- Existing `ai/key/remove`: remove one key through `SecretManager`. +- Existing `ai/key/test`: validate a candidate or stored provider key. +- Existing `ai/providers/status`: provider-facing availability view. +- `ai/key/status`: list configured key names, source path, empty placeholders, fingerprints, and provider health without values. +- `ai/key/diff`: compare redacted key revisions across selected target nodes and produce a merge plan without values. +- `ai/key/apply-merge`: apply an approved merge plan through `SecretManager`; conflicts require owner/persona approval and never auto-overwrite a newer local key. Rules: @@ -536,6 +559,8 @@ Rules: - Local mode must work with zero API keys. - Cloud personas are eligible only when their required key is non-empty and the provider health check is not expired/failed. - Config sharing is an owner/trusted-node command. It should use grid identity plus transport encryption, then persist through `SecretManager` so all runtimes see one source. +- Remote/grid execution is command routing context, not a namespace. The capability name stays stable while target environment changes. +- Fresh install and Carl smoke must pass with public model downloads and no `HF_TOKEN`; token-dependent private/gated/factory upload paths are optional later setup. ### 2. GPU Runtime Stability diff --git a/src/commands/ai/key/common/AiKeyBase.ts b/src/commands/ai/key/common/AiKeyBase.ts new file mode 100644 index 000000000..e143cf3b1 --- /dev/null +++ b/src/commands/ai/key/common/AiKeyBase.ts @@ -0,0 +1,55 @@ +/** + * Shared AI key command types. + * + * The ai/key/* commands stay modular by verb, while shared params keep + * provider identity, sync intent, and redacted merge metadata consistent. + */ + +import type { CommandParams, CommandResult, JTAGContext } from '@system/core/types/JTAGTypes'; +import { createPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; +import type { JTAGError } from '@system/core/types/ErrorTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; + +export type AiKeySyncMode = boolean | 'trusted-grid'; + +export interface AiKeyParams extends CommandParams { + /** Provider config key or provider alias, e.g. OPENAI_API_KEY or openai. */ + provider?: string; + /** Request sync after local mutation. Remote execution stays routing context. */ + sync?: AiKeySyncMode; + /** Optional target node ids for explicit sync/diff/apply flows. */ + targetNodes?: string[]; + /** Build a merge plan without writing. */ + dryRun?: boolean; +} + +export interface AiKeyResult extends CommandResult { + success: boolean; + provider?: string; + synced?: boolean; + syncMode?: AiKeySyncMode; + targetNodes?: string[]; + mergePlanId?: string; + error?: JTAGError; +} + +export const createAiKeyParams = = Partial>( + context: JTAGContext, + sessionId: UUID, + data: T & { provider?: string } +): AiKeyParams & T => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, + provider: data.provider ?? '', + ...data +} as AiKeyParams & T); + +export const createAiKeyResult = = Partial>( + context: JTAGContext, + sessionId: UUID, + data: T & { success: boolean; provider?: string } +): AiKeyResult & T => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, + provider: data.provider ?? '', + ...data +} as AiKeyResult & T); diff --git a/src/commands/ai/key/common/AiKeyProviders.ts b/src/commands/ai/key/common/AiKeyProviders.ts new file mode 100644 index 000000000..0994765ad --- /dev/null +++ b/src/commands/ai/key/common/AiKeyProviders.ts @@ -0,0 +1,96 @@ +/** + * Known AI provider key metadata shared by ai/key/* commands. + * + * Keep this list about secret/config keys only. Transport routing and grid + * synchronization stay command execution context, not provider taxonomy. + */ + +export type AiKeyCategory = 'local' | 'cloud'; + +export interface AiKeyProviderMetadata { + provider: string; + key: string; + category: AiKeyCategory; + description: string; +} + +export const AI_KEY_PROVIDERS: readonly AiKeyProviderMetadata[] = [ + { + provider: 'Docker Model Runner', + key: 'DMR_ENABLED', + category: 'local', + description: 'Local LLM inference via Docker Desktop Model Runner' + }, + { + provider: 'Anthropic', + key: 'ANTHROPIC_API_KEY', + category: 'cloud', + description: 'Claude models' + }, + { + provider: 'OpenAI', + key: 'OPENAI_API_KEY', + category: 'cloud', + description: 'GPT models' + }, + { + provider: 'Groq', + key: 'GROQ_API_KEY', + category: 'cloud', + description: 'Fast inference' + }, + { + provider: 'DeepSeek', + key: 'DEEPSEEK_API_KEY', + category: 'cloud', + description: 'Reasoning models' + }, + { + provider: 'xAI', + key: 'XAI_API_KEY', + category: 'cloud', + description: 'Grok models' + }, + { + provider: 'Together', + key: 'TOGETHER_API_KEY', + category: 'cloud', + description: 'Open model hosting' + }, + { + provider: 'Fireworks', + key: 'FIREWORKS_API_KEY', + category: 'cloud', + description: 'Open model hosting' + }, + { + provider: 'Alibaba', + key: 'DASHSCOPE_API_KEY', + category: 'cloud', + description: 'Qwen/DashScope models' + }, + { + provider: 'Google', + key: 'GOOGLE_API_KEY', + category: 'cloud', + description: 'Gemini models' + }, + { + provider: 'Hugging Face', + key: 'HF_TOKEN', + category: 'cloud', + description: 'Model upload/factory access. Public downloads must not require this.' + } +] as const; + +export function normalizeAiKeyProvider(input: string): string { + return input.trim().toLowerCase().replace(/[\s_-]+/g, ''); +} + +export function findAiKeyProvider(input: string): AiKeyProviderMetadata | undefined { + const normalized = normalizeAiKeyProvider(input); + return AI_KEY_PROVIDERS.find(provider => + normalizeAiKeyProvider(provider.provider) === normalized || + normalizeAiKeyProvider(provider.key) === normalized + ); +} diff --git a/src/commands/ai/key/remove/shared/AiKeyRemoveTypes.ts b/src/commands/ai/key/remove/shared/AiKeyRemoveTypes.ts index c8da4f6d1..6b5fd0dd2 100644 --- a/src/commands/ai/key/remove/shared/AiKeyRemoveTypes.ts +++ b/src/commands/ai/key/remove/shared/AiKeyRemoveTypes.ts @@ -4,19 +4,27 @@ * Remove an API key for a cloud AI provider. Removes from ~/.continuum/config.env, clears process.env, and emits system:config:key-removed event to deactivate personas. */ -import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; -import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; -import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; +import type { CommandInput, CommandParams, JTAGContext } from '@system/core/types/JTAGTypes'; +import { transformPayload } from '@system/core/types/JTAGTypes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; +import { + type AiKeyParams, + type AiKeyResult, + type AiKeySyncMode, + createAiKeyParams, + createAiKeyResult +} from '../../common/AiKeyBase'; /** * Ai Key Remove Command Parameters */ -export interface AiKeyRemoveParams extends CommandParams { +export interface AiKeyRemoveParams extends CommandParams, AiKeyParams { // The config key name (e.g., 'ANTHROPIC_API_KEY', 'DEEPSEEK_API_KEY') provider: string; + // Request immediate sync after local remove + sync?: AiKeySyncMode; } /** @@ -28,22 +36,25 @@ export const createAiKeyRemoveParams = ( data: { // The config key name (e.g., 'ANTHROPIC_API_KEY', 'DEEPSEEK_API_KEY') provider: string; + sync?: AiKeySyncMode; + targetNodes?: string[]; + dryRun?: boolean; } -): AiKeyRemoveParams => createPayload(context, sessionId, { - userId: SYSTEM_SCOPES.SYSTEM, - +): AiKeyRemoveParams => createAiKeyParams(context, sessionId, { ...data }); /** * Ai Key Remove Command Result */ -export interface AiKeyRemoveResult extends CommandResult { - success: boolean; +export interface AiKeyRemoveResult extends AiKeyResult { // Whether the key was removed successfully removed: boolean; // The config key name that was removed provider: string; + synced?: boolean; + syncMode?: AiKeySyncMode; + targetNodes?: string[]; error?: JTAGError; } @@ -59,9 +70,13 @@ export const createAiKeyRemoveResult = ( removed?: boolean; // The config key name that was removed provider?: string; + synced?: boolean; + syncMode?: AiKeySyncMode; + targetNodes?: string[]; + mergePlanId?: string; error?: JTAGError; } -): AiKeyRemoveResult => createPayload(context, sessionId, { +): AiKeyRemoveResult => createAiKeyResult(context, sessionId, { removed: data.removed ?? false, provider: data.provider ?? '', ...data diff --git a/src/commands/ai/key/save/shared/AiKeySaveTypes.ts b/src/commands/ai/key/save/shared/AiKeySaveTypes.ts index 2cdee29c3..259294bbb 100644 --- a/src/commands/ai/key/save/shared/AiKeySaveTypes.ts +++ b/src/commands/ai/key/save/shared/AiKeySaveTypes.ts @@ -4,21 +4,29 @@ * Save an API key for a cloud AI provider. Persists to ~/.continuum/config.env, sets process.env, and emits system:config:key-added event to trigger persona creation. */ -import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; -import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; -import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; +import type { CommandInput, CommandParams, JTAGContext } from '@system/core/types/JTAGTypes'; +import { transformPayload } from '@system/core/types/JTAGTypes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; +import { + type AiKeyParams, + type AiKeyResult, + type AiKeySyncMode, + createAiKeyParams, + createAiKeyResult +} from '../../common/AiKeyBase'; /** * Ai Key Save Command Parameters */ -export interface AiKeySaveParams extends CommandParams { +export interface AiKeySaveParams extends CommandParams, AiKeyParams { // The config key name (e.g., 'ANTHROPIC_API_KEY', 'DEEPSEEK_API_KEY') provider: string; // The API key value to save value: string; + // Request immediate sync after local save + sync?: AiKeySyncMode; } /** @@ -32,22 +40,25 @@ export const createAiKeySaveParams = ( provider: string; // The API key value to save value: string; + sync?: AiKeySyncMode; + targetNodes?: string[]; + dryRun?: boolean; } -): AiKeySaveParams => createPayload(context, sessionId, { - userId: SYSTEM_SCOPES.SYSTEM, - +): AiKeySaveParams => createAiKeyParams(context, sessionId, { ...data }); /** * Ai Key Save Command Result */ -export interface AiKeySaveResult extends CommandResult { - success: boolean; +export interface AiKeySaveResult extends AiKeyResult { // Whether the key was saved successfully saved: boolean; // The config key name that was saved provider: string; + synced?: boolean; + syncMode?: AiKeySyncMode; + targetNodes?: string[]; error?: JTAGError; } @@ -63,9 +74,13 @@ export const createAiKeySaveResult = ( saved?: boolean; // The config key name that was saved provider?: string; + synced?: boolean; + syncMode?: AiKeySyncMode; + targetNodes?: string[]; + mergePlanId?: string; error?: JTAGError; } -): AiKeySaveResult => createPayload(context, sessionId, { +): AiKeySaveResult => createAiKeyResult(context, sessionId, { saved: data.saved ?? false, provider: data.provider ?? '', ...data diff --git a/src/commands/ai/key/status/.npmignore b/src/commands/ai/key/status/.npmignore new file mode 100644 index 000000000..f74ad6b8a --- /dev/null +++ b/src/commands/ai/key/status/.npmignore @@ -0,0 +1,20 @@ +# Development files +.eslintrc* +tsconfig*.json +vitest.config.ts + +# Build artifacts +*.js.map +*.d.ts.map + +# IDE +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db diff --git a/src/commands/ai/key/status/README.md b/src/commands/ai/key/status/README.md new file mode 100644 index 000000000..60c9b6374 --- /dev/null +++ b/src/commands/ai/key/status/README.md @@ -0,0 +1,164 @@ +# Ai Key Status Command + +Report redacted API-key availability and fingerprints without exposing raw or masked secret values. + +## Table of Contents + +- [Usage](#usage) + - [CLI Usage](#cli-usage) + - [Tool Usage](#tool-usage) +- [Parameters](#parameters) +- [Result](#result) +- [Examples](#examples) +- [Testing](#testing) + - [Unit Tests](#unit-tests) + - [Integration Tests](#integration-tests) +- [Getting Help](#getting-help) +- [Access Level](#access-level) +- [Implementation Notes](#implementation-notes) + +## Usage + +### CLI Usage + +From the command line using the jtag CLI: + +```bash +./jtag ai/key/status [options] +``` + +### Tool Usage + +From Persona tools or programmatic access using `Commands.execute()`: + +```typescript +import { Commands } from '@system/core/shared/Commands'; + +const result = await Commands.execute('ai/key/status', { + // your parameters here +}); +``` + +## Parameters + +- **provider** (optional): `string` - Optional provider name or config key. Omit to list all known keys. + +## Result + +Returns `AiKeyStatusResult` with: + +Returns CommandResult with: +- **entries**: `array` - Redacted key status entries containing provider names, config key names, booleans, source, and short fingerprints only. +- **configuredCount**: `number` - Number of configured keys. +- **totalCount**: `number` - Number of checked keys. + +## Examples + +### List all known AI key statuses + +```bash +./jtag ai/key/status +``` + +**Expected result:** +{ success: true, configuredCount: 1, totalCount: 11 } + +### Check one provider by config key + +```bash +./jtag ai/key/status --provider=OPENAI_API_KEY +``` + +**Expected result:** +{ success: true, configuredCount: 1, totalCount: 1 } + +## Getting Help + +### Using the Help Tool + +Get detailed usage information for this command: + +**CLI:** +```bash +./jtag help ai/key/status +``` + +**Tool:** +```typescript +// Use your help tool with command name 'ai/key/status' +``` + +### Using the README Tool + +Access this README programmatically: + +**CLI:** +```bash +./jtag readme ai/key/status +``` + +**Tool:** +```typescript +// Use your readme tool with command name 'ai/key/status' +``` + +## Testing + +### Unit Tests + +Test command logic in isolation using mock dependencies: + +```bash +# Run unit tests (no server required) +npx tsx commands/Ai Key Status/test/unit/AiKeyStatusCommand.test.ts +``` + +**What's tested:** +- Command structure and parameter validation +- Mock command execution patterns +- Required parameter validation (throws ValidationError) +- Optional parameter handling (sensible defaults) +- Performance requirements +- Assertion utility helpers + +**TDD Workflow:** +1. Write/modify unit test first (test-driven development) +2. Run test, see it fail +3. Implement feature +4. Run test, see it pass +5. Refactor if needed + +### Integration Tests + +Test command with real client connections and system integration: + +```bash +# Prerequisites: Server must be running +npm start # Wait 90+ seconds for deployment + +# Run integration tests +npx tsx commands/Ai Key Status/test/integration/AiKeyStatusIntegration.test.ts +``` + +**What's tested:** +- Client connection to live system +- Real command execution via WebSocket +- ValidationError handling for missing params +- Optional parameter defaults +- Performance under load +- Various parameter combinations + +**Best Practice:** +Run unit tests frequently during development (fast feedback). Run integration tests before committing (verify system integration). + +## Access Level + +**owner-only** - Unknown access level + +## Implementation Notes + +- **Shared Logic**: Core business logic in `shared/AiKeyStatusTypes.ts` +- **Browser**: Browser-specific implementation in `browser/AiKeyStatusBrowserCommand.ts` +- **Server**: Server-specific implementation in `server/AiKeyStatusServerCommand.ts` +- **Unit Tests**: Isolated testing in `test/unit/AiKeyStatusCommand.test.ts` +- **Integration Tests**: System testing in `test/integration/AiKeyStatusIntegration.test.ts` diff --git a/src/commands/ai/key/status/browser/AiKeyStatusBrowserCommand.ts b/src/commands/ai/key/status/browser/AiKeyStatusBrowserCommand.ts new file mode 100644 index 000000000..0c56b8bfc --- /dev/null +++ b/src/commands/ai/key/status/browser/AiKeyStatusBrowserCommand.ts @@ -0,0 +1,21 @@ +/** + * Ai Key Status Command - Browser Implementation + * + * Report redacted API-key availability and fingerprints without exposing raw or masked secret values. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { AiKeyStatusParams, AiKeyStatusResult } from '../shared/AiKeyStatusTypes'; + +export class AiKeyStatusBrowserCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('ai/key/status', context, subpath, commander); + } + + async execute(params: AiKeyStatusParams): Promise { + console.log('🌐 BROWSER: Delegating Ai Key Status to server'); + return await this.remoteExecute(params); + } +} diff --git a/src/commands/ai/key/status/package.json b/src/commands/ai/key/status/package.json new file mode 100644 index 000000000..74b5b287b --- /dev/null +++ b/src/commands/ai/key/status/package.json @@ -0,0 +1,35 @@ +{ + "name": "@jtag-commands/ai/key/status", + "version": "1.0.0", + "description": "Report redacted API-key availability and fingerprints without exposing raw or masked secret values.", + "main": "server/AiKeyStatusServerCommand.ts", + "types": "shared/AiKeyStatusTypes.ts", + "scripts": { + "test": "npm run test:unit && npm run test:integration", + "test:unit": "npx vitest run test/unit/*.test.ts", + "test:integration": "npx tsx test/integration/AiKeyStatusIntegration.test.ts", + "lint": "npx eslint **/*.ts", + "typecheck": "npx tsc --noEmit" + }, + "peerDependencies": { + "@jtag/core": "*" + }, + "files": [ + "shared/**/*.ts", + "browser/**/*.ts", + "server/**/*.ts", + "test/**/*.ts", + "README.md" + ], + "keywords": [ + "jtag", + "command", + "ai/key/status" + ], + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "" + } +} diff --git a/src/commands/ai/key/status/server/AiKeyStatusServerCommand.ts b/src/commands/ai/key/status/server/AiKeyStatusServerCommand.ts new file mode 100644 index 000000000..e29a0f4b0 --- /dev/null +++ b/src/commands/ai/key/status/server/AiKeyStatusServerCommand.ts @@ -0,0 +1,60 @@ +/** + * Ai Key Status Command - Server Implementation + * + * Report redacted API-key availability and fingerprints without exposing raw or masked secret values. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import { ValidationError } from '@system/core/types/ErrorTypes'; +import { SecretManager } from '@system/secrets/SecretManager'; +import type { AiKeyStatusParams, AiKeyStatusResult } from '../shared/AiKeyStatusTypes'; +import { createAiKeyStatusResultFromParams } from '../shared/AiKeyStatusTypes'; +import { createAiKeyStatusEntry } from '../shared/AiKeyStatusRedaction'; +import { AI_KEY_PROVIDERS, findAiKeyProvider, type AiKeyProviderMetadata } from '../../common/AiKeyProviders'; + +export class AiKeyStatusServerCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('ai/key/status', context, subpath, commander); + } + + async execute(params: AiKeyStatusParams): Promise { + const secrets = SecretManager.getInstance(); + const requestedProvider = params.provider?.trim(); + + const providers: AiKeyProviderMetadata[] = requestedProvider + ? [findAiKeyProvider(requestedProvider)].filter((provider): provider is AiKeyProviderMetadata => provider !== undefined) + : [...AI_KEY_PROVIDERS]; + + if (requestedProvider && providers.length === 0) { + throw new ValidationError( + 'provider', + `Unknown API key provider '${requestedProvider}'. Use a provider name or config key like OPENAI_API_KEY.` + ); + } + + const entries = providers.map(provider => { + const value = provider.category === 'local' + ? process.env[provider.key] + : secrets.get(provider.key, 'AiKeyStatusServerCommand'); + + return createAiKeyStatusEntry({ + provider: provider.provider, + key: provider.key, + category: provider.category, + description: provider.description, + value, + processValue: process.env[provider.key] + }); + }); + + return createAiKeyStatusResultFromParams(params, { + success: true, + provider: requestedProvider, + entries, + configuredCount: entries.filter(entry => entry.configured).length, + totalCount: entries.length, + }); + } +} diff --git a/src/commands/ai/key/status/shared/AiKeyStatusRedaction.ts b/src/commands/ai/key/status/shared/AiKeyStatusRedaction.ts new file mode 100644 index 000000000..7f7b3e08b --- /dev/null +++ b/src/commands/ai/key/status/shared/AiKeyStatusRedaction.ts @@ -0,0 +1,50 @@ +/** + * Redacted API-key status helpers. + * + * The fingerprint is for equality checks across nodes during diff/reconcile. + * It is intentionally short and keyed by config name, and it must never be + * treated as a credential. + */ + +import { createHash } from 'crypto'; +import type { AiKeyCategory } from '../../common/AiKeyProviders'; +import type { AiKeyStatusEntry } from './AiKeyStatusTypes'; + +export function fingerprintAiKey(keyName: string, value: string): string | undefined { + const normalizedValue = value.trim(); + if (normalizedValue.length === 0) { + return undefined; + } + + return createHash('sha256') + .update(keyName) + .update('\0') + .update(normalizedValue) + .digest('hex') + .slice(0, 16); +} + +export function createAiKeyStatusEntry(data: { + provider: string; + key: string; + category: AiKeyCategory; + description: string; + value?: string; + processValue?: string; +}): AiKeyStatusEntry { + const value = data.value?.trim(); + const processValue = data.processValue?.trim(); + const configuredValue = value !== undefined && value.length > 0 ? value : processValue; + const configured = (configuredValue?.length ?? 0) > 0; + + return { + provider: data.provider, + key: data.key, + category: data.category, + description: data.description, + configured, + empty: !configured, + fingerprint: configuredValue ? fingerprintAiKey(data.key, configuredValue) : undefined, + source: value ? 'continuum-home' : processValue ? 'process-env' : 'missing' + }; +} diff --git a/src/commands/ai/key/status/shared/AiKeyStatusTypes.ts b/src/commands/ai/key/status/shared/AiKeyStatusTypes.ts new file mode 100644 index 000000000..d519b70ea --- /dev/null +++ b/src/commands/ai/key/status/shared/AiKeyStatusTypes.ts @@ -0,0 +1,109 @@ +/** + * Ai Key Status Command - Shared Types + * + * Report redacted API-key availability and fingerprints without exposing raw or masked secret values. + */ + +import type { CommandInput, CommandParams, JTAGContext } from '@system/core/types/JTAGTypes'; +import { transformPayload } from '@system/core/types/JTAGTypes'; +import { Commands } from '@system/core/shared/Commands'; +import type { JTAGError } from '@system/core/types/ErrorTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; +import { + type AiKeyParams, + type AiKeyResult, + createAiKeyParams, + createAiKeyResult +} from '../../common/AiKeyBase'; +import type { AiKeyCategory } from '../../common/AiKeyProviders'; + +/** + * Ai Key Status Command Parameters + */ +export interface AiKeyStatusParams extends CommandParams, AiKeyParams { + // Optional provider name or config key. Omit to list all known keys. + provider?: string; +} + +/** + * Factory function for creating AiKeyStatusParams + */ +export const createAiKeyStatusParams = ( + context: JTAGContext, + sessionId: UUID, + data: { + // Optional provider name or config key. Omit to list all known keys. + provider?: string; + }, +): AiKeyStatusParams => createAiKeyParams(context, sessionId, data); + +export interface AiKeyStatusEntry { + provider: string; + key: string; + category: AiKeyCategory; + configured: boolean; + empty: boolean; + fingerprint?: string; + source: 'continuum-home' | 'process-env' | 'missing'; + description: string; +} + +/** + * Ai Key Status Command Result + */ +export interface AiKeyStatusResult extends AiKeyResult { + // Redacted key status entries containing provider names, config key names, booleans, source, and short fingerprints only. + entries: AiKeyStatusEntry[]; + // Number of configured keys. + configuredCount: number; + // Number of checked keys. + totalCount: number; + error?: JTAGError; +} + +/** + * Factory function for creating AiKeyStatusResult with defaults + */ +export const createAiKeyStatusResult = ( + context: JTAGContext, + sessionId: UUID, + data: { + success: boolean; + // Redacted key status entries containing provider names, config key names, booleans, source, and short fingerprints only. + entries?: AiKeyStatusEntry[]; + // Number of configured keys. + configuredCount?: number; + // Number of checked keys. + totalCount?: number; + error?: JTAGError; + } +): AiKeyStatusResult => createAiKeyResult(context, sessionId, { + entries: data.entries ?? [], + configuredCount: data.configuredCount ?? 0, + totalCount: data.totalCount ?? 0, + ...data +}); + +/** + * Smart Ai Key Status-specific inheritance from params + * Auto-inherits context and sessionId from params + * Must provide all required result fields + */ +export const createAiKeyStatusResultFromParams = ( + params: AiKeyStatusParams, + differences: Omit +): AiKeyStatusResult => transformPayload(params, differences); + +/** + * Ai Key Status — Type-safe command executor + * + * Usage: + * import { AiKeyStatus } from '...shared/AiKeyStatusTypes'; + * const result = await AiKeyStatus.execute({ ... }); + */ +export const AiKeyStatus = { + execute(params: CommandInput): Promise { + return Commands.execute('ai/key/status', params as Partial); + }, + commandName: 'ai/key/status' as const, +} as const; diff --git a/src/commands/ai/key/status/test/integration/AiKeyStatusIntegration.test.ts b/src/commands/ai/key/status/test/integration/AiKeyStatusIntegration.test.ts new file mode 100644 index 000000000..72933f129 --- /dev/null +++ b/src/commands/ai/key/status/test/integration/AiKeyStatusIntegration.test.ts @@ -0,0 +1,18 @@ +#!/usr/bin/env tsx + +import { generateUUID } from '@system/core/types/CrossPlatformUUID'; +import { createAiKeyStatusResult } from '../../shared/AiKeyStatusTypes'; + +const context = { environment: 'server' as const }; +const sessionId = generateUUID(); +const result = createAiKeyStatusResult(context, sessionId, { + success: true, + configuredCount: 0, + totalCount: 0 +}); + +if (!result.success || result.entries.length !== 0 || result.totalCount !== 0) { + throw new Error('AiKeyStatus result factory did not apply defaults correctly'); +} + +console.log('AiKeyStatus integration smoke passed'); diff --git a/src/commands/ai/key/status/test/unit/AiKeyStatusCommand.test.ts b/src/commands/ai/key/status/test/unit/AiKeyStatusCommand.test.ts new file mode 100644 index 000000000..a617b60f6 --- /dev/null +++ b/src/commands/ai/key/status/test/unit/AiKeyStatusCommand.test.ts @@ -0,0 +1,61 @@ +#!/usr/bin/env tsx + +import { generateUUID } from '@system/core/types/CrossPlatformUUID'; +import { createAiKeyStatusResult } from '../../shared/AiKeyStatusTypes'; +import { createAiKeyStatusEntry, fingerprintAiKey } from '../../shared/AiKeyStatusRedaction'; + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(message); + } +} + +const secret = 'sk-test-secret-value-1234567890'; +const fingerprint = fingerprintAiKey('OPENAI_API_KEY', secret); + +assert(fingerprint !== undefined, 'non-empty values produce fingerprints'); +assert(fingerprint !== secret, 'fingerprint is not the secret value'); +assert(!fingerprint?.includes('sk-test'), 'fingerprint does not include key prefix'); + +const entry = createAiKeyStatusEntry({ + provider: 'OpenAI', + key: 'OPENAI_API_KEY', + category: 'cloud', + description: 'GPT models', + value: secret +}); + +const serialized = JSON.stringify(entry); + +assert(entry.configured === true, 'configured is true for non-empty keys'); +assert(entry.empty === false, 'empty is false for non-empty keys'); +assert(entry.source === 'continuum-home', 'home config wins as source'); +assert(!serialized.includes(secret), 'status entry never serializes raw secret'); +assert(!serialized.includes(secret.slice(0, 7)), 'status entry never serializes masked prefix'); +assert(!serialized.includes(secret.slice(-4)), 'status entry never serializes masked suffix'); + +const emptyEntry = createAiKeyStatusEntry({ + provider: 'OpenAI', + key: 'OPENAI_API_KEY', + category: 'cloud', + description: 'GPT models', + value: '' +}); + +assert(emptyEntry.configured === false, 'empty values are not configured'); +assert(emptyEntry.fingerprint === undefined, 'empty values have no fingerprint'); + +const context = { environment: 'server' as const }; +const sessionId = generateUUID(); +const result = createAiKeyStatusResult(context, sessionId, { + success: true, + entries: [entry], + configuredCount: 1, + totalCount: 1 +}); + +assert(result.success === true, 'result factory preserves success'); +assert(result.entries.length === 1, 'result factory preserves entries'); +assert(result.configuredCount === 1, 'result factory preserves configured count'); + +console.log('AiKeyStatus command tests passed'); diff --git a/src/commands/ai/key/test/shared/AiKeyTestTypes.ts b/src/commands/ai/key/test/shared/AiKeyTestTypes.ts index ff2b9773c..f9c3253a3 100644 --- a/src/commands/ai/key/test/shared/AiKeyTestTypes.ts +++ b/src/commands/ai/key/test/shared/AiKeyTestTypes.ts @@ -4,17 +4,21 @@ * Test an API key before saving it. Makes a minimal API call to verify the key is valid and has sufficient permissions. */ -import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; -import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; -import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; -import type { JTAGError } from '@system/core/types/ErrorTypes'; +import type { JTAGContext, CommandInput, CommandParams } from '@system/core/types/JTAGTypes'; +import { transformPayload } from '@system/core/types/JTAGTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../../system/core/shared/Commands'; +import { + type AiKeyParams, + type AiKeyResult, + createAiKeyParams, + createAiKeyResult +} from '../../common/AiKeyBase'; /** * Ai Key Test Command Parameters */ -export interface AiKeyTestParams extends CommandParams { +export interface AiKeyTestParams extends CommandParams, AiKeyParams { // Provider to test (anthropic, openai, groq, deepseek, xai, together, fireworks) provider: string; // API key to test (will NOT be stored) @@ -34,18 +38,16 @@ export const createAiKeyTestParams = ( provider: string; // API key to test (will NOT be stored) key: string; + useStored?: boolean; } -): AiKeyTestParams => createPayload(context, sessionId, { - userId: SYSTEM_SCOPES.SYSTEM, - +): AiKeyTestParams => createAiKeyParams(context, sessionId, { ...data }); /** * Ai Key Test Command Result */ -export interface AiKeyTestResult extends CommandResult { - success: boolean; +export interface AiKeyTestResult extends AiKeyResult { // Whether the key is valid valid: boolean; // Provider that was tested @@ -72,8 +74,7 @@ export const createAiKeyTestResult = ( errorMessage?: string; models?: string[]; } -): AiKeyTestResult => createPayload(context, sessionId, { - userId: SYSTEM_SCOPES.SYSTEM, +): AiKeyTestResult => createAiKeyResult(context, sessionId, { valid: data.valid ?? false, provider: data.provider ?? '', responseTimeMs: data.responseTimeMs ?? 0, diff --git a/src/commands/development/generate/README.md b/src/commands/development/generate/README.md index efb775d04..8f74a80e6 100644 --- a/src/commands/development/generate/README.md +++ b/src/commands/development/generate/README.md @@ -4,6 +4,12 @@ Generate new commands, daemons, or widgets using templates and CommandSpec defin ## Quick Start (Most Common Use Case) +**Rule:** new commands must be created from `src/generator/specs/*.json` +through Continuum's command generator. Do not manually scaffold command +folders, types, browser wrappers, server wrappers, package metadata, tests, or +README files. Manual edits happen after generation, only for command-specific +behavior the template cannot infer. + ```bash # 1. Get a template to understand the spec format ./jtag generate --template=true > /tmp/my-command-spec.json diff --git a/src/eslint.config.js b/src/eslint.config.js index b8d7347f3..f21c691a9 100644 --- a/src/eslint.config.js +++ b/src/eslint.config.js @@ -9,7 +9,7 @@ export default tseslint.config( { languageOptions: { parserOptions: { - project: './tsconfig.json', + project: './tsconfig.eslint.json', }, }, rules: { @@ -45,6 +45,7 @@ export default tseslint.config( '**/*.d.ts', '**/*.js', '**/*.mjs', + '**/test/**/*.ts', 'examples/**', 'scripts/**', 'generated-command-schemas.json', diff --git a/src/generator/generate-command-constants.ts b/src/generator/generate-command-constants.ts index 10ba22952..eefbb5695 100644 --- a/src/generator/generate-command-constants.ts +++ b/src/generator/generate-command-constants.ts @@ -87,7 +87,7 @@ class CommandConstantsGenerator { const basePath = commandPathMatch[1]; // Find ALL *Params interfaces that extend CommandParams - const paramsInterfaceRegex = /export\s+interface\s+(\w+Params)\s+extends\s+(\w+)\s*\{/g; + const paramsInterfaceRegex = /export\s+interface\s+(\w+Params)\s+extends\s+([^{]+?)\s*\{/g; const commandNames: string[] = []; let match; diff --git a/src/generator/generate-command-schemas.ts b/src/generator/generate-command-schemas.ts index 36e5b2276..1b06a34f7 100644 --- a/src/generator/generate-command-schemas.ts +++ b/src/generator/generate-command-schemas.ts @@ -26,7 +26,7 @@ * - Type-safe by design (can't get out of sync) */ -import { readFileSync, readdirSync, statSync, existsSync } from 'fs'; +import { readFileSync, existsSync } from 'fs'; import { writeIfChanged } from './core/writeIfChanged'; import { join, relative } from 'path'; import * as glob from 'glob'; @@ -150,7 +150,7 @@ class CommandSchemaGenerator { const byName = new Map(); for (const schema of schemas) { - const group = byName.get(schema.name) || []; + const group = byName.get(schema.name) ?? []; group.push(schema); byName.set(schema.name, group); } @@ -224,19 +224,19 @@ class CommandSchemaGenerator { // Find ALL *Params interfaces that extend CommandParams (or base interfaces that do) // FIXED: Use brace counting instead of naive ([^}]+) which stops at first } // This regex finds the interface START, then we use extractInterfaceBody for the body - const paramsInterfaceStartRegex = /export\s+interface\s+(\w+Params)\s+extends\s+(\w+)\s*\{/g; + const paramsInterfaceStartRegex = /export\s+interface\s+(\w+Params)\s+extends\s+([^{]+?)\s*\{/g; const schemas: CommandSchema[] = []; // First pass: collect all params names to detect multi-interface files const allInterfaceNames: string[] = []; - const interfaceMatches: Array<{ interfaceName: string; parentInterface: string; index: number }> = []; + const interfaceMatches: Array<{ interfaceName: string; parentInterfaces: string[]; index: number }> = []; let match; while ((match = paramsInterfaceStartRegex.exec(content)) !== null) { allInterfaceNames.push(match[1]); interfaceMatches.push({ interfaceName: match[1], - parentInterface: match[2], + parentInterfaces: this.parseParentInterfaces(match[2]), index: match.index }); } @@ -265,7 +265,7 @@ class CommandSchemaGenerator { } // Second pass: process each interface - for (const { interfaceName, parentInterface, index } of interfaceMatches) { + for (const { interfaceName, parentInterfaces, index } of interfaceMatches) { // Use brace counting to extract full body including nested objects const interfaceBody = this.extractInterfaceBody(content, index); @@ -277,15 +277,15 @@ class CommandSchemaGenerator { // Check if this extends CommandParams directly or through an intermediate interface let allParams: Record = {}; - if (parentInterface !== 'CommandParams') { + if (!parentInterfaces.includes('CommandParams')) { // Double inheritance - need to find parent interface in same file - const parentParams = this.extractParentParams(content, parentInterface); - if (parentParams === null) { - console.warn(` ⚠️ Parent interface ${parentInterface} not found or doesn't extend CommandParams: ${interfaceName}`); + const parentParamSets = parentInterfaces.map(parentInterface => this.extractParentParams(content, parentInterface)); + if (parentParamSets.some(parentParams => parentParams === null)) { + console.warn(` ⚠️ Parent interface ${parentInterfaces.join(', ')} not found or doesn't extend CommandParams: ${interfaceName}`); continue; } // Merge parent params - allParams = { ...parentParams }; + allParams = Object.assign({}, ...parentParamSets); } // Extract description: prefer README first paragraph, fall back to cleaned JSDoc @@ -294,7 +294,7 @@ class CommandSchemaGenerator { const description = readmeDesc || jsdocDesc; // Extract parameters from this interface body and merge with parent - const params = this.extractParams(interfaceBody, content, index); + const params = this.extractParams(interfaceBody); allParams = { ...allParams, ...params }; schemas.push({ @@ -311,6 +311,13 @@ class CommandSchemaGenerator { return schemas; } + private parseParentInterfaces(parentInterfaces: string): string[] { + return parentInterfaces + .split(',') + .map(parentInterface => parentInterface.trim().replace(/^type\s+/, '')) + .filter(Boolean); + } + /** * Derive command name from Params interface name and base path * @@ -382,19 +389,19 @@ class CommandSchemaGenerator { // Pattern 1: export interface Foo extends Bar { ... } // Pattern 2: export interface Foo { ... } const parentWithExtendsStartRegex = new RegExp( - `export\\s+interface\\s+${parentInterfaceName}\\s+extends\\s+(\\w+)\\s*\\{` + `export\\s+interface\\s+${parentInterfaceName}\\s+extends\\s+([^\\{]+?)\\s*\\{` ); const parentStandaloneStartRegex = new RegExp( `export\\s+interface\\s+${parentInterfaceName}\\s*\\{` ); - let grandparentInterface: string | null = null; + let grandparentInterfaces: string[] = []; let parentBody: string; const withExtendsMatch = content.match(parentWithExtendsStartRegex); if (withExtendsMatch && withExtendsMatch.index !== undefined) { // Has extends clause - extract grandparent and use brace counting for body - grandparentInterface = withExtendsMatch[1]; + grandparentInterfaces = this.parseParentInterfaces(withExtendsMatch[1]); parentBody = this.extractInterfaceBody(content, withExtendsMatch.index); } else { // Try standalone interface @@ -403,11 +410,11 @@ class CommandSchemaGenerator { return null; } parentBody = this.extractInterfaceBody(content, standaloneMatch.index); - grandparentInterface = null; // No grandparent + grandparentInterfaces = []; // No grandparent } // Extract params from this parent's body - const parentParams = this.extractParams(parentBody, content, 0); + const parentParams = this.extractParams(parentBody); // Check if this interface has required fields (context and sessionId) const hasContext = parentBody.includes('context:'); @@ -419,13 +426,13 @@ class CommandSchemaGenerator { } // If no required fields, check if it extends something else - if (grandparentInterface) { - const grandparentParams = this.extractParentParams(content, grandparentInterface, visited); - if (grandparentParams === null) { + if (grandparentInterfaces.length > 0) { + const grandparentParamSets = grandparentInterfaces.map(grandparentInterface => this.extractParentParams(content, grandparentInterface, visited)); + if (grandparentParamSets.some(grandparentParams => grandparentParams === null)) { return null; } // Merge grandparent params with parent params - return { ...grandparentParams, ...parentParams }; + return { ...Object.assign({}, ...grandparentParamSets), ...parentParams }; } // No extends, no required fields = invalid @@ -528,7 +535,7 @@ class CommandSchemaGenerator { /** * Extract parameters from interface body */ - private extractParams(interfaceBody: string, fullContent: string, interfaceStart: number): Record { + private extractParams(interfaceBody: string): Record { const params: Record = {}; // Match property definitions: propertyName?: type; diff --git a/src/generator/specs/ai-key-status.json b/src/generator/specs/ai-key-status.json new file mode 100644 index 000000000..fdadbf684 --- /dev/null +++ b/src/generator/specs/ai-key-status.json @@ -0,0 +1,42 @@ +{ + "name": "ai/key/status", + "description": "Report redacted API-key availability and fingerprints without exposing raw or masked secret values.", + "params": [ + { + "name": "provider", + "type": "string", + "optional": true, + "description": "Optional provider name or config key. Omit to list all known keys." + } + ], + "results": [ + { + "name": "entries", + "type": "array", + "description": "Redacted key status entries containing provider names, config key names, booleans, source, and short fingerprints only." + }, + { + "name": "configuredCount", + "type": "number", + "description": "Number of configured keys." + }, + { + "name": "totalCount", + "type": "number", + "description": "Number of checked keys." + } + ], + "examples": [ + { + "description": "List all known AI key statuses", + "command": "./jtag ai/key/status", + "expectedResult": "{ success: true, configuredCount: 1, totalCount: 11 }" + }, + { + "description": "Check one provider by config key", + "command": "./jtag ai/key/status --provider=OPENAI_API_KEY", + "expectedResult": "{ success: true, configuredCount: 1, totalCount: 1 }" + } + ], + "accessLevel": "owner-only" +} diff --git a/src/tsconfig.eslint.json b/src/tsconfig.eslint.json new file mode 100644 index 000000000..4d61a8db8 --- /dev/null +++ b/src/tsconfig.eslint.json @@ -0,0 +1,35 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": [ + "cli.ts", + "index.ts", + "browser-index.ts", + "server-index.ts", + "api/**/*.ts", + "browser/**/*.ts", + "server/**/*.ts", + "shared/**/*.ts", + "daemons/**/*.ts", + "commands/**/*.ts", + "generator/generate-command-constants.ts", + "generator/generate-command-schemas.ts", + "widgets/**/*.ts", + "tests/workers/**/*.ts", + "test-path-aliases.ts", + "test-path-aliases-runtime.ts" + ], + "exclude": [ + "node_modules", + "dist", + "workers/vendor/**/*", + "examples/**/*", + "mcp/**/*", + "**/*.test.ts", + "**/*.bak", + "**/*.bak/**/*", + "**/templates/**/*" + ] +}